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

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
environments/review-cleanup-4am6ej/deployments/2594
marcin mikołajczak 2 years ago
commit 4936c3ed38

@ -11,6 +11,7 @@ const config: StorybookConfig = {
'@storybook/addon-links', '@storybook/addon-links',
'@storybook/addon-essentials', '@storybook/addon-essentials',
'@storybook/addon-interactions', '@storybook/addon-interactions',
'storybook-react-intl',
{ {
name: '@storybook/addon-postcss', name: '@storybook/addon-postcss',
options: { options: {

@ -1,12 +0,0 @@
import '../app/styles/tailwind.css';
import '../stories/theme.css';
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
}

@ -0,0 +1,22 @@
import '../app/styles/tailwind.css';
import '../stories/theme.css';
import { addDecorator, Story } from '@storybook/react';
import { IntlProvider } from 'react-intl';
import React from 'react';
const withProvider = (Story: Story) => (
<IntlProvider locale='en'><Story /></IntlProvider>
);
addDecorator(withProvider);
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};

@ -13,12 +13,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Compatibility: improved browser support for older browsers. - Compatibility: improved browser support for older browsers.
- Events: allow to repost events in event menu. - Events: allow to repost events in event menu.
- Groups: Initial support for groups. - Groups: Initial support for groups.
- Profile: Add RSS link to user profiles.
- Reactions: adds support for reacting to chat messages.
- Groups: initial support for groups.
- Profile: add RSS link to user profiles.
- Posts: fix posts filtering.
### Changed ### Changed
- Chats: improved display of media attachments. - Chats: improved display of media attachments.
- ServiceWorker: switch to a network-first strategy. The "An update is available!" prompt goes away. - ServiceWorker: switch to a network-first strategy. The "An update is available!" prompt goes away.
- Posts: increased font size of focused status in threads. - Posts: increased font size of focused status in threads.
- Posts: let "mute conversation" be clicked from any feed, not just noficiations. - Posts: let "mute conversation" be clicked from any feed, not just noficiations.
- Posts: display all emoji reactions.
- Reactions: improved UI of reactions on statuses.
### Fixed ### Fixed
- Chats: media attachments rendering at the wrong size and/or causing the chat to scroll on load. - Chats: media attachments rendering at the wrong size and/or causing the chat to scroll on load.

@ -32,8 +32,8 @@ const getSoapboxConfig = createSelector([
} }
// If RGI reacts aren't supported, strip VS16s // If RGI reacts aren't supported, strip VS16s
// // https://git.pleroma.social/pleroma/pleroma/-/issues/2355 // https://git.pleroma.social/pleroma/pleroma/-/issues/2355
if (!features.emojiReactsRGI) { if (features.emojiReactsNonRGI) {
soapboxConfig.set('allowedEmoji', soapboxConfig.allowedEmoji.map(removeVS16s)); soapboxConfig.set('allowedEmoji', soapboxConfig.allowedEmoji.map(removeVS16s));
} }
}); });

@ -2,7 +2,7 @@ import { getSettings } from 'soapbox/actions/settings';
import messages from 'soapbox/locales/messages'; import messages from 'soapbox/locales/messages';
import { ChatKeys, IChat, isLastMessage } from 'soapbox/queries/chats'; import { ChatKeys, IChat, isLastMessage } from 'soapbox/queries/chats';
import { queryClient } from 'soapbox/queries/client'; import { queryClient } from 'soapbox/queries/client';
import { getUnreadChatsCount, updateChatListItem } from 'soapbox/utils/chats'; import { getUnreadChatsCount, updateChatListItem, updateChatMessage } from 'soapbox/utils/chats';
import { removePageItem } from 'soapbox/utils/queries'; import { removePageItem } from 'soapbox/utils/queries';
import { play, soundCache } from 'soapbox/utils/sounds'; import { play, soundCache } from 'soapbox/utils/sounds';
@ -170,6 +170,9 @@ const connectTimelineStream = (
} }
}); });
break; break;
case 'chat_message.reaction': // TruthSocial
updateChatMessage(JSON.parse(data.payload));
break;
case 'pleroma:follow_relationships_update': case 'pleroma:follow_relationships_update':
dispatch(updateFollowRelationships(JSON.parse(data.payload))); dispatch(updateFollowRelationships(JSON.parse(data.payload)));
break; break;

@ -1,16 +0,0 @@
import React from 'react';
import { render, screen } from '../../jest/test-helpers';
import EmojiSelector from '../emoji-selector';
describe('<EmojiSelector />', () => {
it('renders correctly', () => {
const children = <EmojiSelector />;
// @ts-ignore
children.__proto__.addEventListener = () => {};
render(children);
expect(screen.queryAllByRole('button')).toHaveLength(6);
});
});

@ -1,54 +0,0 @@
import React from 'react';
import { HotKeys } from 'react-hotkeys';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { EmojiSelector as RealEmojiSelector } from 'soapbox/components/ui';
import type { List as ImmutableList } from 'immutable';
import type { RootState } from 'soapbox/store';
const mapStateToProps = (state: RootState) => ({
allowedEmoji: getSoapboxConfig(state).allowedEmoji,
});
interface IEmojiSelector {
allowedEmoji: ImmutableList<string>,
onReact: (emoji: string) => void,
onUnfocus: () => void,
visible: boolean,
focused?: boolean,
}
class EmojiSelector extends ImmutablePureComponent<IEmojiSelector> {
static defaultProps: Partial<IEmojiSelector> = {
onReact: () => { },
onUnfocus: () => { },
visible: false,
};
handlers = {
open: () => { },
};
render() {
const { visible, focused, allowedEmoji, onReact, onUnfocus } = this.props;
return (
<HotKeys handlers={this.handlers}>
<RealEmojiSelector
emojis={allowedEmoji.toArray()}
onReact={onReact}
visible={visible}
focused={focused}
onUnfocus={onUnfocus}
/>
</HotKeys>
);
}
}
export default connect(mapStateToProps)(EmojiSelector);

@ -14,8 +14,8 @@ import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions
import { initMuteModal } from 'soapbox/actions/mutes'; import { initMuteModal } from 'soapbox/actions/mutes';
import { initReport } from 'soapbox/actions/reports'; import { initReport } from 'soapbox/actions/reports';
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses'; import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper';
import StatusActionButton from 'soapbox/components/status-action-button'; import StatusActionButton from 'soapbox/components/status-action-button';
import StatusReactionWrapper from 'soapbox/components/status-reaction-wrapper';
import { HStack } from 'soapbox/components/ui'; import { HStack } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container'; import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
@ -629,7 +629,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
)} )}
{features.emojiReacts ? ( {features.emojiReacts ? (
<EmojiButtonWrapper statusId={status.id}> <StatusReactionWrapper statusId={status.id}>
<StatusActionButton <StatusActionButton
title={meEmojiTitle} title={meEmojiTitle}
icon={require('@tabler/icons/heart.svg')} icon={require('@tabler/icons/heart.svg')}
@ -640,7 +640,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
emoji={meEmojiReact} emoji={meEmojiReact}
text={withLabels ? meEmojiTitle : undefined} text={withLabels ? meEmojiTitle : undefined}
/> />
</EmojiButtonWrapper> </StatusReactionWrapper>
) : ( ) : (
<StatusActionButton <StatusActionButton
title={intl.formatMessage(messages.favourite)} title={intl.formatMessage(messages.favourite)}

@ -1,6 +1,4 @@
import clsx from 'clsx';
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { usePopper } from 'react-popper';
import { simpleEmojiReact } from 'soapbox/actions/emoji-reacts'; import { simpleEmojiReact } from 'soapbox/actions/emoji-reacts';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
@ -9,13 +7,13 @@ import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig } from
import { isUserTouching } from 'soapbox/is-mobile'; import { isUserTouching } from 'soapbox/is-mobile';
import { getReactForStatus } from 'soapbox/utils/emoji-reacts'; import { getReactForStatus } from 'soapbox/utils/emoji-reacts';
interface IEmojiButtonWrapper { interface IStatusReactionWrapper {
statusId: string, statusId: string,
children: JSX.Element, children: JSX.Element,
} }
/** Provides emoji reaction functionality to the underlying button component */ /** Provides emoji reaction functionality to the underlying button component */
const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children }): JSX.Element | null => { const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, children }): JSX.Element | null => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const ownAccount = useOwnAccount(); const ownAccount = useOwnAccount();
const status = useAppSelector(state => state.statuses.get(statusId)); const status = useAppSelector(state => state.statuses.get(statusId));
@ -23,24 +21,8 @@ const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children
const timeout = useRef<NodeJS.Timeout>(); const timeout = useRef<NodeJS.Timeout>();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
// const [focused, setFocused] = useState(false);
// `useRef` won't trigger a re-render, while `useState` does.
// https://popper.js.org/react-popper/v2/
const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: 'top-start',
modifiers: [
{
name: 'offset',
options: {
offset: [-10, 0],
},
},
],
});
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -116,29 +98,6 @@ const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children
})); }));
}; };
const handleUnfocus: React.EventHandler<React.KeyboardEvent> = () => {
setVisible(false);
};
const selector = (
<div
className={clsx('z-50 transition-opacity duration-100', {
'opacity-0 pointer-events-none': !visible,
})}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<EmojiSelector
emojis={soapboxConfig.allowedEmoji}
onReact={handleReact}
visible={visible}
// focused={focused}
onUnfocus={handleUnfocus}
/>
</div>
);
return ( return (
<div className='relative' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}> <div className='relative' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
{React.cloneElement(children, { {React.cloneElement(children, {
@ -146,9 +105,14 @@ const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children
ref: setReferenceElement, ref: setReferenceElement,
})} })}
{selector} <EmojiSelector
placement='top-start'
referenceElement={referenceElement}
onReact={handleReact}
visible={visible}
/>
</div> </div>
); );
}; };
export default EmojiButtonWrapper; export default StatusReactionWrapper;

