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-essentials',
'@storybook/addon-interactions',
'storybook-react-intl',
{
name: '@storybook/addon-postcss',
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.
- Events: allow to repost events in event menu.
- 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
- Chats: improved display of media attachments.
- 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: let "mute conversation" be clicked from any feed, not just noficiations.
- Posts: display all emoji reactions.
- Reactions: improved UI of reactions on statuses.
### Fixed
- 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
// // https://git.pleroma.social/pleroma/pleroma/-/issues/2355
if (!features.emojiReactsRGI) {
// https://git.pleroma.social/pleroma/pleroma/-/issues/2355
if (features.emojiReactsNonRGI) {
soapboxConfig.set('allowedEmoji', soapboxConfig.allowedEmoji.map(removeVS16s));
}
});

@ -2,7 +2,7 @@ import { getSettings } from 'soapbox/actions/settings';
import messages from 'soapbox/locales/messages';
import { ChatKeys, IChat, isLastMessage } from 'soapbox/queries/chats';
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 { play, soundCache } from 'soapbox/utils/sounds';
@ -170,6 +170,9 @@ const connectTimelineStream = (
}
});
break;
case 'chat_message.reaction': // TruthSocial
updateChatMessage(JSON.parse(data.payload));
break;
case 'pleroma:follow_relationships_update':
dispatch(updateFollowRelationships(JSON.parse(data.payload)));
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 { initReport } from 'soapbox/actions/reports';
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 StatusReactionWrapper from 'soapbox/components/status-reaction-wrapper';
import { HStack } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
@ -629,7 +629,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
)}
{features.emojiReacts ? (
<EmojiButtonWrapper statusId={status.id}>
<StatusReactionWrapper statusId={status.id}>
<StatusActionButton
title={meEmojiTitle}
icon={require('@tabler/icons/heart.svg')}
@ -640,7 +640,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
emoji={meEmojiReact}
text={withLabels ? meEmojiTitle : undefined}
/>
</EmojiButtonWrapper>
</StatusReactionWrapper>
) : (
<StatusActionButton
title={intl.formatMessage(messages.favourite)}

@ -1,6 +1,4 @@
import clsx from 'clsx';
import React, { useState, useEffect, useRef } from 'react';
import { usePopper } from 'react-popper';
import { simpleEmojiReact } from 'soapbox/actions/emoji-reacts';
import { openModal } from 'soapbox/actions/modals';
@ -9,13 +7,13 @@ import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig } from
import { isUserTouching } from 'soapbox/is-mobile';
import { getReactForStatus } from 'soapbox/utils/emoji-reacts';
interface IEmojiButtonWrapper {
interface IStatusReactionWrapper {
statusId: string,
children: JSX.Element,
}
/** 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 ownAccount = useOwnAccount();
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 [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 [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: 'top-start',
modifiers: [
{
name: 'offset',
options: {
offset: [-10, 0],
},
},
],
});
useEffect(() => {
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 (
<div className='relative' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
{React.cloneElement(children, {
@ -146,9 +105,14 @@ const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children
ref: setReferenceElement,
})}
{selector}
<EmojiSelector
placement='top-start'
referenceElement={referenceElement}
onReact={handleReact}
visible={visible}
/>
</div>
);
};
export default EmojiButtonWrapper;
export default StatusReactionWrapper;

@ -289,8 +289,10 @@ const Status: React.FC<IStatus> = (props) => {
return (
<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}>
<Text theme='muted'>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
</Text>
</div>
</HotKeys>
);

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

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

@ -1,13 +1,32 @@
import clsx from 'clsx';
import React from 'react';
import { spring } from 'react-motion';
import Motion from 'soapbox/features/ui/util/optional-motion';
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. */
const ProgressBar: React.FC<IProgressBar> = ({ progress }) => (
<div className='h-2.5 w-full overflow-hidden rounded-full bg-gray-300 dark:bg-primary-800'>
<div className='h-full bg-secondary-500' style={{ width: `${Math.floor(progress * 100)}%` }} />
const ProgressBar: React.FC<IProgressBar> = ({ progress, size = 'md' }) => (
<div
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>
);

@ -46,7 +46,7 @@ const AnimatedTabs: React.FC<IAnimatedInterface> = ({ children, ...rest }) => {
ref={ref}
>
<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 }}
/>
<div

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

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

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

@ -6,8 +6,7 @@ import { makeGetStatus } from 'soapbox/selectors';
interface IStatusContainer extends Omit<IStatus, 'status'> {
id: string,
/** @deprecated Unused. */
contextType?: any,
contextType?: string,
/** @deprecated Unused. */
otherAccounts?: any,
/** @deprecated Unused. */
@ -21,10 +20,10 @@ interface IStatusContainer extends Omit<IStatus, 'status'> {
* @deprecated Use the Status component directly.
*/
const StatusContainer: React.FC<IStatusContainer> = (props) => {
const { id, ...rest } = props;
const { id, contextType, ...rest } = props;
const getStatus = useCallback(makeGetStatus(), []);
const status = useAppSelector(state => getStatus(state, { id }));
const status = useAppSelector(state => getStatus(state, { id, contextType }));
if (status) {
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 ActionButton from 'soapbox/features/ui/components/action-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 { ChatKeys, useChats } from 'soapbox/queries/chats';
import { queryClient } from 'soapbox/queries/client';
import toast from 'soapbox/toast';
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';
@ -71,6 +72,7 @@ const messages = defineMessages({
userUnendorsed: { id: 'account.unendorse.success', defaultMessage: 'You are no longer featuring @{acct}' },
profileExternal: { id: 'account.profile_external', defaultMessage: 'View profile on {domain}' },
header: { id: 'account.header.alt', defaultMessage: 'Profile header' },
subscribeFeed: { id: 'account.rss_feed', defaultMessage: 'Subscribe to RSS feed' },
});
interface IHeader {
@ -85,6 +87,8 @@ const Header: React.FC<IHeader> = ({ account }) => {
const features = useFeatures();
const ownAccount = useOwnAccount();
const { software } = useAppSelector((state) => parseVersion(state.instance.version));
const { getOrCreateChatByAccountId } = useChats();
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 = () => {
navigator.share({
text: `@${account.acct}`,
@ -269,20 +277,43 @@ const Header: React.FC<IHeader> = ({ account }) => {
const makeMenu = () => {
const menu: MenuType = [];
if (!account || !ownAccount) {
if (!account) {
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) {
menu.push({
text: intl.formatMessage(messages.share, { name: account.username }),
action: handleShare,
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);
}
if (account.id === ownAccount?.id) {
if (account.id === ownAccount.id) {
menu.push({
text: intl.formatMessage(messages.edit_profile),
to: '/settings/profile',
@ -435,17 +466,9 @@ const Header: React.FC<IHeader> = ({ account }) => {
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({
@ -463,7 +486,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
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(
<Badge
key='followed_by'
@ -471,7 +494,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
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(
<Badge
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(
<Badge
key='muted'
@ -489,7 +512,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
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(
<Badge
key='domain_blocked'
@ -621,7 +644,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
{renderMessageButton()}
{renderShareButton()}
{ownAccount && (
{menu.length > 0 && (
<Menu>
<MenuButton
as={IconButton}

@ -2,13 +2,15 @@ import userEvent from '@testing-library/user-event';
import React from 'react';
import { VirtuosoMockContext } from 'react-virtuoso';
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 { ChatMessage } from 'soapbox/types/entities';
import { __stub } from '../../../../api';
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';
const chat: IChat = {
@ -22,6 +24,7 @@ const chat: IChat = {
avatar_static: 'avatar',
display_name: 'my name',
} as IAccount,
chat_type: 'direct',
created_at: '2020-06-10T02:05:06.000Z',
created_by_account: '2',
discarded_at: null,
@ -33,25 +36,29 @@ const chat: IChat = {
unread: 5,
};
const chatMessages: IChatMessage[] = [
{
const chatMessages: ChatMessage[] = [
normalizeChatMessage({
account_id: '1',
chat_id: '14',
content: 'this is the first chat',
created_at: '2022-09-09T16:02:26.186Z',
emoji_reactions: null,
expiration: 1209600,
id: '1',
unread: false,
pending: false,
},
{
}),
normalizeChatMessage({
account_id: '2',
chat_id: '14',
content: 'this is the second chat',
created_at: '2022-09-09T16:04:26.186Z',
emoji_reactions: null,
expiration: 1209600,
id: '2',
unread: true,
pending: false,
},
}),
];
// 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 { 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 UploadButton from 'soapbox/features/compose/components/upload-button';
import { search as emojiSearch } from 'soapbox/features/emoji/emoji-mart-search-light';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { Attachment } from 'soapbox/types/entities';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import ChatTextarea from './chat-textarea';
const messages = defineMessages({
placeholder: { id: 'chat.input.placeholder', defaultMessage: 'Type a message' },
send: { id: 'chat.actions.send', defaultMessage: 'Send' },
@ -39,7 +42,10 @@ interface IChatComposer extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaEl
errorMessage: string | undefined
onSelectFile: (files: FileList, intl: IntlShape) => void
resetFileKey: number | null
hasAttachment?: boolean
attachments?: Attachment[]
onDeleteAttachment?: () => void
isUploading?: boolean
uploadProgress?: number
}
/** Textarea input for chats. */
@ -53,7 +59,10 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
onSelectFile,
resetFileKey,
onPaste,
hasAttachment,
attachments = [],
onDeleteAttachment,
isUploading,
uploadProgress,
}, ref) => {
const intl = useIntl();
const dispatch = useAppDispatch();
@ -68,6 +77,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
const [suggestions, setSuggestions] = useState<Suggestion>(initialSuggestionState);
const isSuggestionsAvailable = suggestions.list.length > 0;
const hasAttachment = attachments.length > 0;
const isOverCharacterLimit = maxCharacterCount && value?.length > maxCharacterCount;
const isSubmitDisabled = disabled || isOverCharacterLimit || (value.length === 0 && !hasAttachment);
@ -167,12 +177,9 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
)}
<Stack grow>
<Combobox
aria-labelledby='demo'
onSelect={onSelectComboboxOption}
>
<Combobox onSelect={onSelectComboboxOption}>
<ComboboxInput
as={Textarea}
as={ChatTextarea}
autoFocus
ref={ref}
placeholder={intl.formatMessage(messages.placeholder)}
@ -184,6 +191,10 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
autoGrow
maxRows={5}
disabled={disabled}
attachments={attachments}
onDeleteAttachment={onDeleteAttachment}
isUploading={isUploading}
uploadProgress={uploadProgress}
/>
{isSuggestionsAvailable ? (
<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 { useIntl, defineMessages } from 'react-intl';
import { Components, Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { openModal } from 'soapbox/actions/modals';
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 { Avatar, Button, Divider, Spinner, Stack, Text } from 'soapbox/components/ui';
import PlaceholderChatMessage from 'soapbox/features/placeholder/components/placeholder-chat-message';
import Bundle from 'soapbox/features/ui/components/bundle';
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
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 { useAppSelector, useOwnAccount } from 'soapbox/hooks';
import { IChat, useChatActions, useChatMessages } from 'soapbox/queries/chats';
import ChatMessage from './chat-message';
import ChatMessageListIntro from './chat-message-list-intro';
import type { Menu } from 'soapbox/components/dropdown-menu';
import type { ChatMessage as ChatMessageEntity } from 'soapbox/types/entities';
const BIG_EMOJI_LIMIT = 3;
const messages = defineMessages({
today: { id: 'chats.dividers.today', defaultMessage: 'Today' },
more: { id: 'chats.actions.more', defaultMessage: 'More' },
@ -43,7 +27,7 @@ const messages = defineMessages({
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 currDate = new Date(curr.created_at).getDate();
const nowDate = new Date().getDate();
@ -55,10 +39,6 @@ const timeChange = (prev: IChatMessage, curr: IChatMessage): TimeFormat | 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 List: Components['List'] = React.forwardRef((props, ref) => {
@ -89,19 +69,15 @@ interface IChatMessageList {
/** Scrollable list of chat messages. */
const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
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 lastReadMessageTimestamp = lastReadMessageDateString ? new Date(lastReadMessageDateString) : null;
const myLastReadMessageTimestamp = myLastReadMessageDateString ? new Date(myLastReadMessageDateString) : null;
const node = useRef<VirtuosoHandle>(null);
const [firstItemIndex, setFirstItemIndex] = useState(START_INDEX - 20);
const { deleteChatMessage, markChatAsRead } = useChatActions(chat.id);
const { markChatAsRead } = useChatActions(chat.id);
const {
data: chatMessages,
fetchNextPage,
@ -115,24 +91,24 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
const formattedChatMessages = chatMessages || [];
const me = useAppSelector((state) => state.me);
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 cachedChatMessages = useMemo(() => {
useEffect(() => {
if (!chatMessages) {
return [];
return;
}
const nextFirstItemIndex = START_INDEX - chatMessages.length;
setFirstItemIndex(nextFirstItemIndex);
}, [lastChatMessage]);
const buildCachedMessages = () => {
if (!chatMessages) {
return [];
}
return chatMessages.reduce((acc: any, curr: any, idx: number) => {
const lastMessage = formattedChatMessages[idx - 1];
@ -156,32 +132,19 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
acc.push(curr);
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) => {
if (!c) return;
const links = c.querySelectorAll('a[rel="ugc"]');
const initialScrollPositionProps = useMemo(() => {
if (process.env.NODE_ENV === 'test') {
return {};
}
links.forEach(link => {
link.classList.add('chat-link');
link.setAttribute('rel', 'ugc nofollow noopener');
link.setAttribute('target', '_blank');
});
return {
initialTopMostItemIndex: cachedChatMessages.length - 1,
firstItemIndex: Math.max(0, firstItemIndex),
};
}, [cachedChatMessages.length, firstItemIndex]);
const handleStartReached = useCallback(() => {
if (hasNextPage && !isFetching) {
@ -190,213 +153,8 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
return false;
}, [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 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(() => {
const lastMessage = formattedChatMessages[formattedChatMessages.length - 1];
if (!lastMessage) {
@ -477,8 +235,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
<Virtuoso
ref={node}
alignToBottom
firstItemIndex={Math.max(0, firstItemIndex)}
initialTopMostItemIndex={initialTopMostItemIndex}
{...initialScrollPositionProps}
data={cachedChatMessages}
startReached={handleStartReached}
followOutput='auto'
@ -486,11 +243,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
if (chatMessage.type === 'divider') {
return renderDivider(index, chatMessage.text);
} else {
return (
<div className='px-4 py-2'>
{renderMessage(chatMessage)}
</div>
);
return <ChatMessage chat={chat} chatMessage={chatMessage} />;
}
}}
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 { Stack } from 'soapbox/components/ui';
import Upload from 'soapbox/components/upload';
import UploadProgress from 'soapbox/components/upload-progress';
import { useAppDispatch } from 'soapbox/hooks';
import { normalizeAttachment } from 'soapbox/normalizers';
import { IChat, useChatActions } from 'soapbox/queries/chats';
@ -164,22 +162,6 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
<ChatMessageList chat={chat} />
</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
ref={inputRef}
onKeyDown={handleKeyDown}
@ -190,7 +172,10 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
onSelectFile={handleFiles}
resetFileKey={resetFileKey}
onPaste={handlePaste}
hasAttachment={!!attachment}
attachments={attachment ? [attachment] : []}
onDeleteAttachment={handleRemoveFile}
isUploading={isUploading}
uploadProgress={uploadProgress}
/>
</Stack>
);

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

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

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

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

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

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

@ -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 isRequested = account.relationship?.requested;
const isSubscribed = features.accountNotifies ?
account.relationship?.notifying :
account.relationship?.subscribing;
const title = isSubscribed ?
intl.formatMessage(messages.unsubscribe, { name: account.get('username') }) :
intl.formatMessage(messages.subscribe, { name: account.get('username') });
const isSubscribed = features.accountNotifies
? account.relationship?.notifying
: account.relationship?.subscribing;
const title = isSubscribed
? intl.formatMessage(messages.unsubscribe, { name: account.get('username') })
: intl.formatMessage(messages.subscribe, { name: account.get('username') });
const onSubscribeSuccess = () =>
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 DefaultPage from 'soapbox/pages/default-page';
import EventPage from 'soapbox/pages/event-page';
import EventsPage from 'soapbox/pages/events-page';
import GroupPage from 'soapbox/pages/group-page';
import GroupsPage from 'soapbox/pages/groups-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} />
{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.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/new' page={ChatsPage} component={ChatIndex} content={children} />}

@ -573,3 +573,7 @@ export function NewGroupPanel() {
export function GroupMediaPanel() {
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_domain": "{domain} wieder anzeigen",
"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.unmute": "Stummsch. aufheben",
"account.unsubscribe": "Benachrichtigungen von @{name} entabonnieren",
@ -139,7 +139,7 @@
"admin_nav.awaiting_approval": "Wartet auf Bestätigung",
"admin_nav.dashboard": "Steuerung",
"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.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.",
@ -169,7 +169,7 @@
"app_create.scopes_placeholder": "z.B. 'lesen schreiben folgen'",
"app_create.submit": "App erstellen",
"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.logged_out": "Abgemeldet.",
"auth_layout.register": "Ein Konto erstellen",
@ -199,30 +199,30 @@
"chat.page_settings.privacy": "Privatsphäre",
"chat.page_settings.submit": "Speichern",
"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.notice": "Du kannst diese Einstellungen später ändern.",
"chat.welcome.submit": "Speichern & Fortfahren",
"chat.welcome.subtitle": "Direkte Nachrichten mit anderen Nutzern austauschen.",
"chat.welcome.submit": "Speichern & fortfahren",
"chat.welcome.subtitle": "Direktnachrichten mit anderen Nutzern austauschen.",
"chat.welcome.title": "Willkommen zu {br} Chats!",
"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_message_list.blocked": "Du blockiertest diesen Benutzer",
"chat_message_list.blockedBy": "Du bist blockiert von",
"chat_message_list.blocked": "Du hast diesen Nutzer blockiert",
"chat_message_list.blockedBy": "Du wurdest blockiert von",
"chat_message_list.network_failure.action": "Erneut versuchen",
"chat_message_list.network_failure.subtitle": "Wir haben einen Netzwerkfehler festgestellt.",
"chat_message_list.network_failure.title": "Huch!",
"chat_message_list_intro.actions.accept": "Akzeptieren",
"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.report": "Meldung",
"chat_message_list_intro.intro": "möchte einen Chat mit Dir beginnen",
"chat_message_list_intro.actions.report": "Melden",
"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.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_search.blankslate.body": "Suche nach jemandem zum Chatten.",
"chat_search.blankslate.title": "Einen Chat beginnen",
"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.title": "Neuen Chat eröffnen",
"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.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.label": "Automatisches Löschen von Nachrichten",
"chat_settings.block.confirm": "Blockieren",
"chat_settings.block.heading": "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.heading": "@{acct} blockieren",
"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.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.options.block_user": "Blockiere @{acct}",
"chat_settings.options.block_user": "@{acct} blockieren",
"chat_settings.options.leave_chat": "Chat verlassen",
"chat_settings.options.report_user": "Melden",
"chat_settings.options.unblock_user": "Entblocke @{acct}",
"chat_settings.title": "Chateinzelheiten",
"chat_settings.options.report_user": "@{acct} melden",
"chat_settings.options.unblock_user": "@{acct} nicht mehr blockieren",
"chat_settings.title": "Chat-Details",
"chat_settings.unblock.confirm": "Entblocken",
"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.heading": "Entblocke @{acct}",
"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_tooltip": "Chatnachrichten werden nach {day, plural, one {# Tag} other {# Tagen}} nach dem Senden automatisch gelöscht.",
"chats.actions.copy": "Kopieren",
@ -258,10 +258,10 @@
"chats.actions.more": "Mehr",
"chats.actions.report": "Nutzer melden",
"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.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.search_placeholder": "Chatten mit…",
"column.admin.awaiting_approval": "Wartet auf Bestätigung",
@ -352,37 +352,37 @@
"common.cancel": "Abbrechen",
"common.error": "Ein Fehler ist aufgetreten. Versuche die Seite neu zu laden.",
"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.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_event.create": "Erstellen",
"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.description_hint": "Markdownsyntax wird unterstützt",
"compose_event.fields.description_label": "Veranstaltungsbeschreibung",
"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.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.name_label": "Veranstaltungsname",
"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.participation_requests.authorize": "Autorisieren",
"compose_event.participation_requests.authorize_success": "Benutzer akzeptiert",
"compose_event.participation_requests.authorize": "Zulassen",
"compose_event.participation_requests.authorize_success": "Nutzer akzeptiert",
"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.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.update": "Updaten",
"compose_event.update": "Aktualisieren",
"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.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.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",
@ -437,39 +437,40 @@
"confirmations.block.message": "Bist du dir sicher, dass du {name} blockieren möchtest?",
"confirmations.block_from_group.confirm": "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.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.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_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.heading": "Beitrag löschen",
"confirmations.delete.message": "Bist du dir sicher, dass du diesen Beitrag löschen möchtest?",
"confirmations.delete_event.confirm": "Löschen",
"confirmations.delete_event.heading": "Lösche Veranstaltung",
"confirmations.delete_event.message": "Bist du sicher, dass du dieses Ereignis löschen willst?",
"confirmations.delete_event.heading": "Veranstaltung löschen",
"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.message": "Soll der Beitrag von @{name} wirklich gelöscht werden?",
"confirmations.delete_group.confirm": "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.heading": "Liste löschen",
"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.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.kick_from_group.confirm": "Entfernen",
"confirmations.kick_from_group.heading": "Gruppenmitglied entfernen",
"confirmations.kick_from_group.confirm": "Rauswerfen",
"confirmations.kick_from_group.heading": "Gruppenmitglied rauswerfen",
"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_group.confirm": "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.heading": "Stummschalten",
"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.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.event_participant_requests": "Es sind keine Anträge auf Teilnahme an Veranstaltungen anhängig.",
"empty_column.event_participants": "Bisher ist noch niemand diesem Ereignis beigetreten. Wenn es jemand tut, wird er hier auftauchen.",
"empty_column.event_participant_requests": "Es stehen keine Personen zu dieser Veranstaltungen aus.",
"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.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.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.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.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}",
@ -640,6 +644,7 @@
"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.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.statuses": "Es wurden keine Beiträge unter \"{term}\" gefunden",
"empty_column.test": "Die Testzeitleiste ist leer.",
@ -647,26 +652,26 @@
"event.copy": "Link zur Veranstaltung kopieren",
"event.date": "Datum",
"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.external": "Veranstaltung auf {domain} anzeigen",
"event.join_state.accept": "Gehen hin",
"event.join_state.empty": "Nehmen teil",
"event.join_state.accept": "Teilnehmen",
"event.join_state.empty": "Teilnehmen",
"event.join_state.pending": "Ausstehend",
"event.join_state.rejected": "Gehen hin",
"event.join_state.rejected": "Nehme teil",
"event.location": "Ort",
"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.quote": "Veranstaltung zitieren",
"event.reblog": "Veranstaltung teilen",
"event.show_on_map": "Auf Karte anzeigen",
"event.unreblog": "Veranstaltung unteilen",
"event.website": "Externe links",
"event.unreblog": "Veranstaltung nicht mehr teilen",
"event.website": "Externe Links",
"event_map.navigate": "Navigieren",
"events.create_event": "Veranstaltung erstellen",
"events.joined_events": "Beigetretene Veranstaltungen",
"events.joined_events.empty": "Du bist noch keiner Veranstaltung beigetreten.",
"events.joined_events": "Veranstaltungen, an denen ich teilnehme",
"events.joined_events.empty": "Du hast bisher noch an keiner Veranstaltung teilgenommen.",
"events.recent_events": "Kürzliche Veranstaltungen",
"events.recent_events.empty": "Es gibt noch keine öffentlichen Veranstaltungen.",
"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.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.",
"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.any": "oder {additional}",
"hashtag.column_header.tag_mode.none": "ohne {additional}",
@ -818,6 +859,27 @@
"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_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.title": "Media",
"mfa.confirm.success_message": "MFA bestätigt",
@ -886,6 +948,7 @@
"navigation_bar.create_event": "Neue Veranstaltung erstellen",
"navigation_bar.create_group": "Gruppe erstellen",
"navigation_bar.domain_blocks": "Versteckte Domains",
"navigation_bar.edit_group": "Gruppe bearbeiten",
"navigation_bar.favourites": "Favoriten",
"navigation_bar.filters": "Stummgeschaltete Wörter",
"navigation_bar.follow_requests": "Folgeanfragen",
@ -897,6 +960,9 @@
"navigation_bar.preferences": "Einstellungen",
"navigation_bar.profile_directory": "Profilverzeichnis",
"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.follow": "{name} folgt dir",
"notification.follow_request": "{name} möchte dir folgen",
@ -1127,6 +1193,7 @@
"search.placeholder": "Suche",
"search_results.accounts": "Personen",
"search_results.filter_message": "Du suchst nach Beiträgen von @{acct}.",
"search_results.groups": "Gruppen",
"search_results.hashtags": "Hashtags",
"search_results.statuses": "Beiträge",
"security.codes.fail": "Abrufen von Sicherheitskopiecodes fehlgeschlagen",
@ -1237,6 +1304,8 @@
"sponsored.subtitle": "Werbebeitrag",
"status.admin_account": "Öffne Moderationsoberfläche für @{name}",
"status.admin_status": "Öffne Beitrag in der Moderationsoberfläche",
"status.approval.pending": "Ausstehende Anfrage",
"status.approval.rejected": "Abgelehnt",
"status.bookmark": "Lesezeichen",
"status.bookmarked": "Lesezeichen angelegt.",
"status.cancel_reblog_private": "Teilen zurücknehmen",
@ -1246,11 +1315,16 @@
"status.delete": "Löschen",
"status.detailed_status": "Detaillierte Ansicht der Unterhaltung",
"status.direct": "Direktnachricht",
"status.disabled_replies.group_membership": "Nur Gruppenmitglieder können antworten",
"status.edit": "Bearbeiten",
"status.embed": "Einbetten",
"status.external": "Öffne auf Heimatdomäne",
"status.favourite": "Favorisieren",
"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.quotes": "{count, plural, one {Mal zitiert} other {Mal zitiert}}",
"status.interactions.reblogs": "{count, plural, one {Mal geteilt} other {Mal geteilt}}",
@ -1311,6 +1385,7 @@
"tabs_bar.all": "Alle",
"tabs_bar.dashboard": "Steuerung",
"tabs_bar.fediverse": "Fediverse",
"tabs_bar.groups": "Gruppen",
"tabs_bar.home": "Start",
"tabs_bar.local": "Lokal",
"tabs_bar.more": "Mehr",

@ -47,6 +47,7 @@
"account.report": "Report @{name}",
"account.requested": "Awaiting approval. Click to cancel follow request",
"account.requested_small": "Awaiting approval",
"account.rss_feed": "Subscribe to RSS feed",
"account.search": "Search from @{name}",
"account.search_self": "Search your posts",
"account.share": "Share @{name}'s profile",
@ -705,8 +706,6 @@
"filters.context_header": "Filter contexts",
"filters.context_hint": "One or multiple contexts where the filter should apply",
"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_hide": "Hide",
"filters.filters_list_phrase_label": "Keyword or phrase:",
@ -960,6 +959,9 @@
"navigation_bar.preferences": "Preferences",
"navigation_bar.profile_directory": "Profile directory",
"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.subtitle": "Can't find what you're looking for? Start your own private or public group.",
"new_group_panel.title": "Create New Group",

@ -47,6 +47,7 @@
"account.report": "Reportar a @{name}",
"account.requested": "Esperando 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_self": "Busca en tus entradas",
"account.share": "Compartir el perfil de @{name}",
@ -960,6 +961,9 @@
"navigation_bar.preferences": "Preferencias",
"navigation_bar.profile_directory": "Profile directory",
"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.subtitle": "¿No encuentra lo que busca? Crea tu propio grupo privado o público.",
"new_group_panel.title": "Crear un nuevo grupo",

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

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

@ -47,6 +47,7 @@
"account.report": "举报 @{name}",
"account.requested": "正在等待对方批准。点击以取消发送关注请求",
"account.requested_small": "等待批准",
"account.rss_feed": "订阅 RSS 源",
"account.search": "在 @{name} 的内容中搜索",
"account.search_self": "搜索您的帖文",
"account.share": "分享 @{name} 的个人资料",
@ -960,6 +961,9 @@
"navigation_bar.preferences": "首选项",
"navigation_bar.profile_directory": "发现用户",
"navigation_bar.soapbox_config": "Soapbox 设置",
"new_event_panel.action": "创建活动",
"new_event_panel.subtitle": "找不到您要查找的内容?安排您自己的活动。",
"new_event_panel.title": "创建新活动",
"new_group_panel.action": "创建群组",
"new_group_panel.subtitle": "找不到你要找的东西?开始你自己的私有或公共群组。",
"new_group_panel.title": "创建新群组",
@ -1123,11 +1127,11 @@
"registrations.unprocessable_entity": "此用户名已被占用。",
"registrations.username.hint": "只能包含字母、数字和下划线",
"registrations.username.label": "您的用户名",
"relative_time.days": "{number}d",
"relative_time.hours": "{number}h",
"relative_time.days": "{number}",
"relative_time.hours": "{number}",
"relative_time.just_now": "刚刚",
"relative_time.minutes": "{number}m",
"relative_time.seconds": "{number}s",
"relative_time.minutes": "{number}",
"relative_time.seconds": "{number}",
"remote_instance.edit_federation": "编辑联邦设置",
"remote_instance.federation_panel.heading": "联邦站点限制",
"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 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({
account_id: '',
attachment: null as Attachment | null,
media_attachments: ImmutableList<Attachment>(),
card: null as Card | null,
chat_id: '',
content: '',
created_at: '',
emojis: ImmutableList<Emoji>(),
expiration: null as number | null,
emoji_reactions: ImmutableList<EmojiReaction>(),
id: '',
unread: false,
deleting: false,
@ -24,12 +28,25 @@ export const ChatMessageRecord = ImmutableRecord({
});
const normalizeMedia = (status: ImmutableMap<string, any>) => {
const attachments = status.get('media_attachments');
const attachment = status.get('attachment');
if (attachment) {
return status.set('attachment', normalizeAttachment(attachment));
if (attachments) {
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 {
return status;
return chatMessage;
}
};
@ -37,6 +54,7 @@ export const normalizeChatMessage = (chatMessage: Record<string, any>) => {
return ChatMessageRecord(
ImmutableMap(fromJS(chatMessage)).withMutations(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';
export type ContextType = 'home' | 'public' | 'notifications' | 'thread';
// https://docs.joinmastodon.org/entities/filter/
export const FilterRecord = ImmutableRecord({
id: '',
phrase: '',
context: ImmutableList<string>(),
context: ImmutableList<ContextType>(),
whole_word: false,
expires_at: '',
irreversible: false,

@ -8,6 +8,7 @@ export { CardRecord, normalizeCard } from './card';
export { ChatRecord, normalizeChat } from './chat';
export { ChatMessageRecord, normalizeChatMessage } from './chat-message';
export { EmojiRecord, normalizeEmoji } from './emoji';
export { EmojiReactionRecord } from './emoji-reaction';
export { FilterRecord, normalizeFilter } from './filter';
export { GroupRecord, normalizeGroup } from './group';
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,22 +3,14 @@ import React from 'react';
import { Column, Layout } from 'soapbox/components/ui';
import LinkFooter from 'soapbox/features/ui/components/link-footer';
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
import {
NewGroupPanel,
CtaBanner,
} from 'soapbox/features/ui/util/async-components';
import { useAppSelector } from 'soapbox/hooks';
import { NewGroupPanel } from 'soapbox/features/ui/util/async-components';
interface IGroupsPage {
children: React.ReactNode
}
/** Page to display groups. */
const GroupsPage: React.FC<IGroupsPage> = ({ children }) => {
const me = useAppSelector(state => state.me);
// const match = useRouteMatch();
return (
const GroupsPage: React.FC<IGroupsPage> = ({ children }) => (
<>
<Layout.Main>
<Column withHeader={false}>
@ -26,12 +18,6 @@ const GroupsPage: React.FC<IGroupsPage> = ({ children }) => {
{children}
</div>
</Column>
{!me && (
<BundleContainer fetchComponent={CtaBanner}>
{Component => <Component key='cta-banner' />}
</BundleContainer>
)}
</Layout.Main>
<Layout.Aside>
@ -41,7 +27,6 @@ const GroupsPage: React.FC<IGroupsPage> = ({ children }) => {
<LinkFooter key='link-footer' />
</Layout.Aside>
</>
);
};
);
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 { useEffect } from 'react';
import { __stub } from 'soapbox/api';
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 { ChatMessage } from 'soapbox/types/entities';
import { flattenPages } from 'soapbox/utils/queries';
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 = {
accepted: true,
@ -22,6 +24,7 @@ const chat: IChat = {
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,
@ -33,12 +36,14 @@ const chat: IChat = {
unread: 0,
};
const buildChatMessage = (id: string): IChatMessage => ({
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,
});
@ -379,4 +384,52 @@ describe('useChatActions', () => {
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 { normalizeChatMessage } from 'soapbox/normalizers';
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 { queryClient } from './client';
@ -28,6 +29,7 @@ export enum MessageExpirationValues {
export interface IChat {
accepted: boolean
account: IAccount
chat_type: 'channel' | 'direct'
created_at: string
created_by_account: string
discarded_at: null | string
@ -50,20 +52,16 @@ export interface IChat {
unread: number
}
export interface IChatMessage {
account_id: string
chat_id: string
content: string
created_at: string
id: string
unread: boolean
pending?: boolean
}
type UpdateChatVariables = {
message_expiration: MessageExpirationValues
}
type CreateReactionVariables = {
messageId: string
emoji: string
chatMessage?: ChatMessage
}
const ChatKeys = {
chat: (chatId?: string) => ['chats', 'chat', chatId] as const,
chatMessages: (chatId: string) => ['chats', 'messages', chatId] as const,
@ -83,7 +81,7 @@ const useChatMessages = (chat: IChat) => {
const api = useApi();
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 uri = nextPageLink || `/api/v1/pleroma/chats/${chatId}/messages`;
const response = await api.get<any[]>(uri);
@ -235,7 +233,7 @@ const useChatActions = (chatId: string) => {
const createChatMessage = useMutation(
(
{ 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,
onMutate: async (variables) => {
@ -245,6 +243,7 @@ const useChatActions = (chatId: string) => {
// Snapshot the previous value
const prevContent = variables.content;
const prevChatMessages = queryClient.getQueryData(['chats', 'messages', variables.chatId]);
const pendingId = String(Number(new Date()));
// Optimistically update to the new value
queryClient.setQueryData(ChatKeys.chatMessages(variables.chatId), (prevResult: any) => {
@ -256,7 +255,7 @@ const useChatActions = (chatId: string) => {
result: [
normalizeChatMessage({
content: variables.content,
id: String(Number(new Date())),
id: pendingId,
created_at: new Date(),
account_id: account?.id,
pending: true,
@ -273,18 +272,21 @@ const useChatActions = (chatId: string) => {
return newResult;
});
return { prevChatMessages, prevContent };
return { prevChatMessages, prevContent, pendingId };
},
// If the mutation fails, use the context returned from onMutate to roll back
onError: (_error: any, variables, context: any) => {
queryClient.setQueryData(['chats', 'messages', variables.chatId], context.prevChatMessages);
},
onSuccess: (response, variables) => {
onSuccess: (response: any, variables, context) => {
const nextChat = { ...chat, last_message: response.data };
updatePageItem(ChatKeys.chatSearch(), nextChat, (o, n) => o.id === n.id);
updatePageItem(
ChatKeys.chatMessages(variables.chatId),
normalizeChatMessage(response.data),
(o) => o.id === context.pendingId,
);
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 };

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

@ -9,6 +9,7 @@ import {
ChatRecord,
ChatMessageRecord,
EmojiRecord,
EmojiReactionRecord,
FieldRecord,
FilterRecord,
GroupRecord,
@ -40,6 +41,7 @@ type Card = ReturnType<typeof CardRecord>;
type Chat = ReturnType<typeof ChatRecord>;
type ChatMessage = ReturnType<typeof ChatMessageRecord>;
type Emoji = ReturnType<typeof EmojiRecord>;
type EmojiReaction = ReturnType<typeof EmojiReactionRecord>;
type Field = ReturnType<typeof FieldRecord>;
type Filter = ReturnType<typeof FilterRecord>;
type Group = ReturnType<typeof GroupRecord>;
@ -84,6 +86,7 @@ export {
Chat,
ChatMessage,
Emoji,
EmojiReaction,
Field,
Filter,
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 {
sortEmoji,
mergeEmojiFavourites,
filterEmoji,
oneEmojiPerAccount,
reduceEmoji,
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('with an unsorted list of emoji', () => {
const emojiReacts = fromJS([
{ 'count': 7, 'me': true, 'name': '😃' },
{ 'count': 7, 'me': true, 'name': '😯' },
{ 'count': 3, 'me': true, 'name': '😢' },
{ 'count': 1, 'me': true, 'name': '😡' },
@ -53,11 +33,12 @@ describe('sortEmoji', () => {
{ 'count': 15, 'me': true, 'name': '❤' },
]) as ImmutableList<ImmutableMap<string, any>>;
it('sorts the emoji by count', () => {
expect(sortEmoji(emojiReacts)).toEqual(fromJS([
expect(sortEmoji(emojiReacts, ALLOWED_EMOJI)).toEqual(fromJS([
{ 'count': 20, 'me': true, 'name': '👍' },
{ 'count': 15, 'me': true, 'name': '❤' },
{ 'count': 7, 'me': true, 'name': '😯' },
{ 'count': 7, 'me': true, 'name': '😂' },
{ 'count': 7, 'me': true, 'name': '😃' },
{ 'count': 3, 'me': true, 'name': '😢' },
{ 'count': 1, 'me': true, 'name': '😡' },
]));
@ -127,6 +108,10 @@ describe('reduceEmoji', () => {
{ 'count': 7, 'me': false, 'name': '😂' },
{ 'count': 3, '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);
};
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 EmojiReact = ImmutableMap<string, any>;
export const sortEmoji = (emojiReacts: ImmutableList<EmojiReact>): ImmutableList<EmojiReact> => (
emojiReacts.sortBy(emojiReact => -emojiReact.get('count'))
);
export const mergeEmoji = (emojiReacts: ImmutableList<EmojiReact>): ImmutableList<EmojiReact> => (
emojiReacts // TODO: Merge similar emoji
export const sortEmoji = (emojiReacts: ImmutableList<EmojiReact>, allowedEmoji: ImmutableList<string>): ImmutableList<EmojiReact> => (
emojiReacts
.sortBy(emojiReact =>
-(emojiReact.get('count') + Number(allowedEmoji.includes(emojiReact.get('name')))))
);
export const mergeEmojiFavourites = (emojiReacts = ImmutableList<EmojiReact>(), favouritesCount: number, favourited: boolean) => {
@ -70,15 +68,11 @@ export const oneEmojiPerAccount = (emojiReacts: ImmutableList<EmojiReact>, me: M
.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> => (
filterEmoji(sortEmoji(mergeEmoji(mergeEmojiFavourites(
emojiReacts, favouritesCount, favourited,
))), allowedEmoji));
sortEmoji(
mergeEmojiFavourites(emojiReacts, favouritesCount, favourited),
allowedEmoji,
));
export const getReactForStatus = (status: any, allowedEmoji = ALLOWED_EMOJI): string | undefined => {
const result = reduceEmoji(

@ -248,6 +248,11 @@ const getInstanceFeatures = (instance: Instance) => {
*/
chatAcceptance: v.software === TRUTHSOCIAL,
/**
* Ability to add reactions to chat messages.
*/
chatEmojiReactions: false, // v.software === TRUTHSOCIAL,
/**
* Pleroma chats API.
* @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'),
/**
* 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
*/
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.
@ -711,6 +716,14 @@ const getInstanceFeatures = (instance: Instance) => {
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.
* @see POST /api/v1/statuses

@ -25,7 +25,6 @@
@import 'components/react-toggle';
@import 'components/video-player';
@import 'components/audio-player';
@import 'components/filters';
@import 'components/crypto-donate';
@import 'components/aliases';
@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-placeholder {
@apply rounded-none shadow-none p-4;
@apply bg-transparent dark:bg-transparent rounded-none shadow-none p-4;
}
.status-check-box {

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

@ -140,7 +140,7 @@ const configuration: Configuration = {
'/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;
}
},

@ -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"
source-map "^0.6.1"
postcss@^8.2.15:
postcss@^8.2.15, 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==
@ -14065,15 +14065,6 @@ postcss@^8.4.19:
picocolors "^1.0.0"
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:
version "1.2.1"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
@ -14743,12 +14734,10 @@ react-sparklines@^1.7.0:
dependencies:
prop-types "^15.5.10"
react-sticky-box@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/react-sticky-box/-/react-sticky-box-1.0.2.tgz#7e72a0f237bdf8270cec9254337f49519a411174"
integrity sha512-Kyvtppdtv1KqJyNU4DtrSMI0unyQRgtraZvVQ0GAazVbYiTsIVpyhpr+5R0Aavzu4uJNSe1awj2rk/qI7i6Zfw==
dependencies:
resize-observer-polyfill "^1.5.1"
react-sticky-box@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/react-sticky-box/-/react-sticky-box-2.0.0.tgz#db3d19f700f2c2fb8405da6973cadd9b74ceea91"
integrity sha512-xTy/46lG6GlfdkGL7DoevLj+p3lqjvULZvGkN36/2oM8FrijvFq/zzfAtK0ZSnDxM9Ct6KOr5ZVuejVdDFqEcw==
react-swipeable-views-core@^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"
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:
version "2.0.2"
resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b"

Loading…
Cancel
Save