@ -289,8 +289,10 @@ const Status: React.FC<IStatus> = (props) => {
return ( return (
<HotKeys handlers={minHandlers}> <HotKeys handlers={minHandlers}>
<div className={clsx('status__wrapper', 'status__wrapper--filtered', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}> <div className={clsx('status__wrapper text-center', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' /> <Text theme='muted'>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
</Text>
</div> </div>
</HotKeys> </HotKeys>
); );

@ -1,15 +1,16 @@
import { Placement } from '@popperjs/core';
import clsx from 'clsx'; import clsx from 'clsx';
import React, { useRef } from 'react'; import React, { useEffect, useState } from 'react';
import { usePopper } from 'react-popper';
import { Emoji, HStack } from 'soapbox/components/ui'; import { Emoji, HStack } from 'soapbox/components/ui';
import { useSoapboxConfig } from 'soapbox/hooks';
interface IEmojiButton { interface IEmojiButton {
/** Unicode emoji character. */ /** Unicode emoji character. */
emoji: string, emoji: string,
/** Event handler when the emoji is clicked. */ /** Event handler when the emoji is clicked. */
onClick: React.EventHandler<React.MouseEvent>, onClick(emoji: string): void
/** Keyboard event handler. */
onKeyDown?: React.EventHandler<React.KeyboardEvent>,
/** Extra class name on the <button> element. */ /** Extra class name on the <button> element. */
className?: string, className?: string,
/** Tab order of the button. */ /** Tab order of the button. */
@ -17,104 +18,104 @@ interface IEmojiButton {
} }
/** Clickable emoji button that scales when hovered. */ /** Clickable emoji button that scales when hovered. */
const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, onKeyDown, tabIndex }): JSX.Element => { const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabIndex }): JSX.Element => {
const handleClick: React.EventHandler<React.MouseEvent> = (event) => {
event.preventDefault();
event.stopPropagation();
onClick(emoji);
};
return ( return (
<button className={clsx(className)} onClick={onClick} tabIndex={tabIndex}> <button className={clsx(className)} onClick={handleClick} tabIndex={tabIndex}>
<Emoji className='h-8 w-8 duration-100' emoji={emoji} /> <Emoji className='h-6 w-6 duration-100 hover:scale-110' emoji={emoji} />
</button> </button>
); );
}; };
interface IEmojiSelector { interface IEmojiSelector {
/** List of Unicode emoji characters. */ onClose?(): void
emojis: Iterable<string>,
/** Event handler when an emoji is clicked. */ /** Event handler when an emoji is clicked. */
onReact: (emoji: string) => void, onReact(emoji: string): void
/** Event handler when selector is escaped. */ /** Element that triggers the EmojiSelector Popper */
onUnfocus: React.KeyboardEventHandler<HTMLDivElement>, referenceElement: HTMLElement | null
placement?: Placement
/** Whether the selector should be visible. */ /** Whether the selector should be visible. */
visible?: boolean, visible?: boolean
/** Whether the selector should be focused. */
focused?: boolean,
} }
/** Panel with a row of emoji buttons. */ /** Panel with a row of emoji buttons. */
const EmojiSelector: React.FC<IEmojiSelector> = ({ emojis, onReact, onUnfocus, visible = false, focused = false }): JSX.Element => { const EmojiSelector: React.FC<IEmojiSelector> = ({
const emojiList = Array.from(emojis); referenceElement,
const node = useRef<HTMLDivElement>(null); onClose,
onReact,
const handleReact = (emoji: string): React.EventHandler<React.MouseEvent> => { placement = 'top',
return (e) => { visible = false,
onReact(emoji); }): JSX.Element => {
e.preventDefault(); const soapboxConfig = useSoapboxConfig();
e.stopPropagation();
};
};
const selectPreviousEmoji = (i: number): void => { // `useRef` won't trigger a re-render, while `useState` does.
if (!node.current) return; // https://popper.js.org/react-popper/v2/
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
if (i !== 0) { const handleClickOutside = (event: MouseEvent) => {
const button: HTMLButtonElement | null = node.current.querySelector(`.emoji-react-selector__emoji:nth-child(${i})`); if (referenceElement?.contains(event.target as Node) || popperElement?.contains(event.target as Node)) {
button?.focus(); return;
} else {
const button: HTMLButtonElement | null = node.current.querySelector('.emoji-react-selector__emoji:last-child');
button?.focus();
} }
};
const selectNextEmoji = (i: number) => { if (onClose) {
if (!node.current) return; onClose();
if (i !== emojiList.length - 1) {
const button: HTMLButtonElement | null = node.current.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`);
button?.focus();
} else {
const button: HTMLButtonElement | null = node.current.querySelector('.emoji-react-selector__emoji:first-child');
button?.focus();
} }
}; };
const handleKeyDown = (i: number): React.KeyboardEventHandler<HTMLDivElement> => e => { const { styles, attributes, update } = usePopper(referenceElement, popperElement, {
switch (e.key) { placement,
case 'Enter': modifiers: [
handleReact(emojiList[i])(e as any); {
break; name: 'offset',
case 'Tab': options: {
e.preventDefault(); offset: [-10, 0],
if (e.shiftKey) selectPreviousEmoji(i); },
else selectNextEmoji(i); },
break; ],
case 'Left': });
case 'ArrowLeft':
selectPreviousEmoji(i); useEffect(() => {
break; document.addEventListener('mousedown', handleClickOutside);
case 'Right':
case 'ArrowRight': return () => {
selectNextEmoji(i); document.removeEventListener('mousedown', handleClickOutside);
break; };
case 'Escape': }, [referenceElement]);
onUnfocus(e);
break; useEffect(() => {
if (visible && update) {
update();
} }
}; }, [visible, update]);
return ( return (
<HStack <div
className={clsx('emoji-react-selector z-[999] w-max max-w-[100vw] flex-wrap gap-2 rounded-full bg-white p-3 shadow-md dark:bg-gray-900')} className={clsx('z-50 transition-opacity duration-100', {
ref={node} 'opacity-0 pointer-events-none': !visible,
})}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
> >
{emojiList.map((emoji, i) => ( <HStack
<EmojiButton className={clsx('z-[999] flex w-max max-w-[100vw] flex-wrap space-x-3 rounded-full bg-white px-3 py-2.5 shadow-lg focus:outline-none dark:bg-gray-900 dark:ring-2 dark:ring-primary-700')}
className='emoji-react-selector__emoji hover:scale-125 focus:scale-125' >
key={i} {Array.from(soapboxConfig.allowedEmoji).map((emoji, i) => (
emoji={emoji} <EmojiButton
onClick={handleReact(emoji)} key={i}
onKeyDown={handleKeyDown(i)} emoji={emoji}
tabIndex={(visible || focused) ? 0 : -1} onClick={onReact}
/> tabIndex={visible ? 0 : -1}
))} />
</HStack> ))}
</HStack>
</div>
); );
}; };

@ -15,6 +15,8 @@ interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
transparent?: boolean, transparent?: boolean,
/** Predefined styles to display for the button. */ /** Predefined styles to display for the button. */
theme?: 'seamless' | 'outlined', theme?: 'seamless' | 'outlined',
/** Override the data-testid */
'data-testid'?: string
} }
/** A clickable icon. */ /** A clickable icon. */
@ -31,7 +33,7 @@ const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef
'opacity-50': filteredProps.disabled, 'opacity-50': filteredProps.disabled,
}, className)} }, className)}
{...filteredProps} {...filteredProps}
data-testid='icon-button' data-testid={filteredProps['data-testid'] || 'icon-button'}
> >
<SvgIcon src={src} className={iconClassName} /> <SvgIcon src={src} className={iconClassName} />

@ -1,13 +1,32 @@
import clsx from 'clsx';
import React from 'react'; import React from 'react';
import { spring } from 'react-motion';
import Motion from 'soapbox/features/ui/util/optional-motion';
interface IProgressBar { interface IProgressBar {
progress: number, /** Number between 0 and 1 to represent the percentage complete. */
progress: number
/** Height of the progress bar. */
size?: 'sm' | 'md'
} }
/** A horizontal meter filled to the given percentage. */ /** A horizontal meter filled to the given percentage. */
const ProgressBar: React.FC<IProgressBar> = ({ progress }) => ( const ProgressBar: React.FC<IProgressBar> = ({ progress, size = 'md' }) => (
<div className='h-2.5 w-full overflow-hidden rounded-full bg-gray-300 dark:bg-primary-800'> <div
<div className='h-full bg-secondary-500' style={{ width: `${Math.floor(progress * 100)}%` }} /> className={clsx('h-2.5 w-full overflow-hidden rounded-lg bg-gray-300 dark:bg-primary-800', {
'h-2.5': size === 'md',
'h-[6px]': size === 'sm',
})}
>
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress * 100) }}>
{({ width }) => (
<div
className='h-full bg-secondary-500'
style={{ width: `${width}%` }}
/>
)}
</Motion>
</div> </div>
); );

@ -46,7 +46,7 @@ const AnimatedTabs: React.FC<IAnimatedInterface> = ({ children, ...rest }) => {
ref={ref} ref={ref}
> >
<div <div
className='absolute h-[3px] w-full bg-primary-200 dark:bg-primary-700' className='absolute h-[3px] w-full bg-primary-200 dark:bg-gray-800'
style={{ top }} style={{ top }}
/> />
<div <div

@ -26,6 +26,8 @@ interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElemen
hasError?: boolean, hasError?: boolean,
/** Whether or not you can resize the teztarea */ /** Whether or not you can resize the teztarea */
isResizeable?: boolean, isResizeable?: boolean,
/** Textarea theme. */
theme?: 'default' | 'transparent',
} }
/** Textarea with custom styles. */ /** Textarea with custom styles. */
@ -37,6 +39,7 @@ const Textarea = React.forwardRef(({
autoGrow = false, autoGrow = false,
maxRows = 10, maxRows = 10,
minRows = 1, minRows = 1,
theme = 'default',
...props ...props
}: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => { }: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => {
const [rows, setRows] = useState<number>(autoGrow ? 1 : 4); const [rows, setRows] = useState<number>(autoGrow ? 1 : 4);
@ -72,9 +75,10 @@ const Textarea = React.forwardRef(({
ref={ref} ref={ref}
rows={rows} rows={rows}
onChange={handleChange} onChange={handleChange}
className={clsx({ className={clsx('block w-full rounded-md text-gray-900 placeholder:text-gray-600 dark:text-gray-100 dark:placeholder:text-gray-600 sm:text-sm', {
'bg-white dark:bg-transparent shadow-sm block w-full sm:text-sm rounded-md text-gray-900 dark:text-gray-100 placeholder:text-gray-600 dark:placeholder:text-gray-600 border-gray-400 dark:border-gray-800 dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500': 'bg-white dark:bg-transparent shadow-sm border-gray-400 dark:border-gray-800 dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
true, theme === 'default',
'bg-transparent border-0 focus:border-0 focus:ring-0': theme === 'transparent',
'font-mono': isCodeEditor, 'font-mono': isCodeEditor,
'text-red-600 border-red-600': hasError, 'text-red-600 border-red-600': hasError,
'resize-none': !isResizeable, 'resize-none': !isResizeable,

@ -1,13 +1,11 @@
import React from 'react'; import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { spring } from 'react-motion';
import { HStack, Icon, Stack, Text } from 'soapbox/components/ui'; import { HStack, Icon, ProgressBar, Stack, Text } from 'soapbox/components/ui';
import Motion from 'soapbox/features/ui/util/optional-motion';
interface IUploadProgress { interface IUploadProgress {
/** Number between 0 and 1 to represent the percentage complete. */ /** Number between 0 and 100 to represent the percentage complete. */
progress: number, progress: number
} }
/** Displays a progress bar for uploading files. */ /** Displays a progress bar for uploading files. */
@ -24,16 +22,7 @@ const UploadProgress: React.FC<IUploadProgress> = ({ progress }) => {
<FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' /> <FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />
</Text> </Text>
<div className='relative h-1.5 w-full rounded-lg bg-gray-200'> <ProgressBar progress={progress / 100} size='sm' />
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
{({ width }) =>
(<div
className='absolute left-0 top-0 h-1.5 rounded-lg bg-primary-600'
style={{ width: `${width}%` }}
/>)
}
</Motion>
</div>
</Stack> </Stack>
</HStack> </HStack>
); );

@ -16,7 +16,7 @@ const mapStateToProps = (state: RootState) => ({
openedViaKeyboard: state.dropdown_menu.keyboard, openedViaKeyboard: state.dropdown_menu.keyboard,
}); });
const mapDispatchToProps = (dispatch: Dispatch, { status, items }: Partial<IDropdown>) => ({ const mapDispatchToProps = (dispatch: Dispatch, { status, items, ...filteredProps }: Partial<IDropdown>) => ({
onOpen( onOpen(
id: number, id: number,
onItemClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent>, onItemClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
@ -28,10 +28,18 @@ const mapDispatchToProps = (dispatch: Dispatch, { status, items }: Partial<IDrop
actions: items, actions: items,
onClick: onItemClick, onClick: onItemClick,
}) : openDropdownMenu(id, dropdownPlacement, keyboard)); }) : openDropdownMenu(id, dropdownPlacement, keyboard));
if (filteredProps.onOpen) {
filteredProps.onOpen(id, onItemClick, dropdownPlacement, keyboard);
}
}, },
onClose(id: number) { onClose(id: number) {
dispatch(closeModal('ACTIONS')); dispatch(closeModal('ACTIONS'));
dispatch(closeDropdownMenu(id)); dispatch(closeDropdownMenu(id));
if (filteredProps.onClose) {
filteredProps.onClose(id);
}
}, },
}); });

@ -6,8 +6,7 @@ import { makeGetStatus } from 'soapbox/selectors';
interface IStatusContainer extends Omit<IStatus, 'status'> { interface IStatusContainer extends Omit<IStatus, 'status'> {
id: string, id: string,
/** @deprecated Unused. */ contextType?: string,
contextType?: any,
/** @deprecated Unused. */ /** @deprecated Unused. */
otherAccounts?: any, otherAccounts?: any,
/** @deprecated Unused. */ /** @deprecated Unused. */
@ -21,10 +20,10 @@ interface IStatusContainer extends Omit<IStatus, 'status'> {
* @deprecated Use the Status component directly. * @deprecated Use the Status component directly.
*/ */
const StatusContainer: React.FC<IStatusContainer> = (props) => { const StatusContainer: React.FC<IStatusContainer> = (props) => {
const { id, ...rest } = props; const { id, contextType, ...rest } = props;
const getStatus = useCallback(makeGetStatus(), []); const getStatus = useCallback(makeGetStatus(), []);
const status = useAppSelector(state => getStatus(state, { id })); const status = useAppSelector(state => getStatus(state, { id, contextType }));
if (status) { if (status) {
return <Status status={status} {...rest} />; return <Status status={status} {...rest} />;

@ -22,13 +22,14 @@ import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import MovedNote from 'soapbox/features/account-timeline/components/moved-note'; import MovedNote from 'soapbox/features/account-timeline/components/moved-note';
import ActionButton from 'soapbox/features/ui/components/action-button'; import ActionButton from 'soapbox/features/ui/components/action-button';
import SubscriptionButton from 'soapbox/features/ui/components/subscription-button'; import SubscriptionButton from 'soapbox/features/ui/components/subscription-button';
import { useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks';
import { normalizeAttachment } from 'soapbox/normalizers'; import { normalizeAttachment } from 'soapbox/normalizers';
import { ChatKeys, useChats } from 'soapbox/queries/chats'; import { ChatKeys, useChats } from 'soapbox/queries/chats';
import { queryClient } from 'soapbox/queries/client'; import { queryClient } from 'soapbox/queries/client';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
import { Account } from 'soapbox/types/entities'; import { Account } from 'soapbox/types/entities';
import { isDefaultHeader, isRemote } from 'soapbox/utils/accounts'; import { isDefaultHeader, isLocal, isRemote } from 'soapbox/utils/accounts';
import { MASTODON, parseVersion } from 'soapbox/utils/features';
import type { Menu as MenuType } from 'soapbox/components/dropdown-menu'; import type { Menu as MenuType } from 'soapbox/components/dropdown-menu';
@ -71,6 +72,7 @@ const messages = defineMessages({
userUnendorsed: { id: 'account.unendorse.success', defaultMessage: 'You are no longer featuring @{acct}' }, userUnendorsed: { id: 'account.unendorse.success', defaultMessage: 'You are no longer featuring @{acct}' },
profileExternal: { id: 'account.profile_external', defaultMessage: 'View profile on {domain}' }, profileExternal: { id: 'account.profile_external', defaultMessage: 'View profile on {domain}' },
header: { id: 'account.header.alt', defaultMessage: 'Profile header' }, header: { id: 'account.header.alt', defaultMessage: 'Profile header' },
subscribeFeed: { id: 'account.rss_feed', defaultMessage: 'Subscribe to RSS feed' },
}); });
interface IHeader { interface IHeader {
@ -85,6 +87,8 @@ const Header: React.FC<IHeader> = ({ account }) => {
const features = useFeatures(); const features = useFeatures();
const ownAccount = useOwnAccount(); const ownAccount = useOwnAccount();
const { software } = useAppSelector((state) => parseVersion(state.instance.version));
const { getOrCreateChatByAccountId } = useChats(); const { getOrCreateChatByAccountId } = useChats();
const createAndNavigateToChat = useMutation((accountId: string) => { const createAndNavigateToChat = useMutation((accountId: string) => {
@ -257,6 +261,10 @@ const Header: React.FC<IHeader> = ({ account }) => {
} }
}; };
const handleRssFeedClick = () => {
window.open(software === MASTODON ? `${account.url}.rss` : `${account.url}/feed.rss`, '_blank');
};
const handleShare = () => { const handleShare = () => {
navigator.share({ navigator.share({
text: `@${account.acct}`, text: `@${account.acct}`,
@ -269,20 +277,43 @@ const Header: React.FC<IHeader> = ({ account }) => {
const makeMenu = () => { const makeMenu = () => {
const menu: MenuType = []; const menu: MenuType = [];
if (!account || !ownAccount) { if (!account) {
return []; return [];
} }
if (features.rssFeeds && isLocal(account)) {
menu.push({
text: intl.formatMessage(messages.subscribeFeed),
action: handleRssFeedClick,
icon: require('@tabler/icons/rss.svg'),
});
}
if ('share' in navigator) { if ('share' in navigator) {
menu.push({ menu.push({
text: intl.formatMessage(messages.share, { name: account.username }), text: intl.formatMessage(messages.share, { name: account.username }),
action: handleShare, action: handleShare,
icon: require('@tabler/icons/upload.svg'), icon: require('@tabler/icons/upload.svg'),
}); });
}
if (features.federating && isRemote(account)) {
const domain = account.fqn.split('@')[1];
menu.push({
text: intl.formatMessage(messages.profileExternal, { domain }),
action: () => onProfileExternal(account.url),
icon: require('@tabler/icons/external-link.svg'),
});
}
if (!ownAccount) return menu;
if (menu.length) {
menu.push(null); menu.push(null);
} }
if (account.id === ownAccount?.id) { if (account.id === ownAccount.id) {
menu.push({ menu.push({
text: intl.formatMessage(messages.edit_profile), text: intl.formatMessage(messages.edit_profile),
to: '/settings/profile', to: '/settings/profile',
@ -435,17 +466,9 @@ const Header: React.FC<IHeader> = ({ account }) => {
icon: require('@tabler/icons/ban.svg'), icon: require('@tabler/icons/ban.svg'),
}); });
} }
if (features.federating) {
menu.push({
text: intl.formatMessage(messages.profileExternal, { domain }),
action: () => onProfileExternal(account.url),
icon: require('@tabler/icons/external-link.svg'),
});
}
} }
if (ownAccount?.staff) { if (ownAccount.staff) {
menu.push(null); menu.push(null);
menu.push({ menu.push({
@ -463,7 +486,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
if (!account || !ownAccount) return info; if (!account || !ownAccount) return info;
if (ownAccount?.id !== account.id && account.relationship?.followed_by) { if (ownAccount.id !== account.id && account.relationship?.followed_by) {
info.push( info.push(
<Badge <Badge
key='followed_by' key='followed_by'
@ -471,7 +494,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
title={<FormattedMessage id='account.follows_you' defaultMessage='Follows you' />} title={<FormattedMessage id='account.follows_you' defaultMessage='Follows you' />}
/>, />,
); );
} else if (ownAccount?.id !== account.id && account.relationship?.blocking) { } else if (ownAccount.id !== account.id && account.relationship?.blocking) {
info.push( info.push(
<Badge <Badge
key='blocked' key='blocked'
@ -481,7 +504,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
); );
} }
if (ownAccount?.id !== account.id && account.relationship?.muting) { if (ownAccount.id !== account.id && account.relationship?.muting) {
info.push( info.push(
<Badge <Badge
key='muted' key='muted'
@ -489,7 +512,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
title={<FormattedMessage id='account.muted' defaultMessage='Muted' />} title={<FormattedMessage id='account.muted' defaultMessage='Muted' />}
/>, />,
); );
} else if (ownAccount?.id !== account.id && account.relationship?.domain_blocking) { } else if (ownAccount.id !== account.id && account.relationship?.domain_blocking) {
info.push( info.push(
<Badge <Badge
key='domain_blocked' key='domain_blocked'
@ -621,7 +644,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
{renderMessageButton()} {renderMessageButton()}
{renderShareButton()} {renderShareButton()}
{ownAccount && ( {menu.length > 0 && (
<Menu> <Menu>
<MenuButton <MenuButton
as={IconButton} as={IconButton}

@ -2,13 +2,15 @@ import userEvent from '@testing-library/user-event';
import React from 'react'; import React from 'react';
import { VirtuosoMockContext } from 'react-virtuoso'; import { VirtuosoMockContext } from 'react-virtuoso';
import { ChatContext } from 'soapbox/contexts/chat-context'; import { ChatContext } from 'soapbox/contexts/chat-context';
import { normalizeInstance } from 'soapbox/normalizers'; import { normalizeChatMessage, normalizeInstance } from 'soapbox/normalizers';
import { IAccount } from 'soapbox/queries/accounts'; import { IAccount } from 'soapbox/queries/accounts';
import { ChatMessage } from 'soapbox/types/entities';
import { __stub } from '../../../../api'; import { __stub } from '../../../../api';
import { queryClient, render, rootState, screen, waitFor } from '../../../../jest/test-helpers'; import { queryClient, render, rootState, screen, waitFor } from '../../../../jest/test-helpers';
import { IChat, IChatMessage } from '../../../../queries/chats'; import { IChat } from '../../../../queries/chats';
import ChatMessageList from '../chat-message-list'; import ChatMessageList from '../chat-message-list';
const chat: IChat = { const chat: IChat = {
@ -22,6 +24,7 @@ const chat: IChat = {
avatar_static: 'avatar', avatar_static: 'avatar',
display_name: 'my name', display_name: 'my name',
} as IAccount, } as IAccount,
chat_type: 'direct',
created_at: '2020-06-10T02:05:06.000Z', created_at: '2020-06-10T02:05:06.000Z',
created_by_account: '2', created_by_account: '2',
discarded_at: null, discarded_at: null,
@ -33,25 +36,29 @@ const chat: IChat = {
unread: 5, unread: 5,
}; };
const chatMessages: IChatMessage[] = [ const chatMessages: ChatMessage[] = [
{ normalizeChatMessage({
account_id: '1', account_id: '1',
chat_id: '14', chat_id: '14',
content: 'this is the first chat', content: 'this is the first chat',
created_at: '2022-09-09T16:02:26.186Z', created_at: '2022-09-09T16:02:26.186Z',
emoji_reactions: null,
expiration: 1209600,
id: '1', id: '1',
unread: false, unread: false,
pending: false, pending: false,
}, }),
{ normalizeChatMessage({
account_id: '2', account_id: '2',
chat_id: '14', chat_id: '14',
content: 'this is the second chat', content: 'this is the second chat',
created_at: '2022-09-09T16:04:26.186Z', created_at: '2022-09-09T16:04:26.186Z',
emoji_reactions: null,
expiration: 1209600,
id: '2', id: '2',
unread: true, unread: true,
pending: false, pending: false,
}, }),
]; ];
// Mock scrollIntoView function. // Mock scrollIntoView function.

@ -0,0 +1,78 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { normalizeEmojiReaction } from 'soapbox/normalizers/emoji-reaction';
import { render, screen } from '../../../../jest/test-helpers';
import ChatMessageReaction from '../chat-message-reaction';
const emojiReaction = normalizeEmojiReaction({
name: '👍',
count: 1,
me: false,
});
describe('<ChatMessageReaction />', () => {
it('renders properly', () => {
render(
<ChatMessageReaction
emojiReaction={emojiReaction}
onAddReaction={jest.fn()}
onRemoveReaction={jest.fn()}
/>,
);
expect(screen.getByRole('img').getAttribute('alt')).toEqual(emojiReaction.name);
expect(screen.getByRole('button')).toHaveTextContent(String(emojiReaction.count));
});
it('triggers the "onAddReaction" function', async () => {
const onAddFn = jest.fn();
const onRemoveFn = jest.fn();
const user = userEvent.setup();
render(
<ChatMessageReaction
emojiReaction={emojiReaction}
onAddReaction={onAddFn}
onRemoveReaction={onRemoveFn}
/>,
);
expect(onAddFn).not.toBeCalled();
expect(onRemoveFn).not.toBeCalled();
await user.click(screen.getByRole('button'));
// add function triggered
expect(onAddFn).toBeCalled();
expect(onRemoveFn).not.toBeCalled();
});
it('triggers the "onRemoveReaction" function', async () => {
const onAddFn = jest.fn();
const onRemoveFn = jest.fn();
const user = userEvent.setup();
render(
<ChatMessageReaction
emojiReaction={normalizeEmojiReaction({
name: '👍',
count: 1,
me: true,
})}
onAddReaction={onAddFn}
onRemoveReaction={onRemoveFn}
/>,
);
expect(onAddFn).not.toBeCalled();
expect(onRemoveFn).not.toBeCalled();
await user.click(screen.getByRole('button'));
// remove function triggered
expect(onAddFn).not.toBeCalled();
expect(onRemoveFn).toBeCalled();
});
});

@ -3,13 +3,16 @@ import { defineMessages, IntlShape, useIntl } from 'react-intl';
import { unblockAccount } from 'soapbox/actions/accounts'; import { unblockAccount } from 'soapbox/actions/accounts';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { Button, Combobox, ComboboxInput, ComboboxList, ComboboxOption, ComboboxPopover, HStack, IconButton, Stack, Text, Textarea } from 'soapbox/components/ui'; import { Button, Combobox, ComboboxInput, ComboboxList, ComboboxOption, ComboboxPopover, HStack, IconButton, Stack, Text } from 'soapbox/components/ui';
import { useChatContext } from 'soapbox/contexts/chat-context'; import { useChatContext } from 'soapbox/contexts/chat-context';
import UploadButton from 'soapbox/features/compose/components/upload-button'; import UploadButton from 'soapbox/features/compose/components/upload-button';
import { search as emojiSearch } from 'soapbox/features/emoji/emoji-mart-search-light'; import { search as emojiSearch } from 'soapbox/features/emoji/emoji-mart-search-light';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { Attachment } from 'soapbox/types/entities';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions'; import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import ChatTextarea from './chat-textarea';
const messages = defineMessages({ const messages = defineMessages({
placeholder: { id: 'chat.input.placeholder', defaultMessage: 'Type a message' }, placeholder: { id: 'chat.input.placeholder', defaultMessage: 'Type a message' },
send: { id: 'chat.actions.send', defaultMessage: 'Send' }, send: { id: 'chat.actions.send', defaultMessage: 'Send' },
@ -39,7 +42,10 @@ interface IChatComposer extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaEl
errorMessage: string | undefined errorMessage: string | undefined
onSelectFile: (files: FileList, intl: IntlShape) => void onSelectFile: (files: FileList, intl: IntlShape) => void
resetFileKey: number | null resetFileKey: number | null
hasAttachment?: boolean attachments?: Attachment[]
onDeleteAttachment?: () => void
isUploading?: boolean
uploadProgress?: number
} }
/** Textarea input for chats. */ /** Textarea input for chats. */
@ -53,7 +59,10 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
onSelectFile, onSelectFile,
resetFileKey, resetFileKey,
onPaste, onPaste,
hasAttachment, attachments = [],
onDeleteAttachment,
isUploading,
uploadProgress,
}, ref) => { }, ref) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -68,6 +77,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
const [suggestions, setSuggestions] = useState<Suggestion>(initialSuggestionState); const [suggestions, setSuggestions] = useState<Suggestion>(initialSuggestionState);
const isSuggestionsAvailable = suggestions.list.length > 0; const isSuggestionsAvailable = suggestions.list.length > 0;
const hasAttachment = attachments.length > 0;
const isOverCharacterLimit = maxCharacterCount && value?.length > maxCharacterCount; const isOverCharacterLimit = maxCharacterCount && value?.length > maxCharacterCount;
const isSubmitDisabled = disabled || isOverCharacterLimit || (value.length === 0 && !hasAttachment); const isSubmitDisabled = disabled || isOverCharacterLimit || (value.length === 0 && !hasAttachment);
@ -167,12 +177,9 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
)} )}
<Stack grow> <Stack grow>
<Combobox <Combobox onSelect={onSelectComboboxOption}>
aria-labelledby='demo'
onSelect={onSelectComboboxOption}
>
<ComboboxInput <ComboboxInput
as={Textarea} as={ChatTextarea}
autoFocus autoFocus
ref={ref} ref={ref}
placeholder={intl.formatMessage(messages.placeholder)} placeholder={intl.formatMessage(messages.placeholder)}
@ -184,6 +191,10 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
autoGrow autoGrow
maxRows={5} maxRows={5}
disabled={disabled} disabled={disabled}
attachments={attachments}
onDeleteAttachment={onDeleteAttachment}
isUploading={isUploading}
uploadProgress={uploadProgress}
/> />
{isSuggestionsAvailable ? ( {isSuggestionsAvailable ? (
<ComboboxPopover> <ComboboxPopover>

@ -1,33 +1,17 @@
import { useMutation } from '@tanstack/react-query';
import clsx from 'clsx';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import escape from 'lodash/escape';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useIntl, defineMessages } from 'react-intl'; import { useIntl, defineMessages } from 'react-intl';
import { Components, Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import { Components, Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { openModal } from 'soapbox/actions/modals'; import { Avatar, Button, Divider, Spinner, Stack, Text } from 'soapbox/components/ui';
import { initReport } from 'soapbox/actions/reports';
import { Avatar, Button, Divider, HStack, Icon, IconButton, Spinner, Stack, Text } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import emojify from 'soapbox/features/emoji/emoji';
import PlaceholderChatMessage from 'soapbox/features/placeholder/components/placeholder-chat-message'; import PlaceholderChatMessage from 'soapbox/features/placeholder/components/placeholder-chat-message';
import Bundle from 'soapbox/features/ui/components/bundle'; import { useAppSelector, useOwnAccount } from 'soapbox/hooks';
import { MediaGallery } from 'soapbox/features/ui/util/async-components'; import { IChat, useChatActions, useChatMessages } from 'soapbox/queries/chats';
import { useAppSelector, useAppDispatch, useOwnAccount, useFeatures } from 'soapbox/hooks';
import { normalizeAccount } from 'soapbox/normalizers';
import { ChatKeys, IChat, IChatMessage, useChatActions, useChatMessages } from 'soapbox/queries/chats';
import { queryClient } from 'soapbox/queries/client';
import { stripHTML } from 'soapbox/utils/html';
import { onlyEmoji } from 'soapbox/utils/rich-content';
import ChatMessage from './chat-message';
import ChatMessageListIntro from './chat-message-list-intro'; import ChatMessageListIntro from './chat-message-list-intro';
import type { Menu } from 'soapbox/components/dropdown-menu';
import type { ChatMessage as ChatMessageEntity } from 'soapbox/types/entities'; import type { ChatMessage as ChatMessageEntity } from 'soapbox/types/entities';
const BIG_EMOJI_LIMIT = 3;
const messages = defineMessages({ const messages = defineMessages({
today: { id: 'chats.dividers.today', defaultMessage: 'Today' }, today: { id: 'chats.dividers.today', defaultMessage: 'Today' },
more: { id: 'chats.actions.more', defaultMessage: 'More' }, more: { id: 'chats.actions.more', defaultMessage: 'More' },
@ -43,7 +27,7 @@ const messages = defineMessages({
type TimeFormat = 'today' | 'date'; type TimeFormat = 'today' | 'date';
const timeChange = (prev: IChatMessage, curr: IChatMessage): TimeFormat | null => { const timeChange = (prev: ChatMessageEntity, curr: ChatMessageEntity): TimeFormat | null => {
const prevDate = new Date(prev.created_at).getDate(); const prevDate = new Date(prev.created_at).getDate();
const currDate = new Date(curr.created_at).getDate(); const currDate = new Date(curr.created_at).getDate();
const nowDate = new Date().getDate(); const nowDate = new Date().getDate();
@ -55,10 +39,6 @@ const timeChange = (prev: IChatMessage, curr: IChatMessage): TimeFormat | null =
return null; return null;
}; };
const makeEmojiMap = (record: any) => record.get('emojis', ImmutableList()).reduce((map: ImmutableMap<string, any>, emoji: ImmutableMap<string, any>) => {
return map.set(`:${emoji.get('shortcode')}:`, emoji);
}, ImmutableMap());
const START_INDEX = 10000; const START_INDEX = 10000;
const List: Components['List'] = React.forwardRef((props, ref) => { const List: Components['List'] = React.forwardRef((props, ref) => {
@ -89,19 +69,15 @@ interface IChatMessageList {
/** Scrollable list of chat messages. */ /** Scrollable list of chat messages. */
const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => { const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch();
const account = useOwnAccount(); const account = useOwnAccount();
const features = useFeatures();
const lastReadMessageDateString = chat.latest_read_message_by_account?.find((latest) => latest.id === chat.account.id)?.date;
const myLastReadMessageDateString = chat.latest_read_message_by_account?.find((latest) => latest.id === account?.id)?.date; const myLastReadMessageDateString = chat.latest_read_message_by_account?.find((latest) => latest.id === account?.id)?.date;
const lastReadMessageTimestamp = lastReadMessageDateString ? new Date(lastReadMessageDateString) : null;
const myLastReadMessageTimestamp = myLastReadMessageDateString ? new Date(myLastReadMessageDateString) : null; const myLastReadMessageTimestamp = myLastReadMessageDateString ? new Date(myLastReadMessageDateString) : null;
const node = useRef<VirtuosoHandle>(null); const node = useRef<VirtuosoHandle>(null);
const [firstItemIndex, setFirstItemIndex] = useState(START_INDEX - 20); const [firstItemIndex, setFirstItemIndex] = useState(START_INDEX - 20);
const { deleteChatMessage, markChatAsRead } = useChatActions(chat.id); const { markChatAsRead } = useChatActions(chat.id);
const { const {
data: chatMessages, data: chatMessages,
fetchNextPage, fetchNextPage,
@ -115,24 +91,24 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
const formattedChatMessages = chatMessages || []; const formattedChatMessages = chatMessages || [];
const me = useAppSelector((state) => state.me);
const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat.account.id, 'blocked_by'])); const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat.account.id, 'blocked_by']));
const handleDeleteMessage = useMutation((chatMessageId: string) => deleteChatMessage(chatMessageId), {
onSettled: () => {
queryClient.invalidateQueries(ChatKeys.chatMessages(chat.id));
},
});
const lastChatMessage = chatMessages ? chatMessages[chatMessages.length - 1] : null; const lastChatMessage = chatMessages ? chatMessages[chatMessages.length - 1] : null;
const cachedChatMessages = useMemo(() => { useEffect(() => {
if (!chatMessages) { if (!chatMessages) {
return []; return;
} }
const nextFirstItemIndex = START_INDEX - chatMessages.length; const nextFirstItemIndex = START_INDEX - chatMessages.length;
setFirstItemIndex(nextFirstItemIndex); setFirstItemIndex(nextFirstItemIndex);
}, [lastChatMessage]);
const buildCachedMessages = () => {
if (!chatMessages) {
return [];
}
return chatMessages.reduce((acc: any, curr: any, idx: number) => { return chatMessages.reduce((acc: any, curr: any, idx: number) => {
const lastMessage = formattedChatMessages[idx - 1]; const lastMessage = formattedChatMessages[idx - 1];
@ -156,32 +132,19 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
acc.push(curr); acc.push(curr);
return acc; return acc;
}, []); }, []);
}, [chatMessages?.length, lastChatMessage]);
const initialTopMostItemIndex = process.env.NODE_ENV === 'test' ? 0 : cachedChatMessages.length - 1;
const getFormattedTimestamp = (chatMessage: ChatMessageEntity) => {
return intl.formatDate(new Date(chatMessage.created_at), {
hour12: false,
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}; };
const cachedChatMessages = buildCachedMessages();
const setBubbleRef = (c: HTMLDivElement) => { const initialScrollPositionProps = useMemo(() => {
if (!c) return; if (process.env.NODE_ENV === 'test') {
const links = c.querySelectorAll('a[rel="ugc"]'); return {};
}
links.forEach(link => { return {
link.classList.add('chat-link'); initialTopMostItemIndex: cachedChatMessages.length - 1,
link.setAttribute('rel', 'ugc nofollow noopener'); firstItemIndex: Math.max(0, firstItemIndex),
link.setAttribute('target', '_blank'); };
}); }, [cachedChatMessages.length, firstItemIndex]);
};
const handleStartReached = useCallback(() => { const handleStartReached = useCallback(() => {
if (hasNextPage && !isFetching) { if (hasNextPage && !isFetching) {
@ -190,213 +153,8 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
return false; return false;
}, [firstItemIndex, hasNextPage, isFetching]); }, [firstItemIndex, hasNextPage, isFetching]);
const onOpenMedia = (media: any, index: number) => {
dispatch(openModal('MEDIA', { media, index }));
};
const maybeRenderMedia = (chatMessage: ChatMessageEntity) => {
const { attachment } = chatMessage;
if (!attachment) return null;
return (
<Bundle fetchComponent={MediaGallery}>
{(Component: any) => (
<Component
media={ImmutableList([attachment])}
onOpenMedia={onOpenMedia}
visible
/>
)}
</Bundle>
);
};
const parsePendingContent = (content: string) => {
return escape(content).replace(/(?:\r\n|\r|\n)/g, '<br>');
};
const parseContent = (chatMessage: ChatMessageEntity) => {
const content = chatMessage.content || '';
const pending = chatMessage.pending;
const deleting = chatMessage.deleting;
const formatted = (pending && !deleting) ? parsePendingContent(content) : content;
const emojiMap = makeEmojiMap(chatMessage);
return emojify(formatted, emojiMap.toJS());
};
const renderDivider = (key: React.Key, text: string) => <Divider key={key} text={text} textSize='xs' />; const renderDivider = (key: React.Key, text: string) => <Divider key={key} text={text} textSize='xs' />;
const handleCopyText = (chatMessage: ChatMessageEntity) => {
if (navigator.clipboard) {
const text = stripHTML(chatMessage.content);
navigator.clipboard.writeText(text);
}
};
const renderMessage = (chatMessage: ChatMessageEntity) => {
const content = parseContent(chatMessage);
const hiddenEl = document.createElement('div');
hiddenEl.innerHTML = content;
const isOnlyEmoji = onlyEmoji(hiddenEl, BIG_EMOJI_LIMIT, false);
const isMyMessage = chatMessage.account_id === me;
// did this occur before this time?
const isRead = isMyMessage
&& lastReadMessageTimestamp
&& lastReadMessageTimestamp >= new Date(chatMessage.created_at);
const menu: Menu = [];
if (navigator.clipboard && chatMessage.content) {
menu.push({
text: intl.formatMessage(messages.copy),
action: () => handleCopyText(chatMessage),
icon: require('@tabler/icons/copy.svg'),
});
}
if (isMyMessage) {
menu.push({
text: intl.formatMessage(messages.delete),
action: () => handleDeleteMessage.mutate(chatMessage.id),
icon: require('@tabler/icons/trash.svg'),
destructive: true,
});
} else {
if (features.reportChats) {
menu.push({
text: intl.formatMessage(messages.report),
action: () => dispatch(initReport(normalizeAccount(chat.account) as any, { chatMessage } as any)),
icon: require('@tabler/icons/flag.svg'),
});
}
menu.push({
text: intl.formatMessage(messages.deleteForMe),
action: () => handleDeleteMessage.mutate(chatMessage.id),
icon: require('@tabler/icons/trash.svg'),
destructive: true,
});
}
return (
<div key={chatMessage.id} className='group' data-testid='chat-message'>
<Stack
space={1.5}
className={clsx({
'ml-auto': isMyMessage,
})}
>
<HStack
alignItems='center'
justifyContent={isMyMessage ? 'end' : 'start'}
className={clsx({
'opacity-50': chatMessage.pending,
})}
>
{menu.length > 0 && (
<div
className={clsx({
'hidden focus:block group-hover:block text-gray-500': true,
'mr-2 order-1': isMyMessage,
'ml-2 order-2': !isMyMessage,
})}
data-testid='chat-message-menu'
>
<DropdownMenuContainer items={menu}>
<IconButton
src={require('@tabler/icons/dots.svg')}
title={intl.formatMessage(messages.more)}
className='text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-500'
iconClassName='w-4 h-4'
/>
</DropdownMenuContainer>
</div>
)}
<Stack
space={0.5}
className={clsx({
'max-w-[85%]': true,
'flex-1': chatMessage.attachment,
'order-2': isMyMessage,
'order-1': !isMyMessage,
})}
alignItems={isMyMessage ? 'end' : 'start'}
>
{maybeRenderMedia(chatMessage)}
{content && (
<HStack alignItems='bottom' className='max-w-full'>
<div
title={getFormattedTimestamp(chatMessage)}
className={
clsx({
'text-ellipsis break-words relative rounded-md py-2 px-3 max-w-full space-y-2 [&_.mention]:underline': true,
'rounded-tr-sm': chatMessage.attachment && isMyMessage,
'rounded-tl-sm': chatMessage.attachment && !isMyMessage,
'[&_.mention]:text-primary-600 dark:[&_.mention]:text-accent-blue': !isMyMessage,
'[&_.mention]:text-white dark:[&_.mention]:white': isMyMessage,
'bg-primary-500 text-white': isMyMessage,
'bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100': !isMyMessage,
'!bg-transparent !p-0 emoji-lg': isOnlyEmoji,
})
}
ref={setBubbleRef}
tabIndex={0}
>
<Text
size='sm'
theme='inherit'
className='break-word-nested'
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
</HStack>
)}
</Stack>
</HStack>
<HStack
alignItems='center'
space={2}
className={clsx({
'ml-auto': isMyMessage,
})}
>
<div
className={clsx({
'text-right': isMyMessage,
'order-2': !isMyMessage,
})}
>
<span className='flex items-center space-x-1.5'>
<Text
theme='muted'
size='xs'
>
{intl.formatTime(chatMessage.created_at)}
</Text>
{(isMyMessage && features.chatsReadReceipts) ? (
<>
{isRead ? (
<span className='flex flex-col items-center justify-center rounded-full border border-solid border-primary-500 bg-primary-500 p-0.5 text-white dark:border-primary-400 dark:bg-primary-400 dark:text-primary-900'>
<Icon src={require('@tabler/icons/check.svg')} strokeWidth={3} className='h-2.5 w-2.5' />
</span>
) : (
<span className='flex flex-col items-center justify-center rounded-full border border-solid border-primary-500 bg-transparent p-0.5 text-primary-500 dark:border-primary-400 dark:text-primary-400'>
<Icon src={require('@tabler/icons/check.svg')} strokeWidth={3} className='h-2.5 w-2.5' />
</span>
)}
</>
) : null}
</span>
</div>
</HStack>
</Stack>
</div>
);
};
useEffect(() => { useEffect(() => {
const lastMessage = formattedChatMessages[formattedChatMessages.length - 1]; const lastMessage = formattedChatMessages[formattedChatMessages.length - 1];
if (!lastMessage) { if (!lastMessage) {
@ -477,8 +235,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
<Virtuoso <Virtuoso
ref={node} ref={node}
alignToBottom alignToBottom
firstItemIndex={Math.max(0, firstItemIndex)} {...initialScrollPositionProps}
initialTopMostItemIndex={initialTopMostItemIndex}
data={cachedChatMessages} data={cachedChatMessages}
startReached={handleStartReached} startReached={handleStartReached}
followOutput='auto' followOutput='auto'
@ -486,11 +243,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
if (chatMessage.type === 'divider') { if (chatMessage.type === 'divider') {
return renderDivider(index, chatMessage.text); return renderDivider(index, chatMessage.text);
} else { } else {
return ( return <ChatMessage chat={chat} chatMessage={chatMessage} />;
<div className='px-4 py-2'>
{renderMessage(chatMessage)}
</div>
);
} }
}} }}
components={{ components={{

@ -0,0 +1,49 @@
import React, { useState, useEffect } from 'react';
import EmojiSelector from '../../../../components/ui/emoji-selector/emoji-selector';
interface IChatMessageReactionWrapper {
onOpen(isOpen: boolean): void
onSelect(emoji: string): void
children: JSX.Element
}
/**
* Emoji Reaction Selector
*/
function ChatMessageReactionWrapper(props: IChatMessageReactionWrapper) {
const { onOpen, onSelect, children } = props;
const [isOpen, setIsOpen] = useState(false);
const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>(null);
const handleSelect = (emoji: string) => {
onSelect(emoji);
setIsOpen(false);
};
const onToggleVisibility = () => setIsOpen((prevValue) => !prevValue);
useEffect(() => {
onOpen(isOpen);
}, [isOpen]);
return (
<React.Fragment>
{React.cloneElement(children, {
ref: setReferenceElement,
onClick: onToggleVisibility,
})}
<EmojiSelector
visible={isOpen}
referenceElement={referenceElement}
onReact={handleSelect}
onClose={() => setIsOpen(false)}
/>
</React.Fragment>
);
}
export default ChatMessageReactionWrapper;

@ -0,0 +1,45 @@
import clsx from 'clsx';
import React from 'react';
import { Text } from 'soapbox/components/ui';
import emojify from 'soapbox/features/emoji/emoji';
import { EmojiReaction } from 'soapbox/types/entities';
interface IChatMessageReaction {
emojiReaction: EmojiReaction
onRemoveReaction(emoji: string): void
onAddReaction(emoji: string): void
}
const ChatMessageReaction = (props: IChatMessageReaction) => {
const { emojiReaction, onAddReaction, onRemoveReaction } = props;
const isAlreadyReacted = emojiReaction.me;
const handleClick = () => {
if (isAlreadyReacted) {
onRemoveReaction(emojiReaction.name);
} else {
onAddReaction(emojiReaction.name);
}
};
return (
<button
type='button'
onClick={handleClick}
className={
clsx({
'w-12 rounded-lg flex justify-between text-sm border items-center border-solid text-gray-700 dark:text-gray-600 px-2 py-1 space-x-1.5 transition-colors hover:bg-gray-200 dark:hover:bg-gray-800 whitespace-nowrap': true,
'border-primary-500 dark:border-primary-400': emojiReaction.me,
'border-gray-300 dark:border-gray-800': !emojiReaction.me,
})
}
>
<span dangerouslySetInnerHTML={{ __html: emojify(emojiReaction.name) }} />
<Text tag='span' weight='medium' size='sm'>{emojiReaction.count}</Text>
</button>
);
};
export default ChatMessageReaction;

@ -0,0 +1,371 @@
import { useMutation } from '@tanstack/react-query';
import clsx from 'clsx';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { escape } from 'lodash';
import React, { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals';
import { initReport } from 'soapbox/actions/reports';
import { HStack, Icon, IconButton, Stack, Text } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import emojify from 'soapbox/features/emoji/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';
import { normalizeAccount } from 'soapbox/normalizers';
import { ChatKeys, IChat, useChatActions } from 'soapbox/queries/chats';
import { queryClient } from 'soapbox/queries/client';
import { stripHTML } from 'soapbox/utils/html';
import { onlyEmoji } from 'soapbox/utils/rich-content';
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 { ChatMessage as ChatMessageEntity } from 'soapbox/types/entities';
const messages = defineMessages({
copy: { id: 'chats.actions.copy', defaultMessage: 'Copy' },
delete: { id: 'chats.actions.delete', defaultMessage: 'Delete for both' },
deleteForMe: { id: 'chats.actions.deleteForMe', defaultMessage: 'Delete for me' },
more: { id: 'chats.actions.more', defaultMessage: 'More' },
report: { id: 'chats.actions.report', defaultMessage: 'Report' },
});
const BIG_EMOJI_LIMIT = 3;
const makeEmojiMap = (record: any) => record.get('emojis', ImmutableList()).reduce((map: ImmutableMap<string, any>, emoji: ImmutableMap<string, any>) => {
return map.set(`:${emoji.get('shortcode')}:`, emoji);
}, ImmutableMap());
const parsePendingContent = (content: string) => {
return escape(content).replace(/(?:\r\n|\r|\n)/g, '<br>');
};
const parseContent = (chatMessage: ChatMessageEntity) => {
const content = chatMessage.content || '';
const pending = chatMessage.pending;
const deleting = chatMessage.deleting;
const formatted = (pending && !deleting) ? parsePendingContent(content) : content;
const emojiMap = makeEmojiMap(chatMessage);
return emojify(formatted, emojiMap.toJS());
};
interface IChatMessage {
chat: IChat
chatMessage: ChatMessageEntity
}
const ChatMessage = (props: IChatMessage) => {
const { chat, chatMessage } = props;
const dispatch = useAppDispatch();
const features = useFeatures();
const intl = useIntl();
const me = useAppSelector((state) => state.me);
const { createReaction, deleteChatMessage, deleteReaction } = useChatActions(chat.id);
const [isReactionSelectorOpen, setIsReactionSelectorOpen] = useState<boolean>(false);
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
const handleDeleteMessage = useMutation((chatMessageId: string) => deleteChatMessage(chatMessageId), {
onSettled: () => {
queryClient.invalidateQueries(ChatKeys.chatMessages(chat.id));
},
});
const content = parseContent(chatMessage);
const lastReadMessageDateString = chat.latest_read_message_by_account?.find((latest) => latest.id === chat.account.id)?.date;
const lastReadMessageTimestamp = lastReadMessageDateString ? new Date(lastReadMessageDateString) : null;
const isMyMessage = chatMessage.account_id === me;
// did this occur before this time?
const isRead = isMyMessage
&& lastReadMessageTimestamp
&& lastReadMessageTimestamp >= new Date(chatMessage.created_at);
const isOnlyEmoji = useMemo(() => {
const hiddenEl = document.createElement('div');
hiddenEl.innerHTML = content;
return onlyEmoji(hiddenEl, BIG_EMOJI_LIMIT, false);
}, []);
const emojiReactionRows = useMemo(() => {
if (!chatMessage.emoji_reactions) {
return [];
}
return chatMessage.emoji_reactions.reduce((rows: any, key: any, index) => {
return (index % 4 === 0 ? rows.push([key])
: rows[rows.length - 1].push(key)) && rows;
}, []);
}, [chatMessage.emoji_reactions]);
const onOpenMedia = (media: any, index: number) => {
dispatch(openModal('MEDIA', { media, index }));
};
const maybeRenderMedia = (chatMessage: ChatMessageEntity) => {
if (!chatMessage.media_attachments.size) return null;
return (
<Bundle fetchComponent={MediaGallery}>
{(Component: any) => (
<Component
media={chatMessage.media_attachments}
onOpenMedia={onOpenMedia}
visible
/>
)}
</Bundle>
);
};
const handleCopyText = (chatMessage: ChatMessageEntity) => {
if (navigator.clipboard) {
const text = stripHTML(chatMessage.content);
navigator.clipboard.writeText(text);
}
};
const setBubbleRef = (c: HTMLDivElement) => {
if (!c) return;
const links = c.querySelectorAll('a[rel="ugc"]');
links.forEach(link => {
link.classList.add('chat-link');
link.setAttribute('rel', 'ugc nofollow noopener');
link.setAttribute('target', '_blank');
});
};
const getFormattedTimestamp = (chatMessage: ChatMessageEntity) => {
return intl.formatDate(new Date(chatMessage.created_at), {
hour12: false,
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
const menu = useMemo(() => {
const menu: IMenu = [];
if (navigator.clipboard && chatMessage.content) {
menu.push({
text: intl.formatMessage(messages.copy),
action: () => handleCopyText(chatMessage),
icon: require('@tabler/icons/copy.svg'),
});
}
if (isMyMessage) {
menu.push({
text: intl.formatMessage(messages.delete),
action: () => handleDeleteMessage.mutate(chatMessage.id),
icon: require('@tabler/icons/trash.svg'),
destructive: true,
});
} else {
if (features.reportChats) {
menu.push({
text: intl.formatMessage(messages.report),
action: () => dispatch(initReport(normalizeAccount(chat.account) as any, { chatMessage } as any)),
icon: require('@tabler/icons/flag.svg'),
});
}
menu.push({
text: intl.formatMessage(messages.deleteForMe),
action: () => handleDeleteMessage.mutate(chatMessage.id),
icon: require('@tabler/icons/trash.svg'),
destructive: true,
});
}
return menu;
}, [chatMessage, chat]);
return (
<div className='px-4 py-2'>
<div key={chatMessage.id} className='group' data-testid='chat-message'>
<Stack
space={1.5}
className={clsx({
'ml-auto': isMyMessage,
})}
>
<HStack
alignItems='center'
justifyContent={isMyMessage ? 'end' : 'start'}
className={clsx({
'opacity-50': chatMessage.pending,
})}
>
{menu.length > 0 && (
<DropdownMenuContainer
items={menu}
onOpen={() => setIsMenuOpen(true)}
onClose={() => setIsMenuOpen(false)}
>
<IconButton
src={require('@tabler/icons/dots.svg')}
title={intl.formatMessage(messages.more)}
className={clsx({
'opacity-0 transition-opacity group-hover:opacity-100 focus:opacity-100 flex text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500 focus:text-gray-700 dark:focus:text-gray-500': true,
'mr-2 order-2': isMyMessage,
'ml-2 order-2': !isMyMessage,
'!text-gray-700 dark:!text-gray-500': isMenuOpen,
'!opacity-100': isMenuOpen || isReactionSelectorOpen,
})}
data-testid='chat-message-menu'
iconClassName='w-4 h-4'
/>
</DropdownMenuContainer>
)}
{features.chatEmojiReactions ? (
<ChatMessageReactionWrapper
onOpen={setIsReactionSelectorOpen}
onSelect={(emoji) => createReaction.mutate({ emoji, messageId: chatMessage.id, chatMessage })}
>
<IconButton
src={require('@tabler/icons/mood-smile.svg')}
title={intl.formatMessage(messages.more)}
className={clsx({
'opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity flex text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500 focus:text-gray-700 dark:focus:text-gray-500': true,
'mr-2 order-1': isMyMessage,
'ml-2 order-3': !isMyMessage,
'!text-gray-700 dark:!text-gray-500': isReactionSelectorOpen,
'!opacity-100': isMenuOpen || isReactionSelectorOpen,
})}
iconClassName='w-5 h-5'
/>
</ChatMessageReactionWrapper>
) : null}
<Stack
space={0.5}
className={clsx({
'max-w-[85%]': true,
'flex-1': !!chatMessage.media_attachments.size,
'order-3': isMyMessage,
'order-1': !isMyMessage,
})}
alignItems={isMyMessage ? 'end' : 'start'}
>
{maybeRenderMedia(chatMessage)}
{content && (
<HStack alignItems='bottom' className='max-w-full'>
<div
title={getFormattedTimestamp(chatMessage)}
className={
clsx({
'text-ellipsis break-words relative rounded-md py-2 px-3 max-w-full space-y-2 [&_.mention]:underline': true,
'rounded-tr-sm': (!!chatMessage.media_attachments.size) && isMyMessage,
'rounded-tl-sm': (!!chatMessage.media_attachments.size) && !isMyMessage,
'[&_.mention]:text-primary-600 dark:[&_.mention]:text-accent-blue': !isMyMessage,
'[&_.mention]:text-white dark:[&_.mention]:white': isMyMessage,
'bg-primary-500 text-white': isMyMessage,
'bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100': !isMyMessage,
'!bg-transparent !p-0 emoji-lg': isOnlyEmoji,
})
}
ref={setBubbleRef}
tabIndex={0}
>
<Text
size='sm'
theme='inherit'
className='break-word-nested'
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
</HStack>
)}
</Stack>
</HStack>
{(features.chatEmojiReactions && chatMessage.emoji_reactions) ? (
<div
className={clsx({
'space-y-1': true,
'ml-auto': isMyMessage,
'mr-auto': !isMyMessage,
})}
>
{emojiReactionRows?.map((emojiReactionRow: any, idx: number) => (
<HStack
key={idx}
className={
clsx({
'flex items-center gap-1': true,
'flex-row-reverse': isMyMessage,
})
}
>
{emojiReactionRow.map((emojiReaction: any, idx: number) => (
<ChatMessageReaction
key={idx}
emojiReaction={emojiReaction}
onAddReaction={(emoji) => createReaction.mutate({ emoji, messageId: chatMessage.id, chatMessage })}
onRemoveReaction={(emoji) => deleteReaction.mutate({ emoji, messageId: chatMessage.id })}
/>
))}
</HStack>
))}
</div>
) : null}
<HStack
alignItems='center'
space={2}
className={clsx({
'ml-auto': isMyMessage,
})}
>
<div
className={clsx({
'text-right': isMyMessage,
'order-2': !isMyMessage,
})}
>
<span className='flex items-center space-x-1.5'>
<Text theme='muted' size='xs'>
{intl.formatTime(chatMessage.created_at)}
</Text>
{(isMyMessage && features.chatsReadReceipts) ? (
<>
{isRead ? (
<span className='flex flex-col items-center justify-center rounded-full border border-solid border-primary-500 bg-primary-500 p-0.5 text-white dark:border-primary-400 dark:bg-primary-400 dark:text-primary-900'>
<Icon
src={require('@tabler/icons/check.svg')}
strokeWidth={3}
className='h-2.5 w-2.5'
/>
</span>
) : (
<span className='flex flex-col items-center justify-center rounded-full border border-solid border-primary-500 bg-transparent p-0.5 text-primary-500 dark:border-primary-400 dark:text-primary-400'>
<Icon
src={require('@tabler/icons/check.svg')}
strokeWidth={3}
className='h-2.5 w-2.5'
/>
</span>
)}
</>
) : null}
</span>
</div>
</HStack>
</Stack>
</div>
</div>
);
};
export default ChatMessage;

@ -0,0 +1,18 @@
import React from 'react';
import { ProgressBar } from 'soapbox/components/ui';
interface IChatPendingUpload {
progress: number
}
/** Displays a loading thumbnail for an upload in the chat composer. */
const ChatPendingUpload: React.FC<IChatPendingUpload> = ({ progress }) => {
return (
<div className='relative isolate inline-flex h-24 w-24 items-center justify-center overflow-hidden rounded-lg bg-gray-200 p-4 dark:bg-primary-900'>
<ProgressBar progress={progress} size='sm' />
</div>
);
};
export default ChatPendingUpload;

@ -0,0 +1,58 @@
import React from 'react';
import { Textarea } from 'soapbox/components/ui';
import { Attachment } from 'soapbox/types/entities';
import ChatPendingUpload from './chat-pending-upload';
import ChatUpload from './chat-upload';
interface IChatTextarea extends React.ComponentProps<typeof Textarea> {
attachments?: Attachment[]
onDeleteAttachment?: () => void
isUploading?: boolean
uploadProgress?: number
}
/** Custom textarea for chats. */
const ChatTextarea: React.FC<IChatTextarea> = ({
attachments,
onDeleteAttachment,
isUploading = false,
uploadProgress = 0,
...rest
}) => {
return (
<div className={`
block
w-full
rounded-md border border-gray-400
bg-white text-gray-900
shadow-sm placeholder:text-gray-600
focus-within:border-primary-500
focus-within:ring-1 focus-within:ring-primary-500 dark:border-gray-800 dark:bg-gray-800
dark:text-gray-100 dark:ring-1 dark:ring-gray-800 dark:placeholder:text-gray-600 dark:focus-within:border-primary-500
dark:focus-within:ring-primary-500 sm:text-sm
`}
>
{(!!attachments?.length || isUploading) && (
<div className='flex p-3 pb-0'>
{isUploading && (
<ChatPendingUpload progress={uploadProgress} />
)}
{attachments?.map(attachment => (
<ChatUpload
key={attachment.id}
attachment={attachment}
onDelete={onDeleteAttachment}
/>
))}
</div>
)}
<Textarea theme='transparent' {...rest} />
</div>
);
};
export default ChatTextarea;

@ -0,0 +1,55 @@
import React from 'react';
import { Icon } from 'soapbox/components/ui';
import { MIMETYPE_ICONS } from 'soapbox/components/upload';
import type { Attachment } from 'soapbox/types/entities';
const defaultIcon = require('@tabler/icons/paperclip.svg');
interface IChatUploadPreview {
className?: string
attachment: Attachment
}
/**
* Displays a generic preview for an upload depending on its media type.
* It fills its container and is expected to be sized by its parent.
*/
const ChatUploadPreview: React.FC<IChatUploadPreview> = ({ className, attachment }) => {
const mimeType = attachment.pleroma.get('mime_type') as string | undefined;
switch (attachment.type) {
case 'image':
return (
<img
className='pointer-events-none h-full w-full object-cover'
src={attachment.preview_url}
alt=''
/>
);
case 'video':
return (
<video
className='pointer-events-none h-full w-full object-cover'
src={attachment.preview_url}
autoPlay
playsInline
controls={false}
muted
loop
/>
);
default:
return (
<div className='pointer-events-none flex h-full w-full items-center justify-center'>
<Icon
className='mx-auto my-12 h-16 w-16 text-gray-800 dark:text-gray-200'
src={MIMETYPE_ICONS[mimeType || ''] || defaultIcon}
/>
</div>
);
}
};
export default ChatUploadPreview;

@ -0,0 +1,66 @@
import clsx from 'clsx';
import { List as ImmutableList } from 'immutable';
import React from 'react';
import { openModal } from 'soapbox/actions/modals';
import Blurhash from 'soapbox/components/blurhash';
import { Icon } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
import ChatUploadPreview from './chat-upload-preview';
import type { Attachment } from 'soapbox/types/entities';
interface IChatUpload {
attachment: Attachment,
onDelete?(): void,
}
/** An attachment uploaded to the chat composer, before sending. */
const ChatUpload: React.FC<IChatUpload> = ({ attachment, onDelete }) => {
const dispatch = useAppDispatch();
const clickable = attachment.type !== 'unknown';
const handleOpenModal = () => {
dispatch(openModal('MEDIA', { media: ImmutableList.of(attachment), index: 0 }));
};
return (
<div className='relative isolate inline-block h-24 w-24 overflow-hidden rounded-lg bg-gray-200 dark:bg-primary-900'>
<Blurhash hash={attachment.blurhash} className='absolute inset-0 -z-10 h-full w-full' />
<div className='absolute right-[6px] top-[6px]'>
<RemoveButton onClick={onDelete} />
</div>
<button
onClick={clickable ? handleOpenModal : undefined}
className={clsx('h-full w-full', { 'cursor-zoom-in': clickable, 'cursor-default': !clickable })}
>
<ChatUploadPreview attachment={attachment} />
</button>
</div>
);
};
interface IRemoveButton {
onClick?: React.MouseEventHandler<HTMLButtonElement>
}
/** Floating button to remove an attachment. */
const RemoveButton: React.FC<IRemoveButton> = ({ onClick }) => {
return (
<button
type='button'
onClick={onClick}
className='flex h-5 w-5 items-center justify-center rounded-full bg-secondary-500 p-1'
>
<Icon
className='h-3 w-3 text-white'
src={require('@tabler/icons/x.svg')}
/>
</button>
);
};
export default ChatUpload;

@ -5,8 +5,6 @@ import { defineMessages, useIntl } from 'react-intl';
import { uploadMedia } from 'soapbox/actions/media'; import { uploadMedia } from 'soapbox/actions/media';
import { Stack } from 'soapbox/components/ui'; import { Stack } from 'soapbox/components/ui';
import Upload from 'soapbox/components/upload';
import UploadProgress from 'soapbox/components/upload-progress';
import { useAppDispatch } from 'soapbox/hooks'; import { useAppDispatch } from 'soapbox/hooks';
import { normalizeAttachment } from 'soapbox/normalizers'; import { normalizeAttachment } from 'soapbox/normalizers';
import { IChat, useChatActions } from 'soapbox/queries/chats'; import { IChat, useChatActions } from 'soapbox/queries/chats';
@ -164,22 +162,6 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
<ChatMessageList chat={chat} /> <ChatMessageList chat={chat} />
</div> </div>
{attachment && (
<div className='relative h-48'>
<Upload
media={attachment}
onDelete={handleRemoveFile}
withPreview
/>
</div>
)}
{isUploading && (
<div className='p-4'>
<UploadProgress progress={uploadProgress * 100} />
</div>
)}
<ChatComposer <ChatComposer
ref={inputRef} ref={inputRef}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@ -190,7 +172,10 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
onSelectFile={handleFiles} onSelectFile={handleFiles}
resetFileKey={resetFileKey} resetFileKey={resetFileKey}
onPaste={handlePaste} onPaste={handlePaste}
hasAttachment={!!attachment} attachments={attachment ? [attachment] : []}
onDeleteAttachment={handleRemoveFile}
isUploading={isUploading}
uploadProgress={uploadProgress}
/> />
</Stack> </Stack>
); );

@ -36,7 +36,7 @@ const Events = () => {
<HStack className='mb-4' space={2} justifyContent='between'> <HStack className='mb-4' space={2} justifyContent='between'>
<CardTitle title={<FormattedMessage id='events.recent_events' defaultMessage='Recent events' />} /> <CardTitle title={<FormattedMessage id='events.recent_events' defaultMessage='Recent events' />} />
<Button <Button
className='ml-auto' className='ml-auto xl:hidden'
theme='primary' theme='primary'
size='sm' size='sm'
onClick={onComposeEvent} onClick={onComposeEvent}

@ -2,13 +2,9 @@ import React, { useEffect, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchFilters, createFilter, deleteFilter } from 'soapbox/actions/filters'; import { fetchFilters, createFilter, deleteFilter } from 'soapbox/actions/filters';
import Icon from 'soapbox/components/icon'; import List, { ListItem } from 'soapbox/components/list';
import ScrollableList from 'soapbox/components/scrollable-list'; import ScrollableList from 'soapbox/components/scrollable-list';
import { Button, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, Input, Text } from 'soapbox/components/ui'; import { Button, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, HStack, IconButton, Input, Stack, Text, Toggle } from 'soapbox/components/ui';
import {
FieldsGroup,
Checkbox,
} from 'soapbox/features/forms';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
@ -33,6 +29,13 @@ const messages = defineMessages({
delete: { id: 'column.filters.delete', defaultMessage: 'Delete' }, delete: { id: 'column.filters.delete', defaultMessage: 'Delete' },
}); });
const contexts = {
home: messages.home_timeline,
public: messages.public_timeline,
notifications: messages.notifications,
thread: messages.conversations,
};
// const expirations = { // const expirations = {
// null: 'Never', // null: 'Never',
// // 3600: '30 minutes', // // 3600: '30 minutes',
@ -85,8 +88,8 @@ const Filters = () => {
}); });
}; };
const handleFilterDelete: React.MouseEventHandler<HTMLDivElement> = e => { const handleFilterDelete = (id: string) => () => {
dispatch(deleteFilter(e.currentTarget.dataset.value!)).then(() => { dispatch(deleteFilter(id)).then(() => {
return dispatch(fetchFilters()); return dispatch(fetchFilters());
}).catch(() => { }).catch(() => {
toast.error(intl.formatMessage(messages.delete_error)); toast.error(intl.formatMessage(messages.delete_error));
@ -121,58 +124,68 @@ const Filters = () => {
/> />
</FormGroup> */} </FormGroup> */}
<FieldsGroup> <Stack>
<Text tag='label'> <Text size='sm' weight='medium'>
<FormattedMessage id='filters.context_header' defaultMessage='Filter contexts' /> <FormattedMessage id='filters.context_header' defaultMessage='Filter contexts' />
</Text> </Text>
<Text theme='muted' size='xs'> <Text size='xs' theme='muted'>
<FormattedMessage id='filters.context_hint' defaultMessage='One or multiple contexts where the filter should apply' /> <FormattedMessage id='filters.context_hint' defaultMessage='One or multiple contexts where the filter should apply' />
</Text> </Text>
<div className='two-col'> </Stack>
<Checkbox
label={intl.formatMessage(messages.home_timeline)} <List>
<ListItem label={intl.formatMessage(messages.home_timeline)}>
<Toggle
name='home_timeline' name='home_timeline'
checked={homeTimeline} checked={homeTimeline}
onChange={({ target }) => setHomeTimeline(target.checked)} onChange={({ target }) => setHomeTimeline(target.checked)}
/> />
<Checkbox </ListItem>
label={intl.formatMessage(messages.public_timeline)} <ListItem label={intl.formatMessage(messages.public_timeline)}>
<Toggle
name='public_timeline' name='public_timeline'
checked={publicTimeline} checked={publicTimeline}
onChange={({ target }) => setPublicTimeline(target.checked)} onChange={({ target }) => setPublicTimeline(target.checked)}
/> />
<Checkbox </ListItem>
label={intl.formatMessage(messages.notifications)} <ListItem label={intl.formatMessage(messages.notifications)}>
<Toggle
name='notifications' name='notifications'
checked={notifications} checked={notifications}
onChange={({ target }) => setNotifications(target.checked)} onChange={({ target }) => setNotifications(target.checked)}
/> />
<Checkbox </ListItem>
label={intl.formatMessage(messages.conversations)} <ListItem label={intl.formatMessage(messages.conversations)}>
<Toggle
name='conversations' name='conversations'
checked={conversations} checked={conversations}
onChange={({ target }) => setConversations(target.checked)} onChange={({ target }) => setConversations(target.checked)}
/> />
</div> </ListItem>
</List>
</FieldsGroup> <List>
<ListItem
<FieldsGroup>
<Checkbox
label={intl.formatMessage(messages.drop_header)} label={intl.formatMessage(messages.drop_header)}
hint={intl.formatMessage(messages.drop_hint)} hint={intl.formatMessage(messages.drop_hint)}
name='irreversible' >
checked={irreversible} <Toggle
onChange={({ target }) => setIrreversible(target.checked)} name='irreversible'
/> checked={irreversible}
<Checkbox onChange={({ target }) => setIrreversible(target.checked)}
/>
</ListItem>
<ListItem
label={intl.formatMessage(messages.whole_word_header)} label={intl.formatMessage(messages.whole_word_header)}
hint={intl.formatMessage(messages.whole_word_hint)} hint={intl.formatMessage(messages.whole_word_hint)}
name='whole_word' >
checked={wholeWord} <Toggle
onChange={({ target }) => setWholeWord(target.checked)} name='whole_word'
/> checked={wholeWord}
</FieldsGroup> onChange={({ target }) => setWholeWord(target.checked)}
/>
</ListItem>
</List>
<FormActions> <FormActions>
<Button type='submit' theme='primary'>{intl.formatMessage(messages.add_new)}</Button> <Button type='submit' theme='primary'>{intl.formatMessage(messages.add_new)}</Button>
@ -186,40 +199,41 @@ const Filters = () => {
<ScrollableList <ScrollableList
scrollKey='filters' scrollKey='filters'
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
itemClassName='pb-4 last:pb-0'
> >
{filters.map((filter, i) => ( {filters.map((filter, i) => (
<div key={i} className='filter__container'> <HStack space={1} justifyContent='between'>
<div className='filter__details'> <Stack space={1}>
<div className='filter__phrase'> <Text weight='medium'>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_phrase_label' defaultMessage='Keyword or phrase:' /></span> <FormattedMessage id='filters.filters_list_phrase_label' defaultMessage='Keyword or phrase:' />
<span className='filter__list-value'>{filter.phrase}</span> {' '}
</div> <Text theme='muted' tag='span'>{filter.phrase}</Text>
<div className='filter__contexts'> </Text>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_context_label' defaultMessage='Filter contexts:' /></span> <Text weight='medium'>
<span className='filter__list-value'> <FormattedMessage id='filters.filters_list_context_label' defaultMessage='Filter contexts:' />
{filter.context.map((context, i) => ( {' '}
<span key={i} className='context'>{context}</span> <Text theme='muted' tag='span'>{filter.context.map(context => contexts[context] ? intl.formatMessage(contexts[context]) : context).join(', ')}</Text>
))} </Text>
</span> <HStack space={4}>
</div> <Text weight='medium'>
<div className='filter__details'>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_details_label' defaultMessage='Filter settings:' /></span>
<span className='filter__list-value'>
{filter.irreversible ? {filter.irreversible ?
<span><FormattedMessage id='filters.filters_list_drop' defaultMessage='Drop' /></span> : <FormattedMessage id='filters.filters_list_drop' defaultMessage='Drop' /> :
<span><FormattedMessage id='filters.filters_list_hide' defaultMessage='Hide' /></span> <FormattedMessage id='filters.filters_list_hide' defaultMessage='Hide' />}
} </Text>
{filter.whole_word && {filter.whole_word && (
<span><FormattedMessage id='filters.filters_list_whole-word' defaultMessage='Whole word' /></span> <Text weight='medium'>
} <FormattedMessage id='filters.filters_list_whole-word' defaultMessage='Whole word' />
</span> </Text>
</div> )}
</div> </HStack>
<div className='filter__delete' role='button' tabIndex={0} onClick={handleFilterDelete} data-value={filter.id} aria-label={intl.formatMessage(messages.delete)}> </Stack>
<Icon className='filter__delete-icon' src={require('@tabler/icons/x.svg')} /> <IconButton
<span className='filter__delete-label'><FormattedMessage id='filters.filters_list_delete' defaultMessage='Delete' /></span> iconClassName='h-5 w-5 text-gray-700 dark:text-gray-600 hover:text-gray-800 dark:hover:text-gray-500'
</div> src={require('@tabler/icons/trash.svg')}
</div> onClick={handleFilterDelete(filter.id)}
title={intl.formatMessage(messages.delete)}
/>
</HStack>
))} ))}
</ScrollableList> </ScrollableList>
</Column> </Column>

@ -103,14 +103,6 @@ export const SimpleInput: React.FC<ISimpleInput> = (props) => {
); );
}; };
interface IFieldsGroup {
children: React.ReactNode,
}
export const FieldsGroup: React.FC<IFieldsGroup> = ({ children }) => (
<div className='fields-group'>{children}</div>
);
interface ICheckbox { interface ICheckbox {
label?: React.ReactNode, label?: React.ReactNode,
hint?: React.ReactNode, hint?: React.ReactNode,

@ -329,6 +329,7 @@ const Notification: React.FC<INotificaton> = (props) => {
onMoveDown={handleMoveDown} onMoveDown={handleMoveDown}
onMoveUp={handleMoveUp} onMoveUp={handleMoveUp}
avatarSize={avatarSize} avatarSize={avatarSize}
contextType='notifications'
/> />
) : null; ) : null;
default: default:

@ -8,6 +8,7 @@ import { useAppSelector } from 'soapbox/hooks';
interface IThreadStatus { interface IThreadStatus {
id: string, id: string,
contextType?: string,
focusedStatusId: string, focusedStatusId: string,
onMoveUp: (id: string) => void, onMoveUp: (id: string) => void,
onMoveDown: (id: string) => void, onMoveDown: (id: string) => void,

@ -361,6 +361,7 @@ const Thread: React.FC<IThread> = (props) => {
focusedStatusId={status!.id} focusedStatusId={status!.id}
onMoveUp={handleMoveUp} onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown} onMoveDown={handleMoveDown}
contextType='thread'
/> />
); );
}; };

@ -156,6 +156,7 @@ const ActionButton: React.FC<IActionButton> = ({ account, actionType, small }) =
onClick={handleRemoteFollow} onClick={handleRemoteFollow}
icon={require('@tabler/icons/plus.svg')} icon={require('@tabler/icons/plus.svg')}
text={intl.formatMessage(messages.follow)} text={intl.formatMessage(messages.follow)}
size='sm'
/> />
); );
// Pleroma's classic remote follow form. // Pleroma's classic remote follow form.
@ -164,7 +165,11 @@ const ActionButton: React.FC<IActionButton> = ({ account, actionType, small }) =
<form method='POST' action='/main/ostatus'> <form method='POST' action='/main/ostatus'>
<input type='hidden' name='nickname' value={account.acct} /> <input type='hidden' name='nickname' value={account.acct} />
<input type='hidden' name='profile' value='' /> <input type='hidden' name='profile' value='' />
<Button text={intl.formatMessage(messages.remote_follow)} type='submit' /> <Button
text={intl.formatMessage(messages.remote_follow)}
type='submit'
size='sm'
/>
</form> </form>
); );
} }

@ -0,0 +1,39 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { openModal } from 'soapbox/actions/modals';
import { Button, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
const NewEventPanel = () => {
const dispatch = useAppDispatch();
const createEvent = () => {
dispatch(openModal('COMPOSE_EVENT'));
};
return (
<Stack space={2}>
<Stack>
<Text size='lg' weight='bold'>
<FormattedMessage id='new_event_panel.title' defaultMessage='Create New Event' />
</Text>
<Text theme='muted' size='sm'>
<FormattedMessage id='new_event_panel.subtitle' defaultMessage="Can't find what you're looking for? Schedule your own event." />
</Text>
</Stack>
<Button
icon={require('@tabler/icons/calendar-event.svg')}
onClick={createEvent}
theme='secondary'
block
>
<FormattedMessage id='new_event_panel.action' defaultMessage='Create event' />
</Button>
</Stack>
);
};
export default NewEventPanel;

@ -32,12 +32,12 @@ const SubscriptionButton = ({ account }: ISubscriptionButton) => {
const isFollowing = account.relationship?.following; const isFollowing = account.relationship?.following;
const isRequested = account.relationship?.requested; const isRequested = account.relationship?.requested;
const isSubscribed = features.accountNotifies ? const isSubscribed = features.accountNotifies
account.relationship?.notifying : ? account.relationship?.notifying
account.relationship?.subscribing; : account.relationship?.subscribing;
const title = isSubscribed ? const title = isSubscribed
intl.formatMessage(messages.unsubscribe, { name: account.get('username') }) : ? intl.formatMessage(messages.unsubscribe, { name: account.get('username') })
intl.formatMessage(messages.subscribe, { name: account.get('username') }); : intl.formatMessage(messages.subscribe, { name: account.get('username') });
const onSubscribeSuccess = () => const onSubscribeSuccess = () =>
toast.success(intl.formatMessage(messages.subscribeSuccess)); toast.success(intl.formatMessage(messages.subscribeSuccess));

@ -29,6 +29,7 @@ import AdminPage from 'soapbox/pages/admin-page';
import ChatsPage from 'soapbox/pages/chats-page'; import ChatsPage from 'soapbox/pages/chats-page';
import DefaultPage from 'soapbox/pages/default-page'; import DefaultPage from 'soapbox/pages/default-page';
import EventPage from 'soapbox/pages/event-page'; import EventPage from 'soapbox/pages/event-page';
import EventsPage from 'soapbox/pages/events-page';
import GroupPage from 'soapbox/pages/group-page'; import GroupPage from 'soapbox/pages/group-page';
import GroupsPage from 'soapbox/pages/groups-page'; import GroupsPage from 'soapbox/pages/groups-page';
import HomePage from 'soapbox/pages/home-page'; import HomePage from 'soapbox/pages/home-page';
@ -253,7 +254,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
<WrappedRoute path='/search' page={DefaultPage} component={Search} content={children} /> <WrappedRoute path='/search' page={DefaultPage} component={Search} content={children} />
{features.suggestions && <WrappedRoute path='/suggestions' publicRoute page={DefaultPage} component={FollowRecommendations} content={children} />} {features.suggestions && <WrappedRoute path='/suggestions' publicRoute page={DefaultPage} component={FollowRecommendations} content={children} />}
{features.profileDirectory && <WrappedRoute path='/directory' publicRoute page={DefaultPage} component={Directory} content={children} />} {features.profileDirectory && <WrappedRoute path='/directory' publicRoute page={DefaultPage} component={Directory} content={children} />}
{features.events && <WrappedRoute path='/events' page={DefaultPage} component={Events} content={children} />} {features.events && <WrappedRoute path='/events' page={EventsPage} component={Events} content={children} />}
{features.chats && <WrappedRoute path='/chats' exact page={ChatsPage} component={ChatIndex} content={children} />} {features.chats && <WrappedRoute path='/chats' exact page={ChatsPage} component={ChatIndex} content={children} />}
{features.chats && <WrappedRoute path='/chats/new' page={ChatsPage} component={ChatIndex} content={children} />} {features.chats && <WrappedRoute path='/chats/new' page={ChatsPage} component={ChatIndex} content={children} />}

@ -573,3 +573,7 @@ export function NewGroupPanel() {
export function GroupMediaPanel() { export function GroupMediaPanel() {
return import(/* webpackChunkName: "features/groups" */'../components/group-media-panel'); return import(/* webpackChunkName: "features/groups" */'../components/group-media-panel');
} }
export function NewEventPanel() {
return import(/* webpackChunkName: "features/events" */'../components/panels/new-event-panel');
}

@ -57,7 +57,7 @@
"account.unblock": "@{name} entblocken", "account.unblock": "@{name} entblocken",
"account.unblock_domain": "{domain} wieder anzeigen", "account.unblock_domain": "{domain} wieder anzeigen",
"account.unendorse": "Nicht auf Profil hervorheben", "account.unendorse": "Nicht auf Profil hervorheben",
"account.unendorse.success": "Du bietest @{acct} nicht mehr an", "account.unendorse.success": "Du schlägst @{acct} nicht mehr vor",
"account.unfollow": "Entfolgen", "account.unfollow": "Entfolgen",
"account.unmute": "Stummsch. aufheben", "account.unmute": "Stummsch. aufheben",
"account.unsubscribe": "Benachrichtigungen von @{name} entabonnieren", "account.unsubscribe": "Benachrichtigungen von @{name} entabonnieren",
@ -139,7 +139,7 @@
"admin_nav.awaiting_approval": "Wartet auf Bestätigung", "admin_nav.awaiting_approval": "Wartet auf Bestätigung",
"admin_nav.dashboard": "Steuerung", "admin_nav.dashboard": "Steuerung",
"admin_nav.reports": "Beschwerden", "admin_nav.reports": "Beschwerden",
"age_verification.body": "{siteTitle} erfordert, dass Benutzer mindestens {ageMinimum, plural, one {# year} other {# years}} Jahre alt sind, um auf die Plattform zugreifen zu können. Personen unter {ageMinimum, plural, one {# year} other {# years}} alt können nicht auf diese Plattform zugreifen.", "age_verification.body": "{siteTitle} erfordert, dass Mitglieder mindestens {ageMinimum, plural, one {# year} other {# years}} Jahre alt sind, um an der Plattform teilzunehmen. Personen unter {ageMinimum, plural, one {# year} other {# years}} alt können nicht Mitglied werden.",
"age_verification.fail": "Du musst {ageMinimum, plural, one {# Jahr} other {# Jahre}} alt oder älter sein.", "age_verification.fail": "Du musst {ageMinimum, plural, one {# Jahr} other {# Jahre}} alt oder älter sein.",
"age_verification.header": "Gib dein Geburtsdatum ein", "age_verification.header": "Gib dein Geburtsdatum ein",
"alert.unexpected.body": "Wir bedauern die Störung. Wende dich an den Support, wenn das Problem über längere Zeit besteht. Möglicherweise hilft es, {clearCookies}. Hierdurch wirst du abgemeldet.", "alert.unexpected.body": "Wir bedauern die Störung. Wende dich an den Support, wenn das Problem über längere Zeit besteht. Möglicherweise hilft es, {clearCookies}. Hierdurch wirst du abgemeldet.",
@ -169,7 +169,7 @@
"app_create.scopes_placeholder": "z.B. 'lesen schreiben folgen'", "app_create.scopes_placeholder": "z.B. 'lesen schreiben folgen'",
"app_create.submit": "App erstellen", "app_create.submit": "App erstellen",
"app_create.website_label": "Webseite", "app_create.website_label": "Webseite",
"auth.awaiting_approval": "Dein Konto ist noch nicht genehmigt", "auth.awaiting_approval": "Dein Konto wurde noch nicht genehmigt",
"auth.invalid_credentials": "Falsches Passwort oder falscher Nutzername", "auth.invalid_credentials": "Falsches Passwort oder falscher Nutzername",
"auth.logged_out": "Abgemeldet.", "auth.logged_out": "Abgemeldet.",
"auth_layout.register": "Ein Konto erstellen", "auth_layout.register": "Ein Konto erstellen",
@ -199,30 +199,30 @@
"chat.page_settings.privacy": "Privatsphäre", "chat.page_settings.privacy": "Privatsphäre",
"chat.page_settings.submit": "Speichern", "chat.page_settings.submit": "Speichern",
"chat.page_settings.title": "Nachrichteneinstellungen", "chat.page_settings.title": "Nachrichteneinstellungen",
"chat.retry": "Wiederholen?", "chat.retry": "Erneut versuchen?",
"chat.welcome.accepting_messages.label": "Erlaube Benutzer mit dir einen neuen Chat zu starten", "chat.welcome.accepting_messages.label": "Erlaube Benutzer mit dir einen neuen Chat zu starten",
"chat.welcome.notice": "Du kannst diese Einstellungen später ändern.", "chat.welcome.notice": "Du kannst diese Einstellungen später ändern.",
"chat.welcome.submit": "Speichern & Fortfahren", "chat.welcome.submit": "Speichern & fortfahren",
"chat.welcome.subtitle": "Direkte Nachrichten mit anderen Nutzern austauschen.", "chat.welcome.subtitle": "Direktnachrichten mit anderen Nutzern austauschen.",
"chat.welcome.title": "Willkommen zu {br} Chats!", "chat.welcome.title": "Willkommen zu {br} Chats!",
"chat_composer.unblock": "Entblocken", "chat_composer.unblock": "Entblocken",
"chat_list_item.blocked_you": "Dieser Benutzer hat dich blockiert", "chat_list_item.blocked_you": "Dieser Nutzer hat dich blockiert",
"chat_list_item.blocking": "Du hast diesen Benutzer blockiert", "chat_list_item.blocking": "Du hast diesen Benutzer blockiert",
"chat_message_list.blocked": "Du blockiertest diesen Benutzer", "chat_message_list.blocked": "Du hast diesen Nutzer blockiert",
"chat_message_list.blockedBy": "Du bist blockiert von", "chat_message_list.blockedBy": "Du wurdest blockiert von",
"chat_message_list.network_failure.action": "Erneut versuchen", "chat_message_list.network_failure.action": "Erneut versuchen",
"chat_message_list.network_failure.subtitle": "Wir haben einen Netzwerkfehler festgestellt.", "chat_message_list.network_failure.subtitle": "Wir haben einen Netzwerkfehler festgestellt.",
"chat_message_list.network_failure.title": "Huch!", "chat_message_list.network_failure.title": "Huch!",
"chat_message_list_intro.actions.accept": "Akzeptieren", "chat_message_list_intro.actions.accept": "Akzeptieren",
"chat_message_list_intro.actions.leave_chat": "Chat verlassen", "chat_message_list_intro.actions.leave_chat": "Chat verlassen",
"chat_message_list_intro.actions.message_lifespan": "Nachrichten, die älter als {day, plural, one {# day} other {# days}} Tage sind, werden gelöscht.", "chat_message_list_intro.actions.message_lifespan": "Nachrichten, die älter als {day, plural, one {# day} other {# days}} Tage sind, werden gelöscht.",
"chat_message_list_intro.actions.report": "Meldung", "chat_message_list_intro.actions.report": "Melden",
"chat_message_list_intro.intro": "möchte einen Chat mit Dir beginnen", "chat_message_list_intro.intro": "möchte mit dir chatten",
"chat_message_list_intro.leave_chat.confirm": "Chat verlassen", "chat_message_list_intro.leave_chat.confirm": "Chat verlassen",
"chat_message_list_intro.leave_chat.heading": "Chat verlassen", "chat_message_list_intro.leave_chat.heading": "Chat verlassen",
"chat_message_list_intro.leave_chat.message": "Bist Du sicher, dass Du diesen Chat verlassen möchtest? Alle Nachrichten werden gelöscht und dieser Chat wird aus deinem Postfach entfernt.", "chat_message_list_intro.leave_chat.message": "Bist du sicher, dass du diesen Chat verlassen möchtest? Alle Nachrichten werden gelöscht und dieser Chat wird aus deinen Nachrichten entfernt.",
"chat_search.blankslate.body": "Suche nach jemandem zum Chatten.", "chat_search.blankslate.body": "Suche nach jemandem zum chatten.",
"chat_search.blankslate.title": "Einen Chat beginnen", "chat_search.blankslate.title": "Neuen Chat eröffnen",
"chat_search.empty_results_blankslate.action": "Schreibe jemandem", "chat_search.empty_results_blankslate.action": "Schreibe jemandem",
"chat_search.empty_results_blankslate.body": "Versuche, nach einem anderen Namen zu suchen.", "chat_search.empty_results_blankslate.body": "Versuche, nach einem anderen Namen zu suchen.",
"chat_search.empty_results_blankslate.title": "Keine Treffer gefunden", "chat_search.empty_results_blankslate.title": "Keine Treffer gefunden",
@ -237,19 +237,19 @@
"chat_settings.auto_delete.hint": "Gesendete Nachrichten werden nach der gewählten Zeitspanne automatisch gelöscht", "chat_settings.auto_delete.hint": "Gesendete Nachrichten werden nach der gewählten Zeitspanne automatisch gelöscht",
"chat_settings.auto_delete.label": "Automatisches Löschen von Nachrichten", "chat_settings.auto_delete.label": "Automatisches Löschen von Nachrichten",
"chat_settings.block.confirm": "Blockieren", "chat_settings.block.confirm": "Blockieren",
"chat_settings.block.heading": "Blockieren", "chat_settings.block.heading": "@{acct} blockieren",
"chat_settings.block.message": "Durch das Blockieren wird dieses Profil daran gehindert, dir direkte Nachrichten zu senden und deine Inhalte anzusehen. Du kannst die Blockierung später aufheben.", "chat_settings.block.message": "Durch das Blockieren wird dieses Profil daran gehindert, dir Nachrichten zu senden und deine Inhalte anzusehen. Du kannst die Blockierung später aufheben.",
"chat_settings.leave.confirm": "Chat verlassen", "chat_settings.leave.confirm": "Chat verlassen",
"chat_settings.leave.heading": "Chat verlassen", "chat_settings.leave.heading": "Chat verlassen",
"chat_settings.leave.message": "Bist du sicher, dass du diesen Chat verlassen willst? Die Nachrichten werden für dich gelöscht und dieser Chat wird aus deinem Posteingang entfernt.", "chat_settings.leave.message": "Bist du sicher, dass du diesen Chat verlassen willst? Die Nachrichten werden für dich gelöscht und dieser Chat wird aus deinem Posteingang entfernt.",
"chat_settings.options.block_user": "Blockiere @{acct}", "chat_settings.options.block_user": "@{acct} blockieren",
"chat_settings.options.leave_chat": "Chat verlassen", "chat_settings.options.leave_chat": "Chat verlassen",
"chat_settings.options.report_user": "Melden", "chat_settings.options.report_user": "@{acct} melden",
"chat_settings.options.unblock_user": "Entblocke @{acct}", "chat_settings.options.unblock_user": "@{acct} nicht mehr blockieren",
"chat_settings.title": "Chateinzelheiten", "chat_settings.title": "Chat-Details",
"chat_settings.unblock.confirm": "Entblocken", "chat_settings.unblock.confirm": "Entblocken",
"chat_settings.unblock.heading": "Entblocke@{acct}", "chat_settings.unblock.heading": "Entblocke @{acct}",
"chat_settings.unblock.message": "Wenn du die Blockierung aufhebst, kann dieses Profil dir direkte Nachrichten schicken und deine Inhalte sehen.", "chat_settings.unblock.message": "Wenn du die Blockierung aufhebst, kann dieser Nutzer dir Nachrichten schicken und deine Inhalte sehen.",
"chat_window.auto_delete_label": "Automatisch löschen nach {day, plural, one {# Tag} other {# Tagen}}", "chat_window.auto_delete_label": "Automatisch löschen nach {day, plural, one {# Tag} other {# Tagen}}",
"chat_window.auto_delete_tooltip": "Chatnachrichten werden nach {day, plural, one {# Tag} other {# Tagen}} nach dem Senden automatisch gelöscht.", "chat_window.auto_delete_tooltip": "Chatnachrichten werden nach {day, plural, one {# Tag} other {# Tagen}} nach dem Senden automatisch gelöscht.",
"chats.actions.copy": "Kopieren", "chats.actions.copy": "Kopieren",
@ -258,10 +258,10 @@
"chats.actions.more": "Mehr", "chats.actions.more": "Mehr",
"chats.actions.report": "Nutzer melden", "chats.actions.report": "Nutzer melden",
"chats.dividers.today": "Heute", "chats.dividers.today": "Heute",
"chats.main.blankslate.new_chat": "Jemanden anschreiben", "chats.main.blankslate.new_chat": "Nachricht verfassen",
"chats.main.blankslate.subtitle": "Jemanden zum chatten suchen", "chats.main.blankslate.subtitle": "Jemanden zum chatten suchen",
"chats.main.blankslate.title": "Noch keine Nachrichten", "chats.main.blankslate.title": "Noch keine Nachrichten",
"chats.main.blankslate_with_chats.subtitle": "Wähle aus einem deiner offenen Chats oder erstelle eine neue Nachricht.", "chats.main.blankslate_with_chats.subtitle": "Wähle aus einem deiner bestehenden Chats oder erstelle eine neue Nachricht.",
"chats.main.blankslate_with_chats.title": "Chat auswählen", "chats.main.blankslate_with_chats.title": "Chat auswählen",
"chats.search_placeholder": "Chatten mit…", "chats.search_placeholder": "Chatten mit…",
"column.admin.awaiting_approval": "Wartet auf Bestätigung", "column.admin.awaiting_approval": "Wartet auf Bestätigung",
@ -352,37 +352,37 @@
"common.cancel": "Abbrechen", "common.cancel": "Abbrechen",
"common.error": "Ein Fehler ist aufgetreten. Versuche die Seite neu zu laden.", "common.error": "Ein Fehler ist aufgetreten. Versuche die Seite neu zu laden.",
"compare_history_modal.header": "Verlauf bearbeiten", "compare_history_modal.header": "Verlauf bearbeiten",
"compose.character_counter.title": "{chars} von {maxChars} Zeichen verwendet", "compose.character_counter.title": "{chars} von {maxChars} zulässigen Zeichen verwendet",
"compose.edit_success": "Dein Beitrag wurde bearbeitet", "compose.edit_success": "Dein Beitrag wurde bearbeitet",
"compose.invalid_schedule": "Der gewählte Zeitpunkt für vorbereitete Beiträge muss mindesten 5 Minuten in der Zukunft liegen.", "compose.invalid_schedule": "Der gewählte Zeitpunkt für vorbereitete Beiträge muss mindesten 5 Minuten in der Zukunft liegen.",
"compose.submit_success": "Beitrag gesendet!", "compose.submit_success": "Beitrag gesendet!",
"compose_event.create": "Erstellen", "compose_event.create": "Erstellen",
"compose_event.edit_success": "Deine Veranstaltung wurde bearbeitet", "compose_event.edit_success": "Deine Veranstaltung wurde bearbeitet",
"compose_event.fields.approval_required": "Ich möchte Teilnahmeanträge manuell genehmigen", "compose_event.fields.approval_required": "Ich möchte Teilnehmende manuell genehmigen",
"compose_event.fields.banner_label": "Veranstaltungsbanner", "compose_event.fields.banner_label": "Veranstaltungsbanner",
"compose_event.fields.description_hint": "Markdownsyntax wird unterstützt", "compose_event.fields.description_hint": "Markdownsyntax wird unterstützt",
"compose_event.fields.description_label": "Veranstaltungsbeschreibung", "compose_event.fields.description_label": "Veranstaltungsbeschreibung",
"compose_event.fields.description_placeholder": "Beschreibung", "compose_event.fields.description_placeholder": "Beschreibung",
"compose_event.fields.end_time_label": "Veranstaltungsenddatum", "compose_event.fields.end_time_label": "Veranstaltungsende",
"compose_event.fields.end_time_placeholder": "Veranstaltung endet am…", "compose_event.fields.end_time_placeholder": "Veranstaltung endet am…",
"compose_event.fields.has_end_time": "Die Veranstaltung hat Endtermin", "compose_event.fields.has_end_time": "Die Veranstaltung endet zu einer bestimmten Zeit",
"compose_event.fields.location_label": "Veranstaltungsort", "compose_event.fields.location_label": "Veranstaltungsort",
"compose_event.fields.name_label": "Veranstaltungsname", "compose_event.fields.name_label": "Veranstaltungsname",
"compose_event.fields.name_placeholder": "Name", "compose_event.fields.name_placeholder": "Name",
"compose_event.fields.start_time_label": "Veranstaltungsstarttermin", "compose_event.fields.start_time_label": "Veranstaltungsstart",
"compose_event.fields.start_time_placeholder": "Veranstaltung beginnt am…", "compose_event.fields.start_time_placeholder": "Veranstaltung beginnt am…",
"compose_event.participation_requests.authorize": "Autorisieren", "compose_event.participation_requests.authorize": "Zulassen",
"compose_event.participation_requests.authorize_success": "Benutzer akzeptiert", "compose_event.participation_requests.authorize_success": "Nutzer akzeptiert",
"compose_event.participation_requests.reject": "Ablehnen", "compose_event.participation_requests.reject": "Ablehnen",
"compose_event.participation_requests.reject_success": "Benutzer abgelehnt", "compose_event.participation_requests.reject_success": "Nutzer abgelehnt",
"compose_event.reset_location": "Standort zurücksetzen", "compose_event.reset_location": "Standort zurücksetzen",
"compose_event.submit_success": "Deine Veranstaltung wurde erstellt", "compose_event.submit_success": "Deine Veranstaltung wurde erstellt",
"compose_event.tabs.edit": "Einzelheiten bearbeiten", "compose_event.tabs.edit": "Details bearbeiten",
"compose_event.tabs.pending": "Anfragen verwalten", "compose_event.tabs.pending": "Anfragen verwalten",
"compose_event.update": "Updaten", "compose_event.update": "Aktualisieren",
"compose_event.upload_banner": "Veranstaltungsbanner hochladen", "compose_event.upload_banner": "Veranstaltungsbanner hochladen",
"compose_form.direct_message_warning": "Dieser Beitrag wird nur für die erwähnten Nutzer sichtbar sein.", "compose_form.direct_message_warning": "Dieser Beitrag wird nur für die erwähnten Nutzer sichtbar sein.",
"compose_form.event_placeholder": "Zu dieser Veranstaltung beitragen", "compose_form.event_placeholder": "In dieser Veranstaltung posten",
"compose_form.hashtag_warning": "Dieser Beitrag wird nicht durch Hashtags auffindbar sein, weil er ungelistet ist. Nur öffentliche Beiträge tauchen in Hashtag-Zeitleisten auf.", "compose_form.hashtag_warning": "Dieser Beitrag wird nicht durch Hashtags auffindbar sein, weil er ungelistet ist. Nur öffentliche Beiträge tauchen in Hashtag-Zeitleisten auf.",
"compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Wer dir folgen will, kann das jederzeit tun und dann auch deine privaten Beiträge sehen.", "compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Wer dir folgen will, kann das jederzeit tun und dann auch deine privaten Beiträge sehen.",
"compose_form.lock_disclaimer.lock": "auf privat gestellt", "compose_form.lock_disclaimer.lock": "auf privat gestellt",
@ -437,39 +437,40 @@
"confirmations.block.message": "Bist du dir sicher, dass du {name} blockieren möchtest?", "confirmations.block.message": "Bist du dir sicher, dass du {name} blockieren möchtest?",
"confirmations.block_from_group.confirm": "Blockieren", "confirmations.block_from_group.confirm": "Blockieren",
"confirmations.block_from_group.heading": "Gruppenmitglied blockieren", "confirmations.block_from_group.heading": "Gruppenmitglied blockieren",
"confirmations.block_from_group.message": "Möchtest du @{name} wirklich aus dieser Gruppe ausschließen?",
"confirmations.cancel.confirm": "Verwerfen", "confirmations.cancel.confirm": "Verwerfen",
"confirmations.cancel.heading": "Beitrag verwerfen", "confirmations.cancel.heading": "Beitrag verwerfen",
"confirmations.cancel.message": "Bist du sicher, dass du die Erstellung dieses Beitrags abbrechen willst?", "confirmations.cancel.message": "Bist du sicher, dass du diesen Entwurf löschen möchtest?",
"confirmations.cancel_editing.confirm": "Bearbeitung abbrechen", "confirmations.cancel_editing.confirm": "Bearbeitung abbrechen",
"confirmations.cancel_editing.heading": "Beitragsbearbeitung abbrechen", "confirmations.cancel_editing.heading": "Beitragsbearbeitung abbrechen",
"confirmations.cancel_editing.message": "Bist du sicher, dass du die Bearbeitung dieses Beitrags abbrechen willst? Alle Änderungen gehen dann verloren.", "confirmations.cancel_editing.message": "Bist du sicher, dass du die Bearbeitung dieses Beitrags abbrechen willst? Alle Änderungen gehen dann verloren.",
"confirmations.cancel_event_editing.heading": "Veranstaltungsbearbeitung abbrechen", "confirmations.cancel_event_editing.heading": "Veranstaltungsbearbeitung abbrechen",
"confirmations.cancel_event_editing.message": "Bist du sicher, dass du die Bearbeitung dieser Veranstaltung abbrechen willst? Alle Änderungen gehen dann verloren.", "confirmations.cancel_event_editing.message": "Bist du sicher, dass du die Bearbeitung dieser Veranstaltung abbrechen willst? Alle Änderungen gehen hierdurch verloren.",
"confirmations.delete.confirm": "Löschen", "confirmations.delete.confirm": "Löschen",
"confirmations.delete.heading": "Beitrag löschen", "confirmations.delete.heading": "Beitrag löschen",
"confirmations.delete.message": "Bist du dir sicher, dass du diesen Beitrag löschen möchtest?", "confirmations.delete.message": "Bist du dir sicher, dass du diesen Beitrag löschen möchtest?",
"confirmations.delete_event.confirm": "Löschen", "confirmations.delete_event.confirm": "Löschen",
"confirmations.delete_event.heading": "Lösche Veranstaltung", "confirmations.delete_event.heading": "Veranstaltung löschen",
"confirmations.delete_event.message": "Bist du sicher, dass du dieses Ereignis löschen willst?", "confirmations.delete_event.message": "Bist du sicher, dass du diese Veranstaltung löschen willst?",
"confirmations.delete_from_group.heading": "Aus der Gruppe löschen", "confirmations.delete_from_group.heading": "Aus der Gruppe löschen",
"confirmations.delete_from_group.message": "Soll der Beitrag von @{name} wirklich gelöscht werden?", "confirmations.delete_from_group.message": "Soll der Beitrag von @{name} wirklich gelöscht werden?",
"confirmations.delete_group.confirm": "Löschen", "confirmations.delete_group.confirm": "Löschen",
"confirmations.delete_group.heading": "Gruppe löschen", "confirmations.delete_group.heading": "Gruppe löschen",
"confirmations.delete_group.message": "Soll diese Gruppe wirklich gelöscht werden? Die Löschung kann nicht rückgängig gemacht werden.", "confirmations.delete_group.message": "Soll diese Gruppe wirklich gelöscht werden? Die Entfernung kann nicht rückgängig gemacht werden.",
"confirmations.delete_list.confirm": "Löschen", "confirmations.delete_list.confirm": "Löschen",
"confirmations.delete_list.heading": "Liste löschen", "confirmations.delete_list.heading": "Liste löschen",
"confirmations.delete_list.message": "Bist du dir sicher, dass du diese Liste permanent löschen möchtest?", "confirmations.delete_list.message": "Bist du dir sicher, dass du diese Liste permanent löschen möchtest?",
"confirmations.domain_block.confirm": "Die ganze Domain verbergen", "confirmations.domain_block.confirm": "Die ganze Domain verbergen",
"confirmations.domain_block.heading": "Blockiere {domain}", "confirmations.domain_block.heading": "Blockiere {domain}",
"confirmations.domain_block.message": "Bist du dir wirklich sicher, dass du die ganze Domain {domain} blockieren willst? In den meisten Fällen reichen ein paar gezielte Blockierungen oder Stummschaltungen aus. Du wirst den Inhalt von dieser Domain dann nicht mehr in irgendwelchen öffentlichen Timelines oder den Benachrichtigungen finden. Von dieser Domain kann dir auch niemand mehr folgen.", "confirmations.domain_block.message": "Bist du dir wirklich sicher, dass du die ganze Domain {domain} blockieren willst? In den meisten Fällen reichen ein paar gezielte Blockierungen oder Stummschaltungen aus. Du wirst den Inhalt von dieser Domain dann nicht mehr in irgendwelchen öffentlichen Timelines oder den Benachrichtigungen finden. Von dieser Domain kann dir auch niemand mehr folgen.",
"confirmations.kick_from_group.confirm": "Entfernen", "confirmations.kick_from_group.confirm": "Rauswerfen",
"confirmations.kick_from_group.heading": "Gruppenmitglied entfernen", "confirmations.kick_from_group.heading": "Gruppenmitglied rauswerfen",
"confirmations.kick_from_group.message": "@{name} wirklich aus der Gruppe entfernen?", "confirmations.kick_from_group.message": "@{name} wirklich aus der Gruppe entfernen?",
"confirmations.leave_event.confirm": "Verlasse Veranstaltung", "confirmations.leave_event.confirm": "Veranstaltung verlassen",
"confirmations.leave_event.message": "Wenn du der Veranstaltung wieder beitreten möchtest, wird der Antrag erneut manuell geprüft. Bist du sicher, dass du fortfahren möchtest?", "confirmations.leave_event.message": "Wenn du der Veranstaltung wieder beitreten möchtest, wird der Antrag erneut manuell geprüft. Bist du sicher, dass du fortfahren möchtest?",
"confirmations.leave_group.confirm": "Verlassen", "confirmations.leave_group.confirm": "Verlassen",
"confirmations.leave_group.heading": "Gruppe verlassen", "confirmations.leave_group.heading": "Gruppe verlassen",
"confirmations.leave_group.message": "Gruppe wirklich dauerhaft verlassen?", "confirmations.leave_group.message": "Gruppe wirklich verlassen?",
"confirmations.mute.confirm": "Stummschalten", "confirmations.mute.confirm": "Stummschalten",
"confirmations.mute.heading": "Stummschalten", "confirmations.mute.heading": "Stummschalten",
"confirmations.mute.message": "Bist du dir sicher, dass du {name} stummschalten möchtest?", "confirmations.mute.message": "Bist du dir sicher, dass du {name} stummschalten möchtest?",
@ -618,13 +619,16 @@
"empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe selbst den ersten Beitrag!", "empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe selbst den ersten Beitrag!",
"empty_column.direct": "Du hast noch keine Direktnachrichten erhalten. Wenn du eine sendest oder empfängst, wird sie hier zu sehen sein.", "empty_column.direct": "Du hast noch keine Direktnachrichten erhalten. Wenn du eine sendest oder empfängst, wird sie hier zu sehen sein.",
"empty_column.domain_blocks": "Bisher wurden noch keine Domains blockiert.", "empty_column.domain_blocks": "Bisher wurden noch keine Domains blockiert.",
"empty_column.event_participant_requests": "Es sind keine Anträge auf Teilnahme an Veranstaltungen anhängig.", "empty_column.event_participant_requests": "Es stehen keine Personen zu dieser Veranstaltungen aus.",
"empty_column.event_participants": "Bisher ist noch niemand diesem Ereignis beigetreten. Wenn es jemand tut, wird er hier auftauchen.", "empty_column.event_participants": "Bisher ist noch niemand dieser Veranstaltung beigetreten. Wenn es jemand tut, wird sie bzw. er hier auftauchen.",
"empty_column.favourited_statuses": "Du hast noch keine Beiträge favorisiert. Favorisierte Beiträge erscheinen hier.", "empty_column.favourited_statuses": "Du hast noch keine Beiträge favorisiert. Favorisierte Beiträge erscheinen hier.",
"empty_column.favourites": "Diesen Beitrag hat noch niemand favorisiert. Sobald es jemand tut, wird das hier angezeigt.", "empty_column.favourites": "Diesen Beitrag hat noch niemand favorisiert. Sobald es jemand tut, wird das hier angezeigt.",
"empty_column.filters": "Du hast keine Wörter stummgeschaltet.", "empty_column.filters": "Du hast keine Wörter stummgeschaltet.",
"empty_column.follow_recommendations": "Sieht so aus, als gibt es gerade keine Vorschläge für dich. Versuche, über die Suche bekannte Personen zu finden oder schaue dich in aktuellen Hashtags um.", "empty_column.follow_recommendations": "Sieht so aus, als gibt es gerade keine Vorschläge für dich. Versuche, über die Suche bekannte Personen zu finden oder schaue dich in aktuellen Hashtags um.",
"empty_column.follow_requests": "Du hast noch keine Folgeanfragen. Sobald du eine erhältst, wird sie hier angezeigt.", "empty_column.follow_requests": "Du hast noch keine Folgeanfragen. Sobald du eine erhältst, wird sie hier angezeigt.",
"empty_column.group": "Es gibt in dieser Gruppe noch keine Beiträge.",
"empty_column.group_blocks": "In dieser Gruppe wurden noch keine Personen blockiert.",
"empty_column.group_membership_requests": "Es gibt in dieser Gruppe noch keine ausstehenden Mitglieder.",
"empty_column.hashtag": "Unter diesem Hashtag gibt es noch nichts.", "empty_column.hashtag": "Unter diesem Hashtag gibt es noch nichts.",
"empty_column.home": "Deine Startseite ist leer! Besuche {public} oder nutze die Suche, um andere Nutzer zu finden.", "empty_column.home": "Deine Startseite ist leer! Besuche {public} oder nutze die Suche, um andere Nutzer zu finden.",
"empty_column.home.local_tab": "den Reiter {site_title}", "empty_column.home.local_tab": "den Reiter {site_title}",
@ -640,6 +644,7 @@
"empty_column.remote": "Hier gibt es nichts! Verfolge manuell die Benutzer von {instance}, um es aufzufüllen.", "empty_column.remote": "Hier gibt es nichts! Verfolge manuell die Benutzer von {instance}, um es aufzufüllen.",
"empty_column.scheduled_statuses": "Bisher wurden keine vorbereiteten Beiträge erstellt. Vorbereitete Beiträge werden hier angezeigt.", "empty_column.scheduled_statuses": "Bisher wurden keine vorbereiteten Beiträge erstellt. Vorbereitete Beiträge werden hier angezeigt.",
"empty_column.search.accounts": "Es wurden keine Nutzer unter \"{term}\" gefunden", "empty_column.search.accounts": "Es wurden keine Nutzer unter \"{term}\" gefunden",
"empty_column.search.groups": "Es wurden keine Gruppen bei der Suche nach \"{term}\" gefunden",
"empty_column.search.hashtags": "Es wurden keine Hashtags unter \"{term}\" gefunden", "empty_column.search.hashtags": "Es wurden keine Hashtags unter \"{term}\" gefunden",
"empty_column.search.statuses": "Es wurden keine Beiträge unter \"{term}\" gefunden", "empty_column.search.statuses": "Es wurden keine Beiträge unter \"{term}\" gefunden",
"empty_column.test": "Die Testzeitleiste ist leer.", "empty_column.test": "Die Testzeitleiste ist leer.",
@ -647,26 +652,26 @@
"event.copy": "Link zur Veranstaltung kopieren", "event.copy": "Link zur Veranstaltung kopieren",
"event.date": "Datum", "event.date": "Datum",
"event.description": "Beschreibung", "event.description": "Beschreibung",
"event.discussion.empty": "Bisher hat noch niemand diese Veranstaltung kommentiert. Wenn dies jemand tut, wird er hier erscheinen.", "event.discussion.empty": "Bisher hat noch niemand diese Veranstaltung kommentiert. Wenn jemand einen Kommentar erstellt, wird er hier erscheinen.",
"event.export_ics": "Zum Kalender exportieren", "event.export_ics": "Zum Kalender exportieren",
"event.external": "Veranstaltung auf {domain} anzeigen", "event.external": "Veranstaltung auf {domain} anzeigen",
"event.join_state.accept": "Gehen hin", "event.join_state.accept": "Teilnehmen",
"event.join_state.empty": "Nehmen teil", "event.join_state.empty": "Teilnehmen",
"event.join_state.pending": "Ausstehend", "event.join_state.pending": "Ausstehend",
"event.join_state.rejected": "Gehen hin", "event.join_state.rejected": "Nehme teil",
"event.location": "Ort", "event.location": "Ort",
"event.manage": "Verwalten", "event.manage": "Verwalten",
"event.organized_by": "Organisiert von {name}", "event.organized_by": "Veranstaltet von {name}",
"event.participants": "{count} {rawCount, plural, eine {person} andere {people}} gehen", "event.participants": "{count} {rawCount, plural, eine {person} andere {people}} gehen",
"event.quote": "Veranstaltung zitieren", "event.quote": "Veranstaltung zitieren",
"event.reblog": "Veranstaltung teilen", "event.reblog": "Veranstaltung teilen",
"event.show_on_map": "Auf Karte anzeigen", "event.show_on_map": "Auf Karte anzeigen",
"event.unreblog": "Veranstaltung unteilen", "event.unreblog": "Veranstaltung nicht mehr teilen",
"event.website": "Externe links", "event.website": "Externe Links",
"event_map.navigate": "Navigieren", "event_map.navigate": "Navigieren",
"events.create_event": "Veranstaltung erstellen", "events.create_event": "Veranstaltung erstellen",
"events.joined_events": "Beigetretene Veranstaltungen", "events.joined_events": "Veranstaltungen, an denen ich teilnehme",
"events.joined_events.empty": "Du bist noch keiner Veranstaltung beigetreten.", "events.joined_events.empty": "Du hast bisher noch an keiner Veranstaltung teilgenommen.",
"events.recent_events": "Kürzliche Veranstaltungen", "events.recent_events": "Kürzliche Veranstaltungen",
"events.recent_events.empty": "Es gibt noch keine öffentlichen Veranstaltungen.", "events.recent_events.empty": "Es gibt noch keine öffentlichen Veranstaltungen.",
"export_data.actions.export": "Exportieren", "export_data.actions.export": "Exportieren",
@ -715,6 +720,42 @@
"gdpr.message": "{siteTitle} verwendet Sitzungscookies, die für das Funktionieren der Website unerlässlich sind.", "gdpr.message": "{siteTitle} verwendet Sitzungscookies, die für das Funktionieren der Website unerlässlich sind.",
"gdpr.title": "{siteTitle} verwendet Cookies", "gdpr.title": "{siteTitle} verwendet Cookies",
"getting_started.open_source_notice": "{code_name} ist quelloffene Software. Du kannst auf GitLab unter {code_link} (v{code_version}) mitarbeiten oder Probleme melden.", "getting_started.open_source_notice": "{code_name} ist quelloffene Software. Du kannst auf GitLab unter {code_link} (v{code_version}) mitarbeiten oder Probleme melden.",
"group.admin_subheading": "Gruppenadministratoren",
"group.cancel_request": "Anfrage zurückziehen",
"group.group_mod_authorize": "Annehmen",
"group.group_mod_authorize.success": "Aufnahmeanfrage von @{name} annehmen",
"group.group_mod_block": "@{name} aus der Gruppe blockieren",
"group.group_mod_block.success": "@{name} in der Gruppe blockiert",
"group.group_mod_demote": "@{name} herunterstufen",
"group.group_mod_demote.success": "@{name} heruntergestuft",
"group.group_mod_kick": "@{name} aus der Gruppe werfen",
"group.group_mod_kick.success": "@{name} aus der Gruppe entfernt",
"group.group_mod_promote_admin": "@{name} zum Gruppenadmin ernennen",
"group.group_mod_promote_admin.success": "@{name} als Gruppen-Admin ernannt",
"group.group_mod_promote_mod": "@{name} zur Moderator:in der Gruppe ernennen",
"group.group_mod_promote_mod.success": "@{name} als Moderator:in ernannt",
"group.group_mod_reject": "Ablehnen",
"group.group_mod_reject.success": "@{name} in der Gruppe abgelehnt",
"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.request_success": "Mitgliedschaft in der Gruppe angefragt",
"group.join.success": "Gruppe beigetreten",
"group.leave": "Gruppe verlassen",
"group.leave.success": "Gruppe verlassen",
"group.manage": "Gruppe verwalten",
"group.moderator_subheading": "Moderator:innen der Gruppe",
"group.privacy.locked": "Privat",
"group.privacy.public": "Öffentlich",
"group.request_join": "Mitgliedschaft in der Gruppe anfragen",
"group.role.admin": "Administrator:in",
"group.role.moderator": "Moderator:in",
"group.tabs.all": "Alle",
"group.tabs.members": "Mitglieder",
"group.user_subheading": "Nutzer:innen",
"groups.empty.subtitle": "Entdecke Gruppen zum teilnehmen oder erstelle deine eigene.",
"groups.empty.title": "Noch keine Gruppen",
"hashtag.column_header.tag_mode.all": "und {additional}", "hashtag.column_header.tag_mode.all": "und {additional}",
"hashtag.column_header.tag_mode.any": "oder {additional}", "hashtag.column_header.tag_mode.any": "oder {additional}",
"hashtag.column_header.tag_mode.none": "ohne {additional}", "hashtag.column_header.tag_mode.none": "ohne {additional}",
@ -749,7 +790,7 @@
"intervals.full.days": "{number, plural, one {# Tag} other {# Tage}}", "intervals.full.days": "{number, plural, one {# Tag} other {# Tage}}",
"intervals.full.hours": "{number, plural, one {# Stunde} other {# Stunden}}", "intervals.full.hours": "{number, plural, one {# Stunde} other {# Stunden}}",
"intervals.full.minutes": "{number, plural, one {# Minute} other {# Minuten}}", "intervals.full.minutes": "{number, plural, one {# Minute} other {# Minuten}}",
"join_event.hint": "Du kannst dem Veranstalter mitteilen, warum du an dieser Veranstaltung teilnehmen möchtest:", "join_event.hint": "Du kannst dem Veranstalter mitteilen, warum du an dieser Veranstaltung teilnehmen möchtest:",
"join_event.join": "Beitritt beantragen", "join_event.join": "Beitritt beantragen",
"join_event.placeholder": "Nachricht an Veranstalter", "join_event.placeholder": "Nachricht an Veranstalter",
"join_event.request_success": "Veranstaltungsbeitrittsantrag gesendet", "join_event.request_success": "Veranstaltungsbeitrittsantrag gesendet",
@ -818,6 +859,27 @@
"login_external.errors.instance_fail": "Die Instanz gab einen Fehler zurück.", "login_external.errors.instance_fail": "Die Instanz gab einen Fehler zurück.",
"login_external.errors.network_fail": "Verbindung fehlgeschlagen. Wird sie durch eine Browsererweiterung blockiert?", "login_external.errors.network_fail": "Verbindung fehlgeschlagen. Wird sie durch eine Browsererweiterung blockiert?",
"login_form.header": "Sign In", "login_form.header": "Sign In",
"manage_group.blocked_members": "Blockierte Mitglieder",
"manage_group.create": "Erstellen",
"manage_group.delete_group": "Gruppe löschen",
"manage_group.edit_group": "Gruppe bearbeiten",
"manage_group.edit_success": "Gruppe wurde bearbeitet",
"manage_group.fields.description_label": "Beschreibung",
"manage_group.fields.description_placeholder": "Beschreibung",
"manage_group.fields.name_label": "Gruppennname (Pflichtfeld)",
"manage_group.fields.name_placeholder": "Gruppenname",
"manage_group.get_started": "Lass uns loslegen!",
"manage_group.next": "Weiter",
"manage_group.pending_requests": "Ausstehende Anfragen",
"manage_group.privacy.hint": "Diese Einstellungen könnnen später nicht geändert werden.",
"manage_group.privacy.label": "Privatsphäreneinstellungen",
"manage_group.privacy.private.hint": "Gelistet. Leute können teilnehmen, nachdem ihre Anfrage bestätigt wurde.",
"manage_group.privacy.private.label": "Privat (Bestätigung durch Veranstalter erforderlich)",
"manage_group.privacy.public.hint": "Gelistet. Jede:r kann teilnehmen.",
"manage_group.privacy.public.label": "Öffentlich",
"manage_group.submit_success": "Die Gruppe wurde erstellt",
"manage_group.tagline": "Gruppen ermöglichen es dir, neue Leute auf Grundlage gemeinsamer Interessen zu finden.",
"manage_group.update": "Update",
"media_panel.empty_message": "Keine Medien gefunden.", "media_panel.empty_message": "Keine Medien gefunden.",
"media_panel.title": "Media", "media_panel.title": "Media",
"mfa.confirm.success_message": "MFA bestätigt", "mfa.confirm.success_message": "MFA bestätigt",
@ -886,6 +948,7 @@
"navigation_bar.create_event": "Neue Veranstaltung erstellen", "navigation_bar.create_event": "Neue Veranstaltung erstellen",
"navigation_bar.create_group": "Gruppe erstellen", "navigation_bar.create_group": "Gruppe erstellen",
"navigation_bar.domain_blocks": "Versteckte Domains", "navigation_bar.domain_blocks": "Versteckte Domains",
"navigation_bar.edit_group": "Gruppe bearbeiten",
"navigation_bar.favourites": "Favoriten", "navigation_bar.favourites": "Favoriten",
"navigation_bar.filters": "Stummgeschaltete Wörter", "navigation_bar.filters": "Stummgeschaltete Wörter",
"navigation_bar.follow_requests": "Folgeanfragen", "navigation_bar.follow_requests": "Folgeanfragen",
@ -897,6 +960,9 @@
"navigation_bar.preferences": "Einstellungen", "navigation_bar.preferences": "Einstellungen",
"navigation_bar.profile_directory": "Profilverzeichnis", "navigation_bar.profile_directory": "Profilverzeichnis",
"navigation_bar.soapbox_config": "Soapbox-Konfiguration", "navigation_bar.soapbox_config": "Soapbox-Konfiguration",
"new_group_panel.action": "Gruppe erstellen",
"new_group_panel.subtitle": "Du findest nicht, wonach du gesucht hast? Erstelle deine eigene private oder öffentliche Gruppe.",
"new_group_panel.title": "Neue Gruppe erstellen",
"notification.favourite": "{name} hat deinen Beitrag favorisiert", "notification.favourite": "{name} hat deinen Beitrag favorisiert",
"notification.follow": "{name} folgt dir", "notification.follow": "{name} folgt dir",
"notification.follow_request": "{name} möchte dir folgen", "notification.follow_request": "{name} möchte dir folgen",
@ -1127,6 +1193,7 @@
"search.placeholder": "Suche", "search.placeholder": "Suche",
"search_results.accounts": "Personen", "search_results.accounts": "Personen",
"search_results.filter_message": "Du suchst nach Beiträgen von @{acct}.", "search_results.filter_message": "Du suchst nach Beiträgen von @{acct}.",
"search_results.groups": "Gruppen",
"search_results.hashtags": "Hashtags", "search_results.hashtags": "Hashtags",
"search_results.statuses": "Beiträge", "search_results.statuses": "Beiträge",
"security.codes.fail": "Abrufen von Sicherheitskopiecodes fehlgeschlagen", "security.codes.fail": "Abrufen von Sicherheitskopiecodes fehlgeschlagen",
@ -1237,6 +1304,8 @@
"sponsored.subtitle": "Werbebeitrag", "sponsored.subtitle": "Werbebeitrag",
"status.admin_account": "Öffne Moderationsoberfläche für @{name}", "status.admin_account": "Öffne Moderationsoberfläche für @{name}",
"status.admin_status": "Öffne Beitrag in der Moderationsoberfläche", "status.admin_status": "Öffne Beitrag in der Moderationsoberfläche",
"status.approval.pending": "Ausstehende Anfrage",
"status.approval.rejected": "Abgelehnt",
"status.bookmark": "Lesezeichen", "status.bookmark": "Lesezeichen",
"status.bookmarked": "Lesezeichen angelegt.", "status.bookmarked": "Lesezeichen angelegt.",
"status.cancel_reblog_private": "Teilen zurücknehmen", "status.cancel_reblog_private": "Teilen zurücknehmen",
@ -1246,11 +1315,16 @@
"status.delete": "Löschen", "status.delete": "Löschen",
"status.detailed_status": "Detaillierte Ansicht der Unterhaltung", "status.detailed_status": "Detaillierte Ansicht der Unterhaltung",
"status.direct": "Direktnachricht", "status.direct": "Direktnachricht",
"status.disabled_replies.group_membership": "Nur Gruppenmitglieder können antworten",
"status.edit": "Bearbeiten", "status.edit": "Bearbeiten",
"status.embed": "Einbetten", "status.embed": "Einbetten",
"status.external": "Öffne auf Heimatdomäne", "status.external": "Öffne auf Heimatdomäne",
"status.favourite": "Favorisieren", "status.favourite": "Favorisieren",
"status.filtered": "Gefiltert", "status.filtered": "Gefiltert",
"status.group": "Gepostet in {group}",
"status.group_mod_block": "@{name} in der Gruppe blockieren",
"status.group_mod_delete": "Post in der Gruppe löschen",
"status.group_mod_kick": "@{name} aus der Gruppe entfernen",
"status.interactions.favourites": "{count, plural, one {Mal favorisiert} other {Mal favorisiert}}", "status.interactions.favourites": "{count, plural, one {Mal favorisiert} other {Mal favorisiert}}",
"status.interactions.quotes": "{count, plural, one {Mal zitiert} other {Mal zitiert}}", "status.interactions.quotes": "{count, plural, one {Mal zitiert} other {Mal zitiert}}",
"status.interactions.reblogs": "{count, plural, one {Mal geteilt} other {Mal geteilt}}", "status.interactions.reblogs": "{count, plural, one {Mal geteilt} other {Mal geteilt}}",
@ -1311,6 +1385,7 @@
"tabs_bar.all": "Alle", "tabs_bar.all": "Alle",
"tabs_bar.dashboard": "Steuerung", "tabs_bar.dashboard": "Steuerung",
"tabs_bar.fediverse": "Fediverse", "tabs_bar.fediverse": "Fediverse",
"tabs_bar.groups": "Gruppen",
"tabs_bar.home": "Start", "tabs_bar.home": "Start",
"tabs_bar.local": "Lokal", "tabs_bar.local": "Lokal",
"tabs_bar.more": "Mehr", "tabs_bar.more": "Mehr",

@ -47,6 +47,7 @@
"account.report": "Report @{name}", "account.report": "Report @{name}",
"account.requested": "Awaiting approval. Click to cancel follow request", "account.requested": "Awaiting approval. Click to cancel follow request",
"account.requested_small": "Awaiting approval", "account.requested_small": "Awaiting approval",
"account.rss_feed": "Subscribe to RSS feed",
"account.search": "Search from @{name}", "account.search": "Search from @{name}",
"account.search_self": "Search your posts", "account.search_self": "Search your posts",
"account.share": "Share @{name}'s profile", "account.share": "Share @{name}'s profile",
@ -705,8 +706,6 @@
"filters.context_header": "Filter contexts", "filters.context_header": "Filter contexts",
"filters.context_hint": "One or multiple contexts where the filter should apply", "filters.context_hint": "One or multiple contexts where the filter should apply",
"filters.filters_list_context_label": "Filter contexts:", "filters.filters_list_context_label": "Filter contexts:",
"filters.filters_list_delete": "Delete",
"filters.filters_list_details_label": "Filter settings:",
"filters.filters_list_drop": "Drop", "filters.filters_list_drop": "Drop",
"filters.filters_list_hide": "Hide", "filters.filters_list_hide": "Hide",
"filters.filters_list_phrase_label": "Keyword or phrase:", "filters.filters_list_phrase_label": "Keyword or phrase:",
@ -960,6 +959,9 @@
"navigation_bar.preferences": "Preferences", "navigation_bar.preferences": "Preferences",
"navigation_bar.profile_directory": "Profile directory", "navigation_bar.profile_directory": "Profile directory",
"navigation_bar.soapbox_config": "Soapbox config", "navigation_bar.soapbox_config": "Soapbox config",
"new_event_panel.action": "Create event",
"new_event_panel.subtitle": "Can't find what you're looking for? Schedule your own event.",
"new_event_panel.title": "Create New Event",
"new_group_panel.action": "Create group", "new_group_panel.action": "Create group",
"new_group_panel.subtitle": "Can't find what you're looking for? Start your own private or public group.", "new_group_panel.subtitle": "Can't find what you're looking for? Start your own private or public group.",
"new_group_panel.title": "Create New Group", "new_group_panel.title": "Create New Group",

@ -47,6 +47,7 @@
"account.report": "Reportar a @{name}", "account.report": "Reportar a @{name}",
"account.requested": "Esperando aprobación", "account.requested": "Esperando aprobación",
"account.requested_small": "En espera de aprobación", "account.requested_small": "En espera de aprobación",
"account.rss_feed": "Suscríbete a la fuente RSS",
"account.search": "Buscar en base a @{name}", "account.search": "Buscar en base a @{name}",
"account.search_self": "Busca en tus entradas", "account.search_self": "Busca en tus entradas",
"account.share": "Compartir el perfil de @{name}", "account.share": "Compartir el perfil de @{name}",
@ -960,6 +961,9 @@
"navigation_bar.preferences": "Preferencias", "navigation_bar.preferences": "Preferencias",
"navigation_bar.profile_directory": "Profile directory", "navigation_bar.profile_directory": "Profile directory",
"navigation_bar.soapbox_config": "Soapbox config", "navigation_bar.soapbox_config": "Soapbox config",
"new_event_panel.action": "Crear un evento",
"new_event_panel.subtitle": "¿No encuentra lo que busca? Programe su propio evento.",
"new_event_panel.title": "Crear un nuevo evento",
"new_group_panel.action": "Crear un grupo", "new_group_panel.action": "Crear un grupo",
"new_group_panel.subtitle": "¿No encuentra lo que busca? Crea tu propio grupo privado o público.", "new_group_panel.subtitle": "¿No encuentra lo que busca? Crea tu propio grupo privado o público.",
"new_group_panel.title": "Crear un nuevo grupo", "new_group_panel.title": "Crear un nuevo grupo",

@ -47,6 +47,7 @@
"account.report": "Segnala @{name}", "account.report": "Segnala @{name}",
"account.requested": "In attesa di approvazione", "account.requested": "In attesa di approvazione",
"account.requested_small": "In approvazione", "account.requested_small": "In approvazione",
"account.rss_feed": "Iscriviti al feed RSS",
"account.search": "Cerca da @{name}", "account.search": "Cerca da @{name}",
"account.search_self": "Cerca tra le tue pubblicazioni", "account.search_self": "Cerca tra le tue pubblicazioni",
"account.share": "Condividi il profilo di @{name}", "account.share": "Condividi il profilo di @{name}",
@ -960,6 +961,9 @@
"navigation_bar.preferences": "Preferenze", "navigation_bar.preferences": "Preferenze",
"navigation_bar.profile_directory": "Esplora i profili", "navigation_bar.profile_directory": "Esplora i profili",
"navigation_bar.soapbox_config": "Configura Soapbox", "navigation_bar.soapbox_config": "Configura Soapbox",
"new_event_panel.action": "Crea evento",
"new_event_panel.subtitle": "Non riesci a trovare niente? Pianifica tu un evento.",
"new_event_panel.title": "Crea un nuovo evento",
"new_group_panel.action": "Crea gruppo", "new_group_panel.action": "Crea gruppo",
"new_group_panel.subtitle": "Non riesci a trovare qualcosa sul tema? Crea un gruppo privato o pubblico.", "new_group_panel.subtitle": "Non riesci a trovare qualcosa sul tema? Crea un gruppo privato o pubblico.",
"new_group_panel.title": "Crea nuovo gruppo", "new_group_panel.title": "Crea nuovo gruppo",

@ -47,6 +47,7 @@
"account.report": "Zgłoś @{name}", "account.report": "Zgłoś @{name}",
"account.requested": "Oczekująca prośba, kliknij aby anulować", "account.requested": "Oczekująca prośba, kliknij aby anulować",
"account.requested_small": "Oczekująca prośba", "account.requested_small": "Oczekująca prośba",
"account.rss_feed": "Subskrybuj kanał RSS",
"account.search": "Szukaj wpisów @{name}", "account.search": "Szukaj wpisów @{name}",
"account.search_self": "Szukaj własnych wpisów", "account.search_self": "Szukaj własnych wpisów",
"account.share": "Udostępnij profil @{name}", "account.share": "Udostępnij profil @{name}",

@ -47,6 +47,7 @@
"account.report": "举报 @{name}", "account.report": "举报 @{name}",
"account.requested": "正在等待对方批准。点击以取消发送关注请求", "account.requested": "正在等待对方批准。点击以取消发送关注请求",
"account.requested_small": "等待批准", "account.requested_small": "等待批准",
"account.rss_feed": "订阅 RSS 源",
"account.search": "在 @{name} 的内容中搜索", "account.search": "在 @{name} 的内容中搜索",
"account.search_self": "搜索您的帖文", "account.search_self": "搜索您的帖文",
"account.share": "分享 @{name} 的个人资料", "account.share": "分享 @{name} 的个人资料",
@ -960,6 +961,9 @@
"navigation_bar.preferences": "首选项", "navigation_bar.preferences": "首选项",
"navigation_bar.profile_directory": "发现用户", "navigation_bar.profile_directory": "发现用户",
"navigation_bar.soapbox_config": "Soapbox 设置", "navigation_bar.soapbox_config": "Soapbox 设置",
"new_event_panel.action": "创建活动",
"new_event_panel.subtitle": "找不到您要查找的内容?安排您自己的活动。",
"new_event_panel.title": "创建新活动",
"new_group_panel.action": "创建群组", "new_group_panel.action": "创建群组",
"new_group_panel.subtitle": "找不到你要找的东西?开始你自己的私有或公共群组。", "new_group_panel.subtitle": "找不到你要找的东西?开始你自己的私有或公共群组。",
"new_group_panel.title": "创建新群组", "new_group_panel.title": "创建新群组",
@ -1123,11 +1127,11 @@
"registrations.unprocessable_entity": "此用户名已被占用。", "registrations.unprocessable_entity": "此用户名已被占用。",
"registrations.username.hint": "只能包含字母、数字和下划线", "registrations.username.hint": "只能包含字母、数字和下划线",
"registrations.username.label": "您的用户名", "registrations.username.label": "您的用户名",
"relative_time.days": "{number}d", "relative_time.days": "{number}",
"relative_time.hours": "{number}h", "relative_time.hours": "{number}",
"relative_time.just_now": "刚刚", "relative_time.just_now": "刚刚",
"relative_time.minutes": "{number}m", "relative_time.minutes": "{number}",
"relative_time.seconds": "{number}s", "relative_time.seconds": "{number}",
"remote_instance.edit_federation": "编辑联邦设置", "remote_instance.edit_federation": "编辑联邦设置",
"remote_instance.federation_panel.heading": "联邦站点限制", "remote_instance.federation_panel.heading": "联邦站点限制",
"remote_instance.federation_panel.no_restrictions_message": "{siteTitle} 未对 {host} 设置限制。", "remote_instance.federation_panel.no_restrictions_message": "{siteTitle} 未对 {host} 设置限制。",

@ -0,0 +1,23 @@
import { Record as ImmutableRecord } from 'immutable';
import { normalizeAttachment } from '../attachment';
import { normalizeChatMessage } from '../chat-message';
describe('normalizeChatMessage()', () => {
it('upgrades attachment to media_attachments', () => {
const message = {
id: 'abc',
attachment: normalizeAttachment({
id: 'def',
url: 'https://gleasonator.com/favicon.png',
}),
};
const result = normalizeChatMessage(message);
expect(ImmutableRecord.isRecord(result)).toBe(true);
expect(result.id).toEqual('abc');
expect(result.media_attachments.first()?.id).toEqual('def');
expect(result.media_attachments.first()?.preview_url).toEqual('https://gleasonator.com/favicon.png');
});
});

@ -7,16 +7,20 @@ import {
import { normalizeAttachment } from 'soapbox/normalizers/attachment'; import { normalizeAttachment } from 'soapbox/normalizers/attachment';
import type { Attachment, Card, Emoji } from 'soapbox/types/entities'; import { normalizeEmojiReaction } from './emoji-reaction';
import type { Attachment, Card, Emoji, EmojiReaction } from 'soapbox/types/entities';
export const ChatMessageRecord = ImmutableRecord({ export const ChatMessageRecord = ImmutableRecord({
account_id: '', account_id: '',
attachment: null as Attachment | null, media_attachments: ImmutableList<Attachment>(),
card: null as Card | null, card: null as Card | null,
chat_id: '', chat_id: '',
content: '', content: '',
created_at: '', created_at: '',
emojis: ImmutableList<Emoji>(), emojis: ImmutableList<Emoji>(),
expiration: null as number | null,
emoji_reactions: ImmutableList<EmojiReaction>(),
id: '', id: '',
unread: false, unread: false,
deleting: false, deleting: false,
@ -24,12 +28,25 @@ export const ChatMessageRecord = ImmutableRecord({
}); });
const normalizeMedia = (status: ImmutableMap<string, any>) => { const normalizeMedia = (status: ImmutableMap<string, any>) => {
const attachments = status.get('media_attachments');
const attachment = status.get('attachment'); const attachment = status.get('attachment');
if (attachment) { if (attachments) {
return status.set('attachment', normalizeAttachment(attachment)); return status.set('media_attachments', ImmutableList(attachments.map(normalizeAttachment)));
} else if (attachment) {
return status.set('media_attachments', ImmutableList([normalizeAttachment(attachment)]));
} else {
return status.set('media_attachments', ImmutableList());
}
};
const normalizeChatMessageEmojiReaction = (chatMessage: ImmutableMap<string, any>) => {
const emojiReactions = chatMessage.get('emoji_reactions');
if (emojiReactions) {
return chatMessage.set('emoji_reactions', ImmutableList(emojiReactions.map(normalizeEmojiReaction)));
} else { } else {
return status; return chatMessage;
} }
}; };
@ -37,6 +54,7 @@ export const normalizeChatMessage = (chatMessage: Record<string, any>) => {
return ChatMessageRecord( return ChatMessageRecord(
ImmutableMap(fromJS(chatMessage)).withMutations(chatMessage => { ImmutableMap(fromJS(chatMessage)).withMutations(chatMessage => {
normalizeMedia(chatMessage); normalizeMedia(chatMessage);
normalizeChatMessageEmojiReaction(chatMessage);
}), }),
); );
}; };

@ -0,0 +1,14 @@
import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
// https://docs.joinmastodon.org/entities/emoji/
export const EmojiReactionRecord = ImmutableRecord({
name: '',
count: null as number | null,
me: false,
});
export const normalizeEmojiReaction = (emojiReaction: Record<string, any>) => {
return EmojiReactionRecord(
ImmutableMap(fromJS(emojiReaction)),
);
};

@ -5,11 +5,13 @@
*/ */
import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
export type ContextType = 'home' | 'public' | 'notifications' | 'thread';
// https://docs.joinmastodon.org/entities/filter/ // https://docs.joinmastodon.org/entities/filter/
export const FilterRecord = ImmutableRecord({ export const FilterRecord = ImmutableRecord({
id: '', id: '',
phrase: '', phrase: '',
context: ImmutableList<string>(), context: ImmutableList<ContextType>(),
whole_word: false, whole_word: false,
expires_at: '', expires_at: '',
irreversible: false, irreversible: false,

@ -8,6 +8,7 @@ export { CardRecord, normalizeCard } from './card';
export { ChatRecord, normalizeChat } from './chat'; export { ChatRecord, normalizeChat } from './chat';
export { ChatMessageRecord, normalizeChatMessage } from './chat-message'; export { ChatMessageRecord, normalizeChatMessage } from './chat-message';
export { EmojiRecord, normalizeEmoji } from './emoji'; export { EmojiRecord, normalizeEmoji } from './emoji';
export { EmojiReactionRecord } from './emoji-reaction';
export { FilterRecord, normalizeFilter } from './filter'; export { FilterRecord, normalizeFilter } from './filter';
export { GroupRecord, normalizeGroup } from './group'; export { GroupRecord, normalizeGroup } from './group';
export { GroupRelationshipRecord, normalizeGroupRelationship } from './group-relationship'; export { GroupRelationshipRecord, normalizeGroupRelationship } from './group-relationship';

@ -0,0 +1,47 @@
import React from 'react';
import { Layout } from 'soapbox/components/ui';
import LinkFooter from 'soapbox/features/ui/components/link-footer';
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
import {
WhoToFollowPanel,
TrendsPanel,
NewEventPanel,
} from 'soapbox/features/ui/util/async-components';
import { useFeatures } from 'soapbox/hooks';
interface IEventsPage {
children: React.ReactNode
}
/** Page to display events list. */
const EventsPage: React.FC<IEventsPage> = ({ children }) => {
const features = useFeatures();
return (
<>
<Layout.Main>
{children}
</Layout.Main>
<Layout.Aside>
<BundleContainer fetchComponent={NewEventPanel}>
{Component => <Component key='new-event-panel' />}
</BundleContainer>
{features.trends && (
<BundleContainer fetchComponent={TrendsPanel}>
{Component => <Component limit={5} key='trends-panel' />}
</BundleContainer>
)}
{features.suggestions && (
<BundleContainer fetchComponent={WhoToFollowPanel}>
{Component => <Component limit={3} key='wtf-panel' />}
</BundleContainer>
)}
<LinkFooter key='link-footer' />
</Layout.Aside>
</>
);
};
export default EventsPage;

@ -3,45 +3,30 @@ import React from 'react';
import { Column, Layout } from 'soapbox/components/ui'; import { Column, Layout } from 'soapbox/components/ui';
import LinkFooter from 'soapbox/features/ui/components/link-footer'; import LinkFooter from 'soapbox/features/ui/components/link-footer';
import BundleContainer from 'soapbox/features/ui/containers/bundle-container'; import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
import { import { NewGroupPanel } from 'soapbox/features/ui/util/async-components';
NewGroupPanel,
CtaBanner,
} from 'soapbox/features/ui/util/async-components';
import { useAppSelector } from 'soapbox/hooks';
interface IGroupsPage { interface IGroupsPage {
children: React.ReactNode children: React.ReactNode
} }
/** Page to display groups. */ /** Page to display groups. */
const GroupsPage: React.FC<IGroupsPage> = ({ children }) => { const GroupsPage: React.FC<IGroupsPage> = ({ children }) => (
const me = useAppSelector(state => state.me); <>
// const match = useRouteMatch(); <Layout.Main>
<Column withHeader={false}>
<div className='space-y-4'>
{children}
</div>
</Column>
</Layout.Main>
return ( <Layout.Aside>
<> <BundleContainer fetchComponent={NewGroupPanel}>
<Layout.Main> {Component => <Component key='new-group-panel' />}
<Column withHeader={false}> </BundleContainer>
<div className='space-y-4'> <LinkFooter key='link-footer' />
{children} </Layout.Aside>
</div> </>
</Column> );
{!me && (
<BundleContainer fetchComponent={CtaBanner}>
{Component => <Component key='cta-banner' />}
</BundleContainer>
)}
</Layout.Main>
<Layout.Aside>
<BundleContainer fetchComponent={NewGroupPanel}>
{Component => <Component key='new-group-panel' />}
</BundleContainer>
<LinkFooter key='link-footer' />
</Layout.Aside>
</>
);
};
export default GroupsPage; export default GroupsPage;

@ -1,15 +1,17 @@
import { Map as ImmutableMap } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import sumBy from 'lodash/sumBy'; import sumBy from 'lodash/sumBy';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { __stub } from 'soapbox/api'; import { __stub } from 'soapbox/api';
import { createTestStore, mockStore, queryClient, renderHook, rootState, waitFor } from 'soapbox/jest/test-helpers'; import { createTestStore, mockStore, queryClient, renderHook, rootState, waitFor } from 'soapbox/jest/test-helpers';
import { normalizeRelationship } from 'soapbox/normalizers'; import { normalizeChatMessage, normalizeRelationship } from 'soapbox/normalizers';
import { normalizeEmojiReaction } from 'soapbox/normalizers/emoji-reaction';
import { Store } from 'soapbox/store'; import { Store } from 'soapbox/store';
import { ChatMessage } from 'soapbox/types/entities';
import { flattenPages } from 'soapbox/utils/queries'; import { flattenPages } from 'soapbox/utils/queries';
import { IAccount } from '../accounts'; import { IAccount } from '../accounts';
import { ChatKeys, IChat, IChatMessage, isLastMessage, useChat, useChatActions, useChatMessages, useChats } from '../chats'; import { ChatKeys, IChat, isLastMessage, useChat, useChatActions, useChatMessages, useChats } from '../chats';
const chat: IChat = { const chat: IChat = {
accepted: true, accepted: true,
@ -22,6 +24,7 @@ const chat: IChat = {
avatar_static: 'avatar', avatar_static: 'avatar',
display_name: 'my name', display_name: 'my name',
} as IAccount, } as IAccount,
chat_type: 'direct',
created_at: '2020-06-10T02:05:06.000Z', created_at: '2020-06-10T02:05:06.000Z',
created_by_account: '1', created_by_account: '1',
discarded_at: null, discarded_at: null,
@ -33,12 +36,14 @@ const chat: IChat = {
unread: 0, unread: 0,
}; };
const buildChatMessage = (id: string): IChatMessage => ({ const buildChatMessage = (id: string) => normalizeChatMessage({
id, id,
chat_id: '1', chat_id: '1',
account_id: '1', account_id: '1',
content: `chat message #${id}`, content: `chat message #${id}`,
created_at: '2020-06-10T02:05:06.000Z', created_at: '2020-06-10T02:05:06.000Z',
emoji_reactions: null,
expiration: 1209600,
unread: true, unread: true,
}); });
@ -365,7 +370,7 @@ describe('useChatActions', () => {
const { updateChat } = useChatActions(chat.id); const { updateChat } = useChatActions(chat.id);
useEffect(() => { useEffect(() => {
updateChat.mutate({ message_expiration: 1200 }); updateChat.mutate({ message_expiration: 1200 });
}, []); }, []);
return updateChat; return updateChat;
@ -379,4 +384,52 @@ describe('useChatActions', () => {
expect((nextQueryData as any).message_expiration).toBe(1200); expect((nextQueryData as any).message_expiration).toBe(1200);
}); });
}); });
describe('createReaction()', () => {
const chatMessage = buildChatMessage('1');
beforeEach(() => {
__stub((mock) => {
mock
.onPost(`/api/v1/pleroma/chats/${chat.id}/messages/${chatMessage.id}/reactions`)
.reply(200, { ...chatMessage.toJS(), emoji_reactions: [{ name: '👍', count: 1, me: true }] });
});
});
it('successfully updates the Chat Message record', async () => {
const initialQueryData = {
pages: [
{ result: [chatMessage], hasMore: false, link: undefined },
],
pageParams: [undefined],
};
queryClient.setQueryData(ChatKeys.chatMessages(chat.id), initialQueryData);
const { result } = renderHook(() => {
const { createReaction } = useChatActions(chat.id);
useEffect(() => {
createReaction.mutate({
messageId: chatMessage.id,
emoji: '👍',
chatMessage,
});
}, []);
return createReaction;
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
const updatedChatMessage = (queryClient.getQueryData(ChatKeys.chatMessages(chat.id)) as any).pages[0].result[0] as ChatMessage;
expect(updatedChatMessage.emoji_reactions).toEqual(ImmutableList([normalizeEmojiReaction({
name: '👍',
count: 1,
me: true,
})]));
});
});
}); });

@ -8,7 +8,8 @@ import { useStatContext } from 'soapbox/contexts/stat-context';
import { useApi, useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; import { useApi, useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks';
import { normalizeChatMessage } from 'soapbox/normalizers'; import { normalizeChatMessage } from 'soapbox/normalizers';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
import { reOrderChatListItems } from 'soapbox/utils/chats'; import { ChatMessage } from 'soapbox/types/entities';
import { reOrderChatListItems, updateChatMessage } from 'soapbox/utils/chats';
import { flattenPages, PaginatedResult, updatePageItem } from 'soapbox/utils/queries'; import { flattenPages, PaginatedResult, updatePageItem } from 'soapbox/utils/queries';
import { queryClient } from './client'; import { queryClient } from './client';
@ -28,6 +29,7 @@ export enum MessageExpirationValues {
export interface IChat { export interface IChat {
accepted: boolean accepted: boolean
account: IAccount account: IAccount
chat_type: 'channel' | 'direct'
created_at: string created_at: string
created_by_account: string created_by_account: string
discarded_at: null | string discarded_at: null | string
@ -50,20 +52,16 @@ export interface IChat {
unread: number unread: number
} }
export interface IChatMessage {
account_id: string
chat_id: string
content: string
created_at: string
id: string
unread: boolean
pending?: boolean
}
type UpdateChatVariables = { type UpdateChatVariables = {
message_expiration: MessageExpirationValues message_expiration: MessageExpirationValues
} }
type CreateReactionVariables = {
messageId: string
emoji: string
chatMessage?: ChatMessage
}
const ChatKeys = { const ChatKeys = {
chat: (chatId?: string) => ['chats', 'chat', chatId] as const, chat: (chatId?: string) => ['chats', 'chat', chatId] as const,
chatMessages: (chatId: string) => ['chats', 'messages', chatId] as const, chatMessages: (chatId: string) => ['chats', 'messages', chatId] as const,
@ -83,7 +81,7 @@ const useChatMessages = (chat: IChat) => {
const api = useApi(); const api = useApi();
const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat.account.id, 'blocked_by'])); const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat.account.id, 'blocked_by']));
const getChatMessages = async (chatId: string, pageParam?: any): Promise<PaginatedResult<IChatMessage>> => { const getChatMessages = async (chatId: string, pageParam?: any): Promise<PaginatedResult<ChatMessage>> => {
const nextPageLink = pageParam?.link; const nextPageLink = pageParam?.link;
const uri = nextPageLink || `/api/v1/pleroma/chats/${chatId}/messages`; const uri = nextPageLink || `/api/v1/pleroma/chats/${chatId}/messages`;
const response = await api.get<any[]>(uri); const response = await api.get<any[]>(uri);
@ -235,7 +233,7 @@ const useChatActions = (chatId: string) => {
const createChatMessage = useMutation( const createChatMessage = useMutation(
( (
{ chatId, content, mediaId }: { chatId: string, content: string, mediaId?: string }, { chatId, content, mediaId }: { chatId: string, content: string, mediaId?: string },
) => api.post<IChatMessage>(`/api/v1/pleroma/chats/${chatId}/messages`, { content, media_id: mediaId }), ) => api.post<ChatMessage>(`/api/v1/pleroma/chats/${chatId}/messages`, { content, media_id: mediaId, media_ids: [mediaId] }),
{ {
retry: false, retry: false,
onMutate: async (variables) => { onMutate: async (variables) => {
@ -245,6 +243,7 @@ const useChatActions = (chatId: string) => {
// Snapshot the previous value // Snapshot the previous value
const prevContent = variables.content; const prevContent = variables.content;
const prevChatMessages = queryClient.getQueryData(['chats', 'messages', variables.chatId]); const prevChatMessages = queryClient.getQueryData(['chats', 'messages', variables.chatId]);
const pendingId = String(Number(new Date()));
// Optimistically update to the new value // Optimistically update to the new value
queryClient.setQueryData(ChatKeys.chatMessages(variables.chatId), (prevResult: any) => { queryClient.setQueryData(ChatKeys.chatMessages(variables.chatId), (prevResult: any) => {
@ -256,7 +255,7 @@ const useChatActions = (chatId: string) => {
result: [ result: [
normalizeChatMessage({ normalizeChatMessage({
content: variables.content, content: variables.content,
id: String(Number(new Date())), id: pendingId,
created_at: new Date(), created_at: new Date(),
account_id: account?.id, account_id: account?.id,
pending: true, pending: true,
@ -273,18 +272,21 @@ const useChatActions = (chatId: string) => {
return newResult; return newResult;
}); });
return { prevChatMessages, prevContent }; return { prevChatMessages, prevContent, pendingId };
}, },
// If the mutation fails, use the context returned from onMutate to roll back // If the mutation fails, use the context returned from onMutate to roll back
onError: (_error: any, variables, context: any) => { onError: (_error: any, variables, context: any) => {
queryClient.setQueryData(['chats', 'messages', variables.chatId], context.prevChatMessages); queryClient.setQueryData(['chats', 'messages', variables.chatId], context.prevChatMessages);
}, },
onSuccess: (response, variables) => { onSuccess: (response: any, variables, context) => {
const nextChat = { ...chat, last_message: response.data }; const nextChat = { ...chat, last_message: response.data };
updatePageItem(ChatKeys.chatSearch(), nextChat, (o, n) => o.id === n.id); updatePageItem(ChatKeys.chatSearch(), nextChat, (o, n) => o.id === n.id);
updatePageItem(
ChatKeys.chatMessages(variables.chatId),
normalizeChatMessage(response.data),
(o) => o.id === context.pendingId,
);
reOrderChatListItems(); reOrderChatListItems();
queryClient.invalidateQueries(ChatKeys.chatMessages(variables.chatId));
}, },
}, },
); );
@ -336,7 +338,34 @@ const useChatActions = (chatId: string) => {
}, },
}); });
return { createChatMessage, markChatAsRead, deleteChatMessage, updateChat, acceptChat, deleteChat }; const createReaction = useMutation((data: CreateReactionVariables) => api.post(`/api/v1/pleroma/chats/${chatId}/messages/${data.messageId}/reactions`, {
emoji: data.emoji,
}), {
// TODO: add optimistic updates
onSuccess(response) {
updateChatMessage(response.data);
},
});
const deleteReaction = useMutation(
(data: CreateReactionVariables) => api.delete(`/api/v1/pleroma/chats/${chatId}/messages/${data.messageId}/reactions/${data.emoji}`),
{
onSuccess() {
queryClient.invalidateQueries(ChatKeys.chatMessages(chatId));
},
},
);
return {
acceptChat,
createChatMessage,
createReaction,
deleteChat,
deleteChatMessage,
deleteReaction,
markChatAsRead,
updateChat,
};
}; };
export { ChatKeys, useChat, useChatActions, useChats, useChatMessages, isLastMessage }; export { ChatKeys, useChat, useChatActions, useChats, useChatMessages, isLastMessage };

@ -12,6 +12,7 @@ import { validId } from 'soapbox/utils/auth';
import ConfigDB from 'soapbox/utils/config-db'; import ConfigDB from 'soapbox/utils/config-db';
import { shouldFilter } from 'soapbox/utils/timelines'; import { shouldFilter } from 'soapbox/utils/timelines';
import type { ContextType } from 'soapbox/normalizers/filter';
import type { ReducerChat } from 'soapbox/reducers/chats'; import type { ReducerChat } from 'soapbox/reducers/chats';
import type { RootState } from 'soapbox/store'; import type { RootState } from 'soapbox/store';
import type { Filter as FilterEntity, Notification } from 'soapbox/types/entities'; import type { Filter as FilterEntity, Notification } from 'soapbox/types/entities';
@ -85,7 +86,7 @@ export const findAccountByUsername = (state: RootState, username: string) => {
} }
}; };
const toServerSideType = (columnType: string): string => { const toServerSideType = (columnType: string): ContextType => {
switch (columnType) { switch (columnType) {
case 'home': case 'home':
case 'notifications': case 'notifications':
@ -105,10 +106,8 @@ type FilterContext = { contextType?: string };
export const getFilters = (state: RootState, query: FilterContext) => { export const getFilters = (state: RootState, query: FilterContext) => {
return state.filters.filter((filter) => { return state.filters.filter((filter) => {
return query?.contextType return (!query?.contextType || filter.context.includes(toServerSideType(query.contextType)))
&& filter.context.includes(toServerSideType(query.contextType)) && (filter.expires_at === null || Date.parse(filter.expires_at) > new Date().getTime());
&& (filter.expires_at === null
|| Date.parse(filter.expires_at) > new Date().getTime());
}); });
}; };

@ -9,6 +9,7 @@ import {
ChatRecord, ChatRecord,
ChatMessageRecord, ChatMessageRecord,
EmojiRecord, EmojiRecord,
EmojiReactionRecord,
FieldRecord, FieldRecord,
FilterRecord, FilterRecord,
GroupRecord, GroupRecord,
@ -40,6 +41,7 @@ type Card = ReturnType<typeof CardRecord>;
type Chat = ReturnType<typeof ChatRecord>; type Chat = ReturnType<typeof ChatRecord>;
type ChatMessage = ReturnType<typeof ChatMessageRecord>; type ChatMessage = ReturnType<typeof ChatMessageRecord>;
type Emoji = ReturnType<typeof EmojiRecord>; type Emoji = ReturnType<typeof EmojiRecord>;
type EmojiReaction = ReturnType<typeof EmojiReactionRecord>;
type Field = ReturnType<typeof FieldRecord>; type Field = ReturnType<typeof FieldRecord>;
type Filter = ReturnType<typeof FilterRecord>; type Filter = ReturnType<typeof FilterRecord>;
type Group = ReturnType<typeof GroupRecord>; type Group = ReturnType<typeof GroupRecord>;
@ -84,6 +86,7 @@ export {
Chat, Chat,
ChatMessage, ChatMessage,
Emoji, Emoji,
EmojiReaction,
Field, Field,
Filter, Filter,
Group, Group,

@ -0,0 +1,73 @@
import { normalizeChatMessage } from 'soapbox/normalizers';
import { IAccount } from 'soapbox/queries/accounts';
import { ChatKeys, IChat } from 'soapbox/queries/chats';
import { queryClient } from 'soapbox/queries/client';
import { updateChatMessage } from '../chats';
const chat: IChat = {
accepted: true,
account: {
username: 'username',
verified: true,
id: '1',
acct: 'acct',
avatar: 'avatar',
avatar_static: 'avatar',
display_name: 'my name',
} as IAccount,
chat_type: 'direct',
created_at: '2020-06-10T02:05:06.000Z',
created_by_account: '1',
discarded_at: null,
id: '1',
last_message: null,
latest_read_message_by_account: [],
latest_read_message_created_at: null,
message_expiration: 1209600,
unread: 0,
};
const buildChatMessage = (id: string) => normalizeChatMessage({
id,
chat_id: '1',
account_id: '1',
content: `chat message #${id}`,
created_at: '2020-06-10T02:05:06.000Z',
emoji_reactions: null,
expiration: 1209600,
unread: true,
});
describe('chat utils', () => {
describe('updateChatMessage()', () => {
const initialChatMessage = buildChatMessage('1');
beforeEach(() => {
const initialQueryData = {
pages: [
{ result: [initialChatMessage], hasMore: false, link: undefined },
],
pageParams: [undefined],
};
queryClient.setQueryData(ChatKeys.chatMessages(chat.id), initialQueryData);
});
it('correctly updates the chat message', () => {
expect(
(queryClient.getQueryData(ChatKeys.chatMessages(chat.id)) as any).pages[0].result[0].content,
).toEqual(initialChatMessage.content);
const nextChatMessage = normalizeChatMessage({
...initialChatMessage.toJS(),
content: 'new content',
});
updateChatMessage(nextChatMessage);
expect(
(queryClient.getQueryData(ChatKeys.chatMessages(chat.id)) as any).pages[0].result[0].content,
).toEqual(nextChatMessage.content);
});
});
});

@ -5,7 +5,6 @@ import { normalizeStatus } from 'soapbox/normalizers';
import { import {
sortEmoji, sortEmoji,
mergeEmojiFavourites, mergeEmojiFavourites,
filterEmoji,
oneEmojiPerAccount, oneEmojiPerAccount,
reduceEmoji, reduceEmoji,
getReactForStatus, getReactForStatus,
@ -22,29 +21,10 @@ const ALLOWED_EMOJI = ImmutableList([
'😡', '😡',
]); ]);
describe('filterEmoji', () => {
describe('with a mix of allowed and disallowed emoji', () => {
const emojiReacts = fromJS([
{ 'count': 1, 'me': true, 'name': '🌵' },
{ 'count': 1, 'me': true, 'name': '😂' },
{ 'count': 1, 'me': true, 'name': '👀' },
{ 'count': 1, 'me': true, 'name': '🍩' },
{ 'count': 1, 'me': true, 'name': '😡' },
{ 'count': 1, 'me': true, 'name': '🔪' },
{ 'count': 1, 'me': true, 'name': '😠' },
]) as ImmutableList<ImmutableMap<string, any>>;
it('filters only allowed emoji', () => {
expect(filterEmoji(emojiReacts, ALLOWED_EMOJI)).toEqual(fromJS([
{ 'count': 1, 'me': true, 'name': '😂' },
{ 'count': 1, 'me': true, 'name': '😡' },
]));
});
});
});
describe('sortEmoji', () => { describe('sortEmoji', () => {
describe('with an unsorted list of emoji', () => { describe('with an unsorted list of emoji', () => {
const emojiReacts = fromJS([ const emojiReacts = fromJS([
{ 'count': 7, 'me': true, 'name': '😃' },
{ 'count': 7, 'me': true, 'name': '😯' }, { 'count': 7, 'me': true, 'name': '😯' },
{ 'count': 3, 'me': true, 'name': '😢' }, { 'count': 3, 'me': true, 'name': '😢' },
{ 'count': 1, 'me': true, 'name': '😡' }, { 'count': 1, 'me': true, 'name': '😡' },
@ -53,11 +33,12 @@ describe('sortEmoji', () => {
{ 'count': 15, 'me': true, 'name': '❤' }, { 'count': 15, 'me': true, 'name': '❤' },
]) as ImmutableList<ImmutableMap<string, any>>; ]) as ImmutableList<ImmutableMap<string, any>>;
it('sorts the emoji by count', () => { it('sorts the emoji by count', () => {
expect(sortEmoji(emojiReacts)).toEqual(fromJS([ expect(sortEmoji(emojiReacts, ALLOWED_EMOJI)).toEqual(fromJS([
{ 'count': 20, 'me': true, 'name': '👍' }, { 'count': 20, 'me': true, 'name': '👍' },
{ 'count': 15, 'me': true, 'name': '❤' }, { 'count': 15, 'me': true, 'name': '❤' },
{ 'count': 7, 'me': true, 'name': '😯' }, { 'count': 7, 'me': true, 'name': '😯' },
{ 'count': 7, 'me': true, 'name': '😂' }, { 'count': 7, 'me': true, 'name': '😂' },
{ 'count': 7, 'me': true, 'name': '😃' },
{ 'count': 3, 'me': true, 'name': '😢' }, { 'count': 3, 'me': true, 'name': '😢' },
{ 'count': 1, 'me': true, 'name': '😡' }, { 'count': 1, 'me': true, 'name': '😡' },
])); ]));
@ -127,6 +108,10 @@ describe('reduceEmoji', () => {
{ 'count': 7, 'me': false, 'name': '😂' }, { 'count': 7, 'me': false, 'name': '😂' },
{ 'count': 3, 'me': false, 'name': '😢' }, { 'count': 3, 'me': false, 'name': '😢' },
{ 'count': 1, 'me': false, 'name': '😡' }, { 'count': 1, 'me': false, 'name': '😡' },
{ 'count': 1, 'me': true, 'name': '🔪' },
{ 'count': 1, 'me': true, 'name': '🌵' },
{ 'count': 1, 'me': false, 'name': '👀' },
{ 'count': 1, 'me': false, 'name': '🍩' },
])); ]));
}); });
}); });

@ -84,4 +84,11 @@ const getUnreadChatsCount = (): number => {
return sumBy(chats, chat => chat.unread); return sumBy(chats, chat => chat.unread);
}; };
export { updateChatListItem, getUnreadChatsCount, reOrderChatListItems }; /** Update the query cache for an individual Chat Message */
const updateChatMessage = (chatMessage: ChatMessage) => updatePageItem(
ChatKeys.chatMessages(chatMessage.chat_id),
normalizeChatMessage(chatMessage),
(o, n) => o.id === n.id,
);
export { updateChatListItem, updateChatMessage, getUnreadChatsCount, reOrderChatListItems };

@ -19,12 +19,10 @@ export const ALLOWED_EMOJI = ImmutableList([
type Account = ImmutableMap<string, any>; type Account = ImmutableMap<string, any>;
type EmojiReact = ImmutableMap<string, any>; type EmojiReact = ImmutableMap<string, any>;
export const sortEmoji = (emojiReacts: ImmutableList<EmojiReact>): ImmutableList<EmojiReact> => ( export const sortEmoji = (emojiReacts: ImmutableList<EmojiReact>, allowedEmoji: ImmutableList<string>): ImmutableList<EmojiReact> => (
emojiReacts.sortBy(emojiReact => -emojiReact.get('count')) emojiReacts
); .sortBy(emojiReact =>
-(emojiReact.get('count') + Number(allowedEmoji.includes(emojiReact.get('name')))))
export const mergeEmoji = (emojiReacts: ImmutableList<EmojiReact>): ImmutableList<EmojiReact> => (
emojiReacts // TODO: Merge similar emoji
); );
export const mergeEmojiFavourites = (emojiReacts = ImmutableList<EmojiReact>(), favouritesCount: number, favourited: boolean) => { export const mergeEmojiFavourites = (emojiReacts = ImmutableList<EmojiReact>(), favouritesCount: number, favourited: boolean) => {
@ -70,15 +68,11 @@ export const oneEmojiPerAccount = (emojiReacts: ImmutableList<EmojiReact>, me: M
.reverse(); .reverse();
}; };
export const filterEmoji = (emojiReacts: ImmutableList<EmojiReact>, allowedEmoji = ALLOWED_EMOJI): ImmutableList<EmojiReact> => (
emojiReacts.filter(emojiReact => (
allowedEmoji.includes(emojiReact.get('name'))
)));
export const reduceEmoji = (emojiReacts: ImmutableList<EmojiReact>, favouritesCount: number, favourited: boolean, allowedEmoji = ALLOWED_EMOJI): ImmutableList<EmojiReact> => ( export const reduceEmoji = (emojiReacts: ImmutableList<EmojiReact>, favouritesCount: number, favourited: boolean, allowedEmoji = ALLOWED_EMOJI): ImmutableList<EmojiReact> => (
filterEmoji(sortEmoji(mergeEmoji(mergeEmojiFavourites( sortEmoji(
emojiReacts, favouritesCount, favourited, mergeEmojiFavourites(emojiReacts, favouritesCount, favourited),
))), allowedEmoji)); allowedEmoji,
));
export const getReactForStatus = (status: any, allowedEmoji = ALLOWED_EMOJI): string | undefined => { export const getReactForStatus = (status: any, allowedEmoji = ALLOWED_EMOJI): string | undefined => {
const result = reduceEmoji( const result = reduceEmoji(

@ -248,6 +248,11 @@ const getInstanceFeatures = (instance: Instance) => {
*/ */
chatAcceptance: v.software === TRUTHSOCIAL, chatAcceptance: v.software === TRUTHSOCIAL,
/**
* Ability to add reactions to chat messages.
*/
chatEmojiReactions: false, // v.software === TRUTHSOCIAL,
/** /**
* Pleroma chats API. * Pleroma chats API.
* @see {@link https://docs.pleroma.social/backend/development/API/chats/} * @see {@link https://docs.pleroma.social/backend/development/API/chats/}
@ -374,10 +379,10 @@ const getInstanceFeatures = (instance: Instance) => {
emojiReacts: v.software === PLEROMA && gte(v.version, '2.0.0'), emojiReacts: v.software === PLEROMA && gte(v.version, '2.0.0'),
/** /**
* The backend allows only RGI ("Recommended for General Interchange") emoji reactions. * The backend allows only non-RGI ("Recommended for General Interchange") emoji reactions.
* @see PUT /api/v1/pleroma/statuses/:id/reactions/:emoji * @see PUT /api/v1/pleroma/statuses/:id/reactions/:emoji
*/ */
emojiReactsRGI: v.software === PLEROMA && gte(v.version, '2.2.49'), emojiReactsNonRGI: v.software === PLEROMA && lt(v.version, '2.2.49'),
/** /**
* Sign in with an Ethereum wallet. * Sign in with an Ethereum wallet.
@ -711,6 +716,14 @@ const getInstanceFeatures = (instance: Instance) => {
v.software === PLEROMA, v.software === PLEROMA,
]), ]),
/**
* Ability to follow account feeds using RSS.
*/
rssFeeds: any([
v.software === MASTODON,
v.software === PLEROMA,
]),
/** /**
* Can schedule statuses to be posted at a later time. * Can schedule statuses to be posted at a later time.
* @see POST /api/v1/statuses * @see POST /api/v1/statuses

@ -25,7 +25,6 @@
@import 'components/react-toggle'; @import 'components/react-toggle';
@import 'components/video-player'; @import 'components/video-player';
@import 'components/audio-player'; @import 'components/audio-player';
@import 'components/filters';
@import 'components/crypto-donate'; @import 'components/crypto-donate';
@import 'components/aliases'; @import 'components/aliases';
@import 'components/icon'; @import 'components/icon';

@ -1,93 +0,0 @@
.filter-settings-panel {
.fields-group .two-col {
display: flex;
align-items: flex-start;
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
div.input {
width: 45%;
margin-right: 20px;
.label_input {
width: 100%;
}
}
@media (max-width: 485px) {
div.input {
width: 100%;
margin-right: 5px;
.label_input {
width: auto;
}
}
}
}
.input.boolean {
.label_input {
@apply relative pl-7 text-black dark:text-white;
label {
@apply text-sm;
}
&__wrapper {
@apply static;
}
input[type='checkbox'] {
position: absolute;
top: 3px;
left: 0;
}
}
.hint {
@apply block pl-7 text-xs text-gray-500 dark:text-gray-400;
}
}
.filter__container {
@apply flex justify-between py-5 px-2 text-sm text-black dark:text-white;
.filter__phrase,
.filter__contexts,
.filter__details {
@apply py-1;
}
span.filter__list-label {
@apply pr-1 text-gray-500 dark:text-gray-400;
}
span.filter__list-value span {
@apply pr-1 capitalize;
&::after {
content: ',';
}
&:last-of-type {
&::after {
content: '';
}
}
}
.filter__delete {
@apply flex items-center h-5 m-2.5 cursor-pointer;
span.filter__delete-label {
@apply text-gray-500 dark:text-gray-400 font-semibold;
}
.filter__delete-icon {
@apply mx-1 text-gray-500 dark:text-gray-400;
}
}
}
}

@ -17,7 +17,7 @@
[column-type='filled'] .status__wrapper, [column-type='filled'] .status__wrapper,
[column-type='filled'] .status-placeholder { [column-type='filled'] .status-placeholder {
@apply rounded-none shadow-none p-4; @apply bg-transparent dark:bg-transparent rounded-none shadow-none p-4;
} }
.status-check-box { .status-check-box {

@ -158,7 +158,7 @@
"react-router-scroll-4": "^1.0.0-beta.2", "react-router-scroll-4": "^1.0.0-beta.2",
"react-simple-pull-to-refresh": "^1.3.3", "react-simple-pull-to-refresh": "^1.3.3",
"react-sparklines": "^1.7.0", "react-sparklines": "^1.7.0",
"react-sticky-box": "^1.0.2", "react-sticky-box": "^2.0.0",
"react-swipeable-views": "^0.14.0", "react-swipeable-views": "^0.14.0",
"react-textarea-autosize": "^8.3.4", "react-textarea-autosize": "^8.3.4",
"react-toggle": "^4.1.2", "react-toggle": "^4.1.2",
@ -232,6 +232,7 @@
"raf": "^3.4.1", "raf": "^3.4.1",
"react-intl-translations-manager": "^5.0.3", "react-intl-translations-manager": "^5.0.3",
"react-refresh": "^0.14.0", "react-refresh": "^0.14.0",
"storybook-react-intl": "^1.1.1",
"stylelint": "^14.0.0", "stylelint": "^14.0.0",
"stylelint-config-standard-scss": "^6.1.0", "stylelint-config-standard-scss": "^6.1.0",
"tailwindcss": "^3.2.1", "tailwindcss": "^3.2.1",

@ -140,7 +140,7 @@ const configuration: Configuration = {
'/report.html', '/report.html',
]; ];
if (backendRoutes.some(path => pathname.startsWith(path)) || pathname.endsWith('/embed')) { if (backendRoutes.some(path => pathname.startsWith(path)) || pathname.endsWith('/embed') || pathname.endsWith('.rss')) {
return url; return url;
} }
}, },

@ -14029,7 +14029,7 @@ postcss@^7.0.14, postcss@^7.0.26, postcss@^7.0.32, postcss@^7.0.35, postcss@^7.0
picocolors "^0.2.1" picocolors "^0.2.1"
source-map "^0.6.1" source-map "^0.6.1"
postcss@^8.2.15: postcss@^8.2.15, postcss@^8.4.4:
version "8.4.21" version "8.4.21"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.21.tgz#c639b719a57efc3187b13a1d765675485f4134f4" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.21.tgz#c639b719a57efc3187b13a1d765675485f4134f4"
integrity sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg== integrity sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==
@ -14065,15 +14065,6 @@ postcss@^8.4.19:
picocolors "^1.0.0" picocolors "^1.0.0"
source-map-js "^1.0.2" source-map-js "^1.0.2"
postcss@^8.4.4:
version "8.4.21"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.21.tgz#c639b719a57efc3187b13a1d765675485f4134f4"
integrity sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==
dependencies:
nanoid "^3.3.4"
picocolors "^1.0.0"
source-map-js "^1.0.2"
prelude-ls@^1.2.1: prelude-ls@^1.2.1:
version "1.2.1" version "1.2.1"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
@ -14743,12 +14734,10 @@ react-sparklines@^1.7.0:
dependencies: dependencies:
prop-types "^15.5.10" prop-types "^15.5.10"
react-sticky-box@^1.0.2: react-sticky-box@^2.0.0:
version "1.0.2" version "2.0.0"
resolved "https://registry.yarnpkg.com/react-sticky-box/-/react-sticky-box-1.0.2.tgz#7e72a0f237bdf8270cec9254337f49519a411174" resolved "https://registry.yarnpkg.com/react-sticky-box/-/react-sticky-box-2.0.0.tgz#db3d19f700f2c2fb8405da6973cadd9b74ceea91"
integrity sha512-Kyvtppdtv1KqJyNU4DtrSMI0unyQRgtraZvVQ0GAazVbYiTsIVpyhpr+5R0Aavzu4uJNSe1awj2rk/qI7i6Zfw== integrity sha512-xTy/46lG6GlfdkGL7DoevLj+p3lqjvULZvGkN36/2oM8FrijvFq/zzfAtK0ZSnDxM9Ct6KOr5ZVuejVdDFqEcw==
dependencies:
resize-observer-polyfill "^1.5.1"
react-swipeable-views-core@^0.14.0: react-swipeable-views-core@^0.14.0:
version "0.14.0" version "0.14.0"
@ -16056,6 +16045,18 @@ store2@^2.12.0:
resolved "https://registry.yarnpkg.com/store2/-/store2-2.14.2.tgz#56138d200f9fe5f582ad63bc2704dbc0e4a45068" resolved "https://registry.yarnpkg.com/store2/-/store2-2.14.2.tgz#56138d200f9fe5f582ad63bc2704dbc0e4a45068"
integrity sha512-siT1RiqlfQnGqgT/YzXVUNsom9S0H1OX+dpdGN1xkyYATo4I6sep5NmsRD/40s3IIOvlCq6akxkqG82urIZW1w== integrity sha512-siT1RiqlfQnGqgT/YzXVUNsom9S0H1OX+dpdGN1xkyYATo4I6sep5NmsRD/40s3IIOvlCq6akxkqG82urIZW1w==
storybook-i18n@^1.1.2:
version "1.1.4"
resolved "https://registry.yarnpkg.com/storybook-i18n/-/storybook-i18n-1.1.4.tgz#f463287fa7d8c79c8283c4e7157c8557ebaf408f"
integrity sha512-0xD005aEBWhuDFU9oO5Yf+33MZQa/NWa2CWjkBODhcqC+N5WJhEPGUSQs8o9AqzQoRpIBOMQ3CS0cbRGbBuTxQ==
storybook-react-intl@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/storybook-react-intl/-/storybook-react-intl-1.1.1.tgz#e0f3dd36fd6ec2ef3ac077c9488bfa2299ae6705"
integrity sha512-KD1G7TPo3+9nBXMkg6lk9MpS3RYMjUxam8d5S/NhHwzom0cnTk2AW4bd0zRmJkdjds1xuIhwZxet7Ebp9dggzw==
dependencies:
storybook-i18n "^1.1.2"
stream-browserify@^2.0.1: stream-browserify@^2.0.1:
version "2.0.2" version "2.0.2"
resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b"

Loading…
Cancel
Save