Merge remote-tracking branch 'origin/develop' into multiple-attachments

This commit is contained in:
Alex Gleason 2023-02-14 09:24:12 -06:00
commit 14efff51ff
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
121 changed files with 2260 additions and 3173 deletions

View File

@ -14,12 +14,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Events: allow to repost events in event menu. - Events: allow to repost events in event menu.
- Groups: Initial support for groups. - Groups: Initial support for groups.
- Profile: Add RSS link to user profiles. - 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.
- Chats: reset chat message field height after sending a message.
### Changed ### Changed
- Chats: improved display of media attachments. - Chats: improved display of media attachments.
- ServiceWorker: switch to a network-first strategy. The "An update is available!" prompt goes away. - ServiceWorker: switch to a network-first strategy. The "An update is available!" prompt goes away.
- Posts: increased font size of focused status in threads. - Posts: increased font size of focused status in threads.
- Posts: let "mute conversation" be clicked from any feed, not just noficiations. - Posts: let "mute conversation" be clicked from any feed, not just noficiations.
- Posts: display all emoji reactions.
- Reactions: improved UI of reactions on statuses.
### Fixed ### Fixed
- Chats: media attachments rendering at the wrong size and/or causing the chat to scroll on load. - Chats: media attachments rendering at the wrong size and/or causing the chat to scroll on load.

View File

@ -1,13 +1,8 @@
import type { DropdownPlacement } from 'soapbox/components/dropdown-menu';
const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN'; const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN';
const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE'; const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE';
const openDropdownMenu = (id: number, placement: DropdownPlacement, keyboard: boolean) => const openDropdownMenu = () => ({ type: DROPDOWN_MENU_OPEN });
({ type: DROPDOWN_MENU_OPEN, id, placement, keyboard }); const closeDropdownMenu = () => ({ type: DROPDOWN_MENU_CLOSE });
const closeDropdownMenu = (id: number) =>
({ type: DROPDOWN_MENU_CLOSE, id });
export { export {
DROPDOWN_MENU_OPEN, DROPDOWN_MENU_OPEN,

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
import { Portal } from '@reach/portal';
import clsx from 'clsx'; import clsx from 'clsx';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import React from 'react'; import React from 'react';
@ -6,7 +5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import AutosuggestEmoji, { Emoji } from 'soapbox/components/autosuggest-emoji'; import AutosuggestEmoji, { Emoji } from 'soapbox/components/autosuggest-emoji';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import { Input } from 'soapbox/components/ui'; import { Input, Portal } from 'soapbox/components/ui';
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account'; import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
import { isRtl } from 'soapbox/rtl'; import { isRtl } from 'soapbox/rtl';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions'; import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';

View File

@ -1,9 +1,9 @@
import { Portal } from '@reach/portal';
import clsx from 'clsx'; import clsx from 'clsx';
import React from 'react'; import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize'; import Textarea from 'react-textarea-autosize';
import { Portal } from 'soapbox/components/ui';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions'; import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import AutosuggestAccount from '../features/compose/components/autosuggest-account'; import AutosuggestAccount from '../features/compose/components/autosuggest-account';

View File

@ -29,7 +29,7 @@ const CopyableInput: React.FC<ICopyableInput> = ({ value }) => {
type='text' type='text'
value={value} value={value}
className='rounded-r-none rtl:rounded-l-none rtl:rounded-r-lg' className='rounded-r-none rtl:rounded-l-none rtl:rounded-r-lg'
outerClassName='flex-grow' outerClassName='grow'
onClick={selectInput} onClick={selectInput}
readOnly readOnly
/> />

View File

@ -1,420 +0,0 @@
import clsx from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events';
import React from 'react';
import { spring } from 'react-motion';
// @ts-ignore: TODO: upgrade react-overlays. v3.1 and above have TS definitions
import Overlay from 'react-overlays/lib/Overlay';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { Counter, IconButton } from 'soapbox/components/ui';
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import Motion from 'soapbox/features/ui/util/optional-motion';
import type { Status } from 'soapbox/types/entities';
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
let id = 0;
export interface MenuItem {
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>,
middleClick?: React.EventHandler<React.MouseEvent>,
text: string,
href?: string,
to?: string,
newTab?: boolean,
isLogout?: boolean,
icon?: string,
count?: number,
destructive?: boolean,
meta?: string,
active?: boolean,
}
export type Menu = Array<MenuItem | null>;
interface IDropdownMenu extends RouteComponentProps {
items: Menu,
onClose: () => void,
style?: React.CSSProperties,
placement?: DropdownPlacement,
arrowOffsetLeft?: string,
arrowOffsetTop?: string,
openedViaKeyboard: boolean,
}
interface IDropdownMenuState {
mounted: boolean,
}
class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState> {
static defaultProps: Partial<IDropdownMenu> = {
style: {},
placement: 'bottom',
};
state = {
mounted: false,
};
node: HTMLDivElement | null = null;
focusedItem: HTMLAnchorElement | null = null;
handleDocumentClick = (e: Event) => {
if (this.node && !this.node.contains(e.target as Node)) {
this.props.onClose();
}
};
componentDidMount() {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('keydown', this.handleKeyDown, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem && this.props.openedViaKeyboard) {
this.focusedItem.focus({ preventScroll: true });
}
this.setState({ mounted: true });
}
componentWillUnmount() {
document.removeEventListener('click', this.handleDocumentClick);
document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('touchend', this.handleDocumentClick);
}
setRef: React.RefCallback<HTMLDivElement> = c => {
this.node = c;
};
setFocusRef: React.RefCallback<HTMLAnchorElement> = c => {
this.focusedItem = c;
};
handleKeyDown = (e: KeyboardEvent) => {
if (!this.node) return;
const items = Array.from(this.node.getElementsByTagName('a'));
const index = items.indexOf(document.activeElement as any);
let element = null;
switch (e.key) {
case 'ArrowDown':
element = items[index + 1] || items[0];
break;
case 'ArrowUp':
element = items[index - 1] || items[items.length - 1];
break;
case 'Tab':
if (e.shiftKey) {
element = items[index - 1] || items[items.length - 1];
} else {
element = items[index + 1] || items[0];
}
break;
case 'Home':
element = items[0];
break;
case 'End':
element = items[items.length - 1];
break;
case 'Escape':
this.props.onClose();
break;
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
};
handleItemKeyPress: React.EventHandler<React.KeyboardEvent> = e => {
if (e.key === 'Enter' || e.key === ' ') {
this.handleClick(e);
}
};
handleClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = this.props.items[i];
if (!item) return;
const { action, to } = item;
this.props.onClose();
e.stopPropagation();
if (to) {
e.preventDefault();
this.props.history.push(to);
} else if (typeof action === 'function') {
e.preventDefault();
action(e);
}
};
handleMiddleClick: React.EventHandler<React.MouseEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = this.props.items[i];
if (!item) return;
const { middleClick } = item;
this.props.onClose();
if (e.button === 1 && typeof middleClick === 'function') {
e.preventDefault();
middleClick(e);
}
};
handleAuxClick: React.EventHandler<React.MouseEvent> = e => {
if (e.button === 1) {
this.handleMiddleClick(e);
}
};
renderItem(option: MenuItem | null, i: number): JSX.Element {
if (option === null) {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, href, to, newTab, isLogout, icon, count, destructive } = option;
return (
<li className={clsx('dropdown-menu__item truncate', { destructive })} key={`${text}-${i}`}>
<a
href={href || to || '#'}
role='button'
tabIndex={0}
ref={i === 0 ? this.setFocusRef : null}
onClick={this.handleClick}
onAuxClick={this.handleAuxClick}
onKeyPress={this.handleItemKeyPress}
data-index={i}
target={newTab ? '_blank' : undefined}
data-method={isLogout ? 'delete' : undefined}
title={text}
>
{icon && <SvgIcon src={icon} className='mr-3 h-5 w-5 flex-none rtl:ml-3 rtl:mr-0' />}
<span className='truncate'>{text}</span>
{count ? (
<span className='ml-auto h-5 w-5 flex-none'>
<Counter count={count} />
</span>
) : null}
</a>
</li>
);
}
render() {
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
const { mounted } = this.state;
return (
<Motion defaultStyle={{ opacity: 0, scaleX: 1, scaleY: 1 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
// It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by
// react-overlays
<div
className={`dropdown-menu ${placement}`}
style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : undefined }}
ref={this.setRef}
data-testid='dropdown-menu'
>
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
<ul>
{items.map((option, i) => this.renderItem(option, i))}
</ul>
</div>
)}
</Motion>
);
}
}
const RouterDropdownMenu = withRouter(DropdownMenu);
export interface IDropdown extends RouteComponentProps {
icon?: string,
src?: string,
items: Menu,
size?: number,
active?: boolean,
pressed?: boolean,
title?: string,
disabled?: boolean,
status?: Status,
isUserTouching?: () => boolean,
isModalOpen?: boolean,
onOpen?: (
id: number,
onItemClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
dropdownPlacement: DropdownPlacement,
keyboard: boolean,
) => void,
onClose?: (id: number) => void,
dropdownPlacement?: string,
openDropdownId?: number | null,
openedViaKeyboard?: boolean,
text?: string,
onShiftClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
children?: JSX.Element,
dropdownMenuStyle?: React.CSSProperties,
}
interface IDropdownState {
id: number,
open: boolean,
}
export type DropdownPlacement = 'top' | 'bottom';
class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
static defaultProps: Partial<IDropdown> = {
title: 'Menu',
};
state = {
id: id++,
open: false,
};
target: HTMLButtonElement | null = null;
activeElement: Element | null = null;
handleClick: React.EventHandler<React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>> = e => {
const { onOpen, onShiftClick, openDropdownId } = this.props;
e.stopPropagation();
if (onShiftClick && e.shiftKey) {
e.preventDefault();
onShiftClick(e);
} else if (this.state.id === openDropdownId) {
this.handleClose();
} else if (onOpen) {
const { top } = e.currentTarget.getBoundingClientRect();
const placement: DropdownPlacement = top * 2 < innerHeight ? 'bottom' : 'top';
onOpen(this.state.id, this.handleItemClick, placement, e.type !== 'click');
}
};
handleClose = () => {
if (this.activeElement && this.activeElement === this.target) {
(this.activeElement as HTMLButtonElement).focus();
this.activeElement = null;
}
if (this.props.onClose) {
this.props.onClose(this.state.id);
}
};
handleMouseDown: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
};
handleButtonKeyDown: React.EventHandler<React.KeyboardEvent> = (e) => {
switch (e.key) {
case ' ':
case 'Enter':
this.handleMouseDown(e);
break;
}
};
handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (e) => {
switch (e.key) {
case ' ':
case 'Enter':
this.handleClick(e);
e.stopPropagation();
e.preventDefault();
break;
}
};
handleItemClick: React.EventHandler<React.MouseEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = this.props.items[i];
if (!item) return;
const { action, to } = item;
this.handleClose();
e.preventDefault();
e.stopPropagation();
if (typeof action === 'function') {
action(e);
} else if (to) {
this.props.history?.push(to);
}
};
setTargetRef: React.RefCallback<HTMLButtonElement> = c => {
this.target = c;
};
findTarget = () => {
return this.target;
};
componentWillUnmount = () => {
if (this.state.id === this.props.openDropdownId) {
this.handleClose();
}
};
render() {
const { src = require('@tabler/icons/dots.svg'), items, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard = false, pressed, text, children, dropdownMenuStyle } = this.props;
const open = this.state.id === openDropdownId;
return (
<>
{children ? (
React.cloneElement(children, {
disabled,
onClick: this.handleClick,
onMouseDown: this.handleMouseDown,
onKeyDown: this.handleButtonKeyDown,
onKeyPress: this.handleKeyPress,
ref: this.setTargetRef,
})
) : (
<IconButton
disabled={disabled}
className={clsx({
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
'text-gray-700 dark:text-gray-500': open,
})}
title={title}
src={src}
aria-pressed={pressed}
text={text}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
onKeyPress={this.handleKeyPress}
ref={this.setTargetRef}
/>
)}
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
<RouterDropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} style={dropdownMenuStyle} />
</Overlay>
</>
);
}
}
export default withRouter(Dropdown);

View File

@ -0,0 +1,109 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import { useHistory } from 'react-router-dom';
import { Counter, Icon } from '../ui';
export interface MenuItem {
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>
active?: boolean
count?: number
destructive?: boolean
href?: string
icon?: string
meta?: string
middleClick?(event: React.MouseEvent): void
target?: React.HTMLAttributeAnchorTarget
text: string
to?: string
}
interface IDropdownMenuItem {
index: number
item: MenuItem | null
onClick?(): void
}
const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => {
const history = useHistory();
const itemRef = useRef<HTMLAnchorElement>(null);
const handleClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = (event) => {
event.stopPropagation();
if (!item) return;
if (onClick) onClick();
if (item.to) {
event.preventDefault();
history.push(item.to);
} else if (typeof item.action === 'function') {
event.preventDefault();
item.action(event);
}
};
const handleAuxClick: React.EventHandler<React.MouseEvent> = (event) => {
if (!item) return;
if (onClick) onClick();
if (event.button === 1 && item.middleClick) {
item.middleClick(event);
}
};
const handleItemKeyPress: React.EventHandler<React.KeyboardEvent> = (event) => {
if (event.key === 'Enter' || event.key === ' ') {
handleClick(event);
}
};
useEffect(() => {
const firstItem = index === 0;
if (itemRef.current && firstItem) {
itemRef.current.focus();
}
}, [itemRef.current, index]);
if (item === null) {
return <li className='my-1 mx-2 h-[2px] bg-gray-100 dark:bg-gray-800' />;
}
return (
<li className='truncate focus-within:ring-2 focus-within:ring-primary-500'>
<a
href={item.href || item.to || '#'}
role='button'
tabIndex={0}
ref={itemRef}
data-index={index}
onClick={handleClick}
onAuxClick={handleAuxClick}
onKeyPress={handleItemKeyPress}
target={item.target}
title={item.text}
className={
clsx({
'flex px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none cursor-pointer': true,
'text-danger-600 dark:text-danger-400': item.destructive,
})
}
>
{item.icon && <Icon src={item.icon} className='mr-3 h-5 w-5 flex-none rtl:ml-3 rtl:mr-0' />}
<span className='truncate'>{item.text}</span>
{item.count ? (
<span className='ml-auto h-5 w-5 flex-none'>
<Counter count={item.count} />
</span>
) : null}
</a>
</li>
);
};
export default DropdownMenuItem;

View File

@ -0,0 +1,300 @@
import { offset, Placement, useFloating } from '@floating-ui/react';
import clsx from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events';
import React, { useEffect, useRef, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { closeDropdownMenu, openDropdownMenu } from 'soapbox/actions/dropdown-menu';
import { closeModal, openModal } from 'soapbox/actions/modals';
import { useAppDispatch } from 'soapbox/hooks';
import { isUserTouching } from 'soapbox/is-mobile';
import { IconButton, Portal } from '../ui';
import DropdownMenuItem, { MenuItem } from './dropdown-menu-item';
import type { Status } from 'soapbox/types/entities';
export type Menu = Array<MenuItem | null>;
interface IDropdownMenu {
children?: React.ReactElement
disabled?: boolean
items: Menu
onClose?: () => void
onOpen?: () => void
onShiftClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>
placement?: Placement
src?: string
status?: Status
title?: string
}
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const DropdownMenu = (props: IDropdownMenu) => {
const {
children,
disabled,
items,
onClose,
onOpen,
onShiftClick,
placement = 'top',
src = require('@tabler/icons/dots.svg'),
title = 'Menu',
...filteredProps
} = props;
const dispatch = useAppDispatch();
const history = useHistory();
const [isOpen, setIsOpen] = useState<boolean>(false);
const activeElement = useRef<Element | null>(null);
const target = useRef<Element>(null);
const isOnMobile = isUserTouching();
const { x, y, strategy, refs } = useFloating<HTMLButtonElement>({
placement,
middleware: [offset(12)],
});
const handleClick: React.EventHandler<
React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>
> = (event) => {
event.stopPropagation();
if (onShiftClick && event.shiftKey) {
event.preventDefault();
onShiftClick(event);
return;
}
if (isOpen) {
handleClose();
} else {
handleOpen();
}
};
/**
* On mobile screens, let's replace the Popper dropdown with a Modal.
*/
const handleOpen = () => {
if (isOnMobile) {
dispatch(
openModal('ACTIONS', {
status: filteredProps.status,
actions: items,
onClick: handleItemClick,
}),
);
} else {
dispatch(openDropdownMenu());
setIsOpen(true);
}
if (onOpen) {
onOpen();
}
};
const handleClose = () => {
if (activeElement.current && activeElement.current === target.current) {
(activeElement.current as any).focus();
activeElement.current = null;
}
if (isOnMobile) {
dispatch(closeModal('ACTIONS'));
} else {
dispatch(closeDropdownMenu());
setIsOpen(false);
}
if (onClose) {
onClose();
}
};
const handleMouseDown: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = () => {
if (!isOpen) {
activeElement.current = document.activeElement;
}
};
const handleButtonKeyDown: React.EventHandler<React.KeyboardEvent> = (event) => {
switch (event.key) {
case ' ':
case 'Enter':
handleMouseDown(event);
break;
}
};
const handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (event) => {
switch (event.key) {
case ' ':
case 'Enter':
event.stopPropagation();
event.preventDefault();
handleClick(event);
break;
}
};
const handleItemClick: React.EventHandler<React.MouseEvent> = (event) => {
event.preventDefault();
event.stopPropagation();
const i = Number(event.currentTarget.getAttribute('data-index'));
const item = items[i];
if (!item) return;
const { action, to } = item;
handleClose();
if (typeof action === 'function') {
action(event);
} else if (to) {
history.push(to);
}
};
const handleDocumentClick = (event: Event) => {
if (refs.floating.current && !refs.floating.current.contains(event.target as Node)) {
handleClose();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!refs.floating.current) return;
const items = Array.from(refs.floating.current.getElementsByTagName('a'));
const index = items.indexOf(document.activeElement as any);
let element = null;
switch (e.key) {
case 'ArrowDown':
element = items[index + 1] || items[0];
break;
case 'ArrowUp':
element = items[index - 1] || items[items.length - 1];
break;
case 'Tab':
if (e.shiftKey) {
element = items[index - 1] || items[items.length - 1];
} else {
element = items[index + 1] || items[0];
}
break;
case 'Home':
element = items[0];
break;
case 'End':
element = items[items.length - 1];
break;
case 'Escape':
handleClose();
break;
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
};
useEffect(() => {
document.addEventListener('click', handleDocumentClick, false);
document.addEventListener('keydown', handleKeyDown, false);
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
return () => {
document.removeEventListener('click', handleDocumentClick);
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('touchend', handleDocumentClick);
};
}, [refs.floating.current]);
return (
<>
{children ? (
React.cloneElement(children, {
disabled,
onClick: handleClick,
onMouseDown: handleMouseDown,
onKeyDown: handleButtonKeyDown,
onKeyPress: handleKeyPress,
ref: refs.setReference,
})
) : (
<IconButton
disabled={disabled}
className={clsx({
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
'text-gray-700 dark:text-gray-500': isOpen,
})}
title={title}
src={src}
onClick={handleClick}
onMouseDown={handleMouseDown}
onKeyDown={handleButtonKeyDown}
onKeyPress={handleKeyPress}
ref={refs.setReference}
/>
)}
{isOpen ? (
<Portal>
<div
data-testid='dropdown-menu'
ref={refs.setFloating}
className={
clsx('relative z-[1001] w-56 rounded-md bg-white py-1 shadow-lg transition-opacity duration-100 focus:outline-none dark:bg-gray-900 dark:ring-2 dark:ring-primary-700', {
'opacity-0 pointer-events-none': !isOpen,
})
}
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
}}
>
<ul>
{items.map((item, idx) => (
<DropdownMenuItem
key={idx}
item={item}
index={idx}
onClick={handleClose}
/>
))}
</ul>
{/* Arrow */}
<div
className={
clsx({
'absolute w-0 h-0 border-0 border-solid border-transparent': true,
'border-t-white dark:border-t-gray-900 -bottom-[5px] -ml-[5px] left-[calc(50%-2.5px)] border-t-[5px] border-x-[5px] border-b-0': placement === 'top',
'border-b-white dark:border-b-gray-900 -top-[5px] -ml-[5px] left-[calc(50%-2.5px)] border-t-0 border-x-[5px] border-b-[5px]': placement === 'bottom',
'border-b-white dark:border-b-gray-900 -top-[5px] -ml-[5px] left-[92.5%] border-t-0 border-x-[5px] border-b-[5px]': placement === 'bottom-end',
})
}
/>
</div>
</Portal>
) : null}
</>
);
};
export default DropdownMenu;

View File

@ -0,0 +1,3 @@
export { default } from './dropdown-menu';
export type { Menu } from './dropdown-menu';
export type { MenuItem } from './dropdown-menu-item';

View File

@ -1,142 +0,0 @@
// import clsx from 'clsx';
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,
};
node?: HTMLDivElement = undefined;
handleBlur: React.FocusEventHandler<HTMLDivElement> = e => {
const { focused, onUnfocus } = this.props;
if (focused && (!e.currentTarget || !e.currentTarget.classList.contains('emoji-react-selector__emoji'))) {
onUnfocus();
}
};
_selectPreviousEmoji = (i: number): void => {
if (!this.node) return;
if (i !== 0) {
const button: HTMLButtonElement | null = this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i})`);
button?.focus();
} else {
const button: HTMLButtonElement | null = this.node.querySelector('.emoji-react-selector__emoji:last-child');
button?.focus();
}
};
_selectNextEmoji = (i: number) => {
if (!this.node) return;
if (i !== this.props.allowedEmoji.size - 1) {
const button: HTMLButtonElement | null = this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`);
button?.focus();
} else {
const button: HTMLButtonElement | null = this.node.querySelector('.emoji-react-selector__emoji:first-child');
button?.focus();
}
};
handleKeyDown = (i: number): React.KeyboardEventHandler => e => {
const { onUnfocus } = this.props;
switch (e.key) {
case 'Tab':
e.preventDefault();
if (e.shiftKey) this._selectPreviousEmoji(i);
else this._selectNextEmoji(i);
break;
case 'Left':
case 'ArrowLeft':
this._selectPreviousEmoji(i);
break;
case 'Right':
case 'ArrowRight':
this._selectNextEmoji(i);
break;
case 'Escape':
onUnfocus();
break;
}
};
handleReact = (emoji: string) => (): void => {
const { onReact, focused, onUnfocus } = this.props;
onReact(emoji);
if (focused) {
onUnfocus();
}
};
handlers = {
open: () => { },
};
setRef = (c: HTMLDivElement): void => {
this.node = c;
};
render() {
const { visible, focused, allowedEmoji, onReact } = this.props;
return (
<HotKeys handlers={this.handlers}>
{/*<div
className={clsx('flex absolute bg-white dark:bg-gray-500 px-2 py-3 rounded-full shadow-md opacity-0 pointer-events-none duration-100 w-max', { 'opacity-100 pointer-events-auto z-[999]': visible || focused })}
onBlur={this.handleBlur}
ref={this.setRef}
>
{allowedEmoji.map((emoji, i) => (
<button
key={i}
className='emoji-react-selector__emoji'
onClick={this.handleReact(emoji)}
onKeyDown={this.handleKeyDown(i)}
tabIndex={(visible || focused) ? 0 : -1}
>
<Emoji emoji={emoji} />
</button>
))}
</div>*/}
<RealEmojiSelector
emojis={allowedEmoji.toArray()}
onReact={onReact}
visible={visible}
focused={focused}
/>
</HotKeys>
);
}
}
export default connect(mapStateToProps)(EmojiSelector);

View File

@ -18,4 +18,4 @@ const OutlineBox: React.FC<IOutlineBox> = ({ children, className, ...rest }) =>
); );
}; };
export default OutlineBox; export default OutlineBox;

View File

@ -2,15 +2,13 @@ import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Stack } from 'soapbox/components/ui'; import { Stack } from 'soapbox/components/ui';
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
import { useStatContext } from 'soapbox/contexts/stat-context'; import { useStatContext } from 'soapbox/contexts/stat-context';
import ComposeButton from 'soapbox/features/ui/components/compose-button'; import ComposeButton from 'soapbox/features/ui/components/compose-button';
import { useAppSelector, useFeatures, useOwnAccount, useSettings } from 'soapbox/hooks'; import { useAppSelector, useFeatures, useOwnAccount, useSettings } from 'soapbox/hooks';
import DropdownMenu, { Menu } from './dropdown-menu';
import SidebarNavigationLink from './sidebar-navigation-link'; import SidebarNavigationLink from './sidebar-navigation-link';
import type { Menu } from 'soapbox/components/dropdown-menu';
const messages = defineMessages({ const messages = defineMessages({
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
@ -185,7 +183,7 @@ const SidebarNavigation = () => {
)} )}
{menu.length > 0 && ( {menu.length > 0 && (
<DropdownMenu items={menu}> <DropdownMenu items={menu} placement='top'>
<SidebarNavigationLink <SidebarNavigationLink
icon={require('@tabler/icons/dots-circle-horizontal.svg')} icon={require('@tabler/icons/dots-circle-horizontal.svg')}
text={<FormattedMessage id='tabs_bar.more' defaultMessage='More' />} text={<FormattedMessage id='tabs_bar.more' defaultMessage='More' />}

View File

@ -14,10 +14,10 @@ import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions
import { initMuteModal } from 'soapbox/actions/mutes'; import { initMuteModal } from 'soapbox/actions/mutes';
import { initReport } from 'soapbox/actions/reports'; import { initReport } from 'soapbox/actions/reports';
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses'; import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper'; import DropdownMenu from 'soapbox/components/dropdown-menu';
import StatusActionButton from 'soapbox/components/status-action-button'; import StatusActionButton from 'soapbox/components/status-action-button';
import StatusReactionWrapper from 'soapbox/components/status-reaction-wrapper';
import { HStack } from 'soapbox/components/ui'; import { HStack } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
import { isLocal, isRemote } from 'soapbox/utils/accounts'; import { isLocal, isRemote } from 'soapbox/utils/accounts';
@ -617,19 +617,19 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
/> />
{(features.quotePosts && me) ? ( {(features.quotePosts && me) ? (
<DropdownMenuContainer <DropdownMenu
items={reblogMenu} items={reblogMenu}
disabled={!publicStatus} disabled={!publicStatus}
onShiftClick={handleReblogClick} onShiftClick={handleReblogClick}
> >
{reblogButton} {reblogButton}
</DropdownMenuContainer> </DropdownMenu>
) : ( ) : (
reblogButton reblogButton
)} )}
{features.emojiReacts ? ( {features.emojiReacts ? (
<EmojiButtonWrapper statusId={status.id}> <StatusReactionWrapper statusId={status.id}>
<StatusActionButton <StatusActionButton
title={meEmojiTitle} title={meEmojiTitle}
icon={require('@tabler/icons/heart.svg')} icon={require('@tabler/icons/heart.svg')}
@ -640,7 +640,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
emoji={meEmojiReact} emoji={meEmojiReact}
text={withLabels ? meEmojiTitle : undefined} text={withLabels ? meEmojiTitle : undefined}
/> />
</EmojiButtonWrapper> </StatusReactionWrapper>
) : ( ) : (
<StatusActionButton <StatusActionButton
title={intl.formatMessage(messages.favourite)} title={intl.formatMessage(messages.favourite)}
@ -662,12 +662,12 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
/> />
)} )}
<DropdownMenuContainer items={menu} status={status}> <DropdownMenu items={menu} status={status}>
<StatusActionButton <StatusActionButton
title={intl.formatMessage(messages.more)} title={intl.formatMessage(messages.more)}
icon={require('@tabler/icons/dots.svg')} icon={require('@tabler/icons/dots.svg')}
/> />
</DropdownMenuContainer> </DropdownMenu>
</HStack> </HStack>
</HStack> </HStack>
); );

View File

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

View File

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

View File

@ -4,10 +4,10 @@ import { defineMessages, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { deleteStatus } from 'soapbox/actions/statuses'; import { deleteStatus } from 'soapbox/actions/statuses';
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
import { useAppDispatch, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks'; import { useAppDispatch, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
import { defaultMediaVisibility } from 'soapbox/utils/status'; import { defaultMediaVisibility } from 'soapbox/utils/status';
import DropdownMenu from '../dropdown-menu';
import { Button, HStack, Text } from '../ui'; import { Button, HStack, Text } from '../ui';
import type { Status as StatusEntity } from 'soapbox/types/entities'; import type { Status as StatusEntity } from 'soapbox/types/entities';

View File

@ -2,7 +2,7 @@ import clsx from 'clsx';
import React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import DropdownMenu from 'soapbox/containers/dropdown-menu-container'; import DropdownMenu from 'soapbox/components/dropdown-menu';
import HStack from '../hstack/hstack'; import HStack from '../hstack/hstack';
import Icon from '../icon/icon'; import Icon from '../icon/icon';

View File

@ -8,7 +8,7 @@ const themes = {
tertiary: tertiary:
'bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500', 'bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500',
accent: 'border-transparent bg-secondary-500 hover:bg-secondary-400 focus:bg-secondary-500 text-gray-100 focus:ring-secondary-300', accent: 'border-transparent bg-secondary-500 hover:bg-secondary-400 focus:bg-secondary-500 text-gray-100 focus:ring-secondary-300',
danger: 'border-transparent bg-danger-100 dark:bg-danger-900 text-danger-600 dark:text-danger-200 hover:bg-danger-600 hover:text-gray-100 dark:hover:text-gray-100 dark:hover:bg-danger-500 focus:bg-danger-800 dark:focus:bg-danger-600', danger: 'border-transparent bg-danger-100 dark:bg-danger-900 text-danger-600 dark:text-danger-200 hover:bg-danger-600 hover:text-gray-100 dark:hover:text-gray-100 dark:hover:bg-danger-500 focus:bg-danger-800 focus:text-gray-200 dark:focus:bg-danger-600 dark:focus:text-gray-100',
transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80', transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80',
outline: 'border-gray-100 border-2 bg-transparent text-gray-100 hover:bg-white/10', outline: 'border-gray-100 border-2 bg-transparent text-gray-100 hover:bg-white/10',
muted: 'border border-solid bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500', muted: 'border border-solid bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500',

View File

@ -1,13 +1,17 @@
import { Placement } from '@popperjs/core';
import clsx from 'clsx'; import clsx from 'clsx';
import React from 'react'; import React, { useEffect, useState } from 'react';
import { usePopper } from 'react-popper';
import { Emoji, HStack } from 'soapbox/components/ui'; import { Emoji, HStack, IconButton } from 'soapbox/components/ui';
import { Picker } from 'soapbox/features/emoji/emoji-picker';
import { useSoapboxConfig } from 'soapbox/hooks';
interface IEmojiButton { interface IEmojiButton {
/** Unicode emoji character. */ /** Unicode emoji character. */
emoji: string, emoji: string,
/** Event handler when the emoji is clicked. */ /** Event handler when the emoji is clicked. */
onClick: React.EventHandler<React.MouseEvent>, onClick(emoji: string): void
/** Extra class name on the <button> element. */ /** Extra class name on the <button> element. */
className?: string, className?: string,
/** Tab order of the button. */ /** Tab order of the button. */
@ -16,48 +20,135 @@ interface IEmojiButton {
/** Clickable emoji button that scales when hovered. */ /** Clickable emoji button that scales when hovered. */
const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabIndex }): JSX.Element => { const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabIndex }): JSX.Element => {
const handleClick: React.EventHandler<React.MouseEvent> = (event) => {
event.preventDefault();
event.stopPropagation();
onClick(emoji);
};
return ( return (
<button className={clsx(className)} onClick={onClick} tabIndex={tabIndex}> <button className={clsx(className)} onClick={handleClick} tabIndex={tabIndex}>
<Emoji className='h-8 w-8 duration-100 hover:scale-125' emoji={emoji} /> <Emoji className='h-6 w-6 duration-100 hover:scale-110' emoji={emoji} />
</button> </button>
); );
}; };
interface IEmojiSelector { interface IEmojiSelector {
/** List of Unicode emoji characters. */ onClose?(): void
emojis: Iterable<string>,
/** Event handler when an emoji is clicked. */ /** Event handler when an emoji is clicked. */
onReact: (emoji: string) => void, onReact(emoji: string): void
/** Element that triggers the EmojiSelector Popper */
referenceElement: HTMLElement | null
placement?: Placement
/** Whether the selector should be visible. */ /** Whether the selector should be visible. */
visible?: boolean, visible?: boolean
/** Whether the selector should be focused. */ /** X/Y offset of the floating picker. */
focused?: boolean, offset?: [number, number]
/** Whether to allow any emoji to be chosen. */
all?: boolean
} }
/** Panel with a row of emoji buttons. */ /** Panel with a row of emoji buttons. */
const EmojiSelector: React.FC<IEmojiSelector> = ({ emojis, onReact, visible = false, focused = false }): JSX.Element => { const EmojiSelector: React.FC<IEmojiSelector> = ({
referenceElement,
onClose,
onReact,
placement = 'top',
visible = false,
offset = [-10, 0],
all = true,
}): JSX.Element => {
const soapboxConfig = useSoapboxConfig();
const handleReact = (emoji: string): React.EventHandler<React.MouseEvent> => { const [expanded, setExpanded] = useState(false);
return (e) => {
onReact(emoji); // `useRef` won't trigger a re-render, while `useState` does.
e.preventDefault(); // https://popper.js.org/react-popper/v2/
e.stopPropagation(); const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
};
const handleClickOutside = (event: MouseEvent) => {
if (referenceElement?.contains(event.target as Node) || popperElement?.contains(event.target as Node)) {
return;
}
if (onClose) {
onClose();
}
}; };
const { styles, attributes, update } = usePopper(referenceElement, popperElement, {
placement,
modifiers: [
{
name: 'offset',
options: {
offset,
},
},
],
});
const handleExpand: React.MouseEventHandler = () => {
setExpanded(true);
};
useEffect(() => {
setExpanded(false);
}, [visible]);
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [referenceElement]);
useEffect(() => {
if (visible && update) {
update();
}
}, [visible, update]);
return ( return (
<HStack <div
className={clsx('z-[999] w-max max-w-[100vw] flex-wrap gap-2 rounded-full bg-white p-3 shadow-md dark:bg-gray-900')} className={clsx('z-50 transition-opacity duration-100', {
'opacity-0 pointer-events-none': !visible,
})}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
> >
{Array.from(emojis).map((emoji, i) => ( {expanded ? (
<EmojiButton <Picker
key={i} set='twitter'
emoji={emoji} backgroundImageFn={() => require('emoji-datasource/img/twitter/sheets/32.png')}
onClick={handleReact(emoji)} onClick={(emoji: any) => onReact(emoji.native)}
tabIndex={(visible || focused) ? 0 : -1}
/> />
))} ) : (
</HStack> <HStack
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')}
>
{Array.from(soapboxConfig.allowedEmoji).map((emoji, i) => (
<EmojiButton
key={i}
emoji={emoji}
onClick={onReact}
tabIndex={visible ? 0 : -1}
/>
))}
{all && (
<IconButton
className='text-gray-600 hover:text-gray-600 dark:hover:text-white'
src={require('@tabler/icons/dots.svg')}
onClick={handleExpand}
/>
)}
</HStack>
)}
</div>
); );
}; };

View File

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

View File

@ -38,6 +38,7 @@ export {
} from './menu/menu'; } from './menu/menu';
export { default as Modal } from './modal/modal'; export { default as Modal } from './modal/modal';
export { default as PhoneInput } from './phone-input/phone-input'; export { default as PhoneInput } from './phone-input/phone-input';
export { default as Portal } from './portal/portal';
export { default as ProgressBar } from './progress-bar/progress-bar'; export { default as ProgressBar } from './progress-bar/progress-bar';
export { default as RadioButton } from './radio-button/radio-button'; export { default as RadioButton } from './radio-button/radio-button';
export { default as Select } from './select/select'; export { default as Select } from './select/select';

View File

@ -0,0 +1,31 @@
import React, { useLayoutEffect, useState } from 'react';
import ReactDOM from 'react-dom';
interface IPortal {
children: React.ReactNode
}
/**
* Portal
*/
const Portal: React.FC<IPortal> = ({ children }) => {
const [isRendered, setIsRendered] = useState<boolean>(false);
useLayoutEffect(() => {
setIsRendered(true);
}, []);
if (!isRendered) {
return null;
}
return (
ReactDOM.createPortal(
children,
document.getElementById('soapbox') as HTMLDivElement,
)
);
};
export default Portal;

View File

@ -142,4 +142,4 @@ const Toast = (props: IToast) => {
); );
}; };
export default Toast; export default Toast;

View File

@ -1,7 +1,8 @@
import { Portal } from '@reach/portal';
import { TooltipPopup, useTooltip } from '@reach/tooltip'; import { TooltipPopup, useTooltip } from '@reach/tooltip';
import React from 'react'; import React from 'react';
import Portal from '../portal/portal';
import './tooltip.css'; import './tooltip.css';
interface ITooltip { interface ITooltip {

View File

@ -1,38 +0,0 @@
import { connect } from 'react-redux';
import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown-menu';
import { openModal, closeModal } from '../actions/modals';
import DropdownMenu from '../components/dropdown-menu';
import { isUserTouching } from '../is-mobile';
import type { Dispatch } from 'redux';
import type { DropdownPlacement, IDropdown } from 'soapbox/components/dropdown-menu';
import type { RootState } from 'soapbox/store';
const mapStateToProps = (state: RootState) => ({
isModalOpen: Boolean(state.modals.size && state.modals.last()!.modalType === 'ACTIONS'),
dropdownPlacement: state.dropdown_menu.placement,
openDropdownId: state.dropdown_menu.openId,
openedViaKeyboard: state.dropdown_menu.keyboard,
});
const mapDispatchToProps = (dispatch: Dispatch, { status, items }: Partial<IDropdown>) => ({
onOpen(
id: number,
onItemClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
dropdownPlacement: DropdownPlacement,
keyboard: boolean,
) {
dispatch(isUserTouching() ? openModal('ACTIONS', {
status,
actions: items,
onClick: onItemClick,
}) : openDropdownMenu(id, dropdownPlacement, keyboard));
},
onClose(id: number) {
dispatch(closeModal('ACTIONS'));
dispatch(closeDropdownMenu(id));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);

View File

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

View File

@ -5,7 +5,7 @@ import { AxiosError } from 'axios';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import React from 'react'; import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link, useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { blockAccount, followAccount, pinAccount, removeFromFollowers, unblockAccount, unmuteAccount, unpinAccount } from 'soapbox/actions/accounts'; import { blockAccount, followAccount, pinAccount, removeFromFollowers, unblockAccount, unmuteAccount, unpinAccount } from 'soapbox/actions/accounts';
import { mentionCompose, directCompose } from 'soapbox/actions/compose'; import { mentionCompose, directCompose } from 'soapbox/actions/compose';
@ -16,9 +16,9 @@ import { initReport } from 'soapbox/actions/reports';
import { setSearchAccount } from 'soapbox/actions/search'; import { setSearchAccount } from 'soapbox/actions/search';
import { getSettings } from 'soapbox/actions/settings'; import { getSettings } from 'soapbox/actions/settings';
import Badge from 'soapbox/components/badge'; import Badge from 'soapbox/components/badge';
import DropdownMenu, { Menu } from 'soapbox/components/dropdown-menu';
import StillImage from 'soapbox/components/still-image'; import StillImage from 'soapbox/components/still-image';
import { Avatar, HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui'; import { Avatar, HStack, IconButton } from 'soapbox/components/ui';
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import MovedNote from 'soapbox/features/account-timeline/components/moved-note'; import MovedNote from 'soapbox/features/account-timeline/components/moved-note';
import ActionButton from 'soapbox/features/ui/components/action-button'; import ActionButton from 'soapbox/features/ui/components/action-button';
import SubscriptionButton from 'soapbox/features/ui/components/subscription-button'; import SubscriptionButton from 'soapbox/features/ui/components/subscription-button';
@ -31,8 +31,6 @@ import { Account } from 'soapbox/types/entities';
import { isDefaultHeader, isLocal, isRemote } from 'soapbox/utils/accounts'; import { isDefaultHeader, isLocal, isRemote } from 'soapbox/utils/accounts';
import { MASTODON, parseVersion } from 'soapbox/utils/features'; import { MASTODON, parseVersion } from 'soapbox/utils/features';
import type { Menu as MenuType } from 'soapbox/components/dropdown-menu';
const messages = defineMessages({ const messages = defineMessages({
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' }, linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' },
@ -275,7 +273,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
}; };
const makeMenu = () => { const makeMenu = () => {
const menu: MenuType = []; const menu: Menu = [];
if (!account) { if (!account) {
return []; return [];
@ -645,39 +643,15 @@ const Header: React.FC<IHeader> = ({ account }) => {
{renderShareButton()} {renderShareButton()}
{menu.length > 0 && ( {menu.length > 0 && (
<Menu> <DropdownMenu items={menu} placement='bottom-end'>
<MenuButton <IconButton
as={IconButton}
src={require('@tabler/icons/dots.svg')} src={require('@tabler/icons/dots.svg')}
theme='outlined' theme='outlined'
className='px-2' className='px-2'
iconClassName='w-4 h-4' iconClassName='w-4 h-4'
children={null} children={null}
/> />
</DropdownMenu>
<MenuList className='w-56'>
{menu.map((menuItem, idx) => {
if (typeof menuItem?.text === 'undefined') {
return <MenuDivider key={idx} />;
} else {
const Comp = (menuItem.action ? MenuItem : MenuLink) as any;
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' };
return (
<Comp key={idx} {...itemProps} className='group'>
<HStack space={3} alignItems='center'>
{menuItem.icon && (
<SvgIcon src={menuItem.icon} className='h-5 w-5 flex-none text-gray-400 group-hover:text-gray-500' />
)}
<div className='truncate'>{menuItem.text}</div>
</HStack>
</Comp>
);
}
})}
</MenuList>
</Menu>
)} )}
<ActionButton account={account} /> <ActionButton account={account} />

View File

@ -2,10 +2,10 @@ import React from 'react';
import { useIntl, defineMessages } from 'react-intl'; import { useIntl, defineMessages } from 'react-intl';
import { deleteStatusModal } from 'soapbox/actions/moderation'; import { deleteStatusModal } from 'soapbox/actions/moderation';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import StatusContent from 'soapbox/components/status-content'; import StatusContent from 'soapbox/components/status-content';
import StatusMedia from 'soapbox/components/status-media'; import StatusMedia from 'soapbox/components/status-media';
import { HStack, Stack } from 'soapbox/components/ui'; import { HStack, Stack } from 'soapbox/components/ui';
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
import { useAppDispatch } from 'soapbox/hooks'; import { useAppDispatch } from 'soapbox/hooks';
import type { AdminReport, Status } from 'soapbox/types/entities'; import type { AdminReport, Status } from 'soapbox/types/entities';

View File

@ -4,9 +4,9 @@ import { Link } from 'react-router-dom';
import { closeReports } from 'soapbox/actions/admin'; import { closeReports } from 'soapbox/actions/admin';
import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation'; import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper'; import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
import { Accordion, Avatar, Button, Stack, HStack, Text } from 'soapbox/components/ui'; import { Accordion, Avatar, Button, Stack, HStack, Text } from 'soapbox/components/ui';
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetReport } from 'soapbox/selectors'; import { makeGetReport } from 'soapbox/selectors';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';

View File

@ -4,9 +4,8 @@ import { defineMessages, useIntl } from 'react-intl';
import { expandUserIndex, fetchUserIndex, setUserIndexQuery } from 'soapbox/actions/admin'; import { expandUserIndex, fetchUserIndex, setUserIndexQuery } from 'soapbox/actions/admin';
import ScrollableList from 'soapbox/components/scrollable-list'; import ScrollableList from 'soapbox/components/scrollable-list';
import { Column } from 'soapbox/components/ui'; import { Column, Input } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container'; import AccountContainer from 'soapbox/containers/account-container';
import { SimpleForm, TextInput } from 'soapbox/features/forms';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
const messages = defineMessages({ const messages = defineMessages({
@ -22,7 +21,7 @@ const UserIndex: React.FC = () => {
const { isLoading, items, total, query, next } = useAppSelector((state) => state.admin_user_index); const { isLoading, items, total, query, next } = useAppSelector((state) => state.admin_user_index);
const handleLoadMore = () => { const handleLoadMore = () => {
dispatch(expandUserIndex()); if (!isLoading) dispatch(expandUserIndex());
}; };
const updateQuery = useCallback(debounce(() => { const updateQuery = useCallback(debounce(() => {
@ -31,25 +30,25 @@ const UserIndex: React.FC = () => {
const handleQueryChange: React.ChangeEventHandler<HTMLInputElement> = e => { const handleQueryChange: React.ChangeEventHandler<HTMLInputElement> = e => {
dispatch(setUserIndexQuery(e.target.value)); dispatch(setUserIndexQuery(e.target.value));
updateQuery();
}; };
useEffect(() => { useEffect(() => {
updateQuery(); updateQuery();
}, [query]); }, []);
const hasMore = items.count() < total && next !== null;
const hasMore = items.count() < total && !!next;
const showLoading = isLoading && items.isEmpty(); const showLoading = isLoading && items.isEmpty();
return ( return (
<Column label={intl.formatMessage(messages.heading)}> <Column label={intl.formatMessage(messages.heading)}>
<SimpleForm style={{ paddingBottom: 0 }}> <Input
<TextInput value={query}
value={query} onChange={handleQueryChange}
onChange={handleQueryChange} placeholder={intl.formatMessage(messages.searchPlaceholder)}
placeholder={intl.formatMessage(messages.searchPlaceholder)} />
/>
</SimpleForm>
<ScrollableList <ScrollableList
scrollKey='user-index' scrollKey='user-index'
hasMore={hasMore} hasMore={hasMore}

View File

@ -581,4 +581,4 @@ const Audio: React.FC<IAudio> = (props) => {
); );
}; };
export default Audio; export default Audio;

View File

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

View File

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

View File

@ -42,6 +42,7 @@ interface IChatComposer extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaEl
errorMessage: string | undefined errorMessage: string | undefined
onSelectFile: (files: FileList, intl: IntlShape) => void onSelectFile: (files: FileList, intl: IntlShape) => void
resetFileKey: number | null resetFileKey: number | null
resetContentKey: number | null
attachments?: Attachment[] attachments?: Attachment[]
onDeleteAttachment?: (i: number) => void onDeleteAttachment?: (i: number) => void
isUploading?: boolean isUploading?: boolean
@ -58,6 +59,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
disabled = false, disabled = false,
onSelectFile, onSelectFile,
resetFileKey, resetFileKey,
resetContentKey,
onPaste, onPaste,
attachments = [], attachments = [],
onDeleteAttachment, onDeleteAttachment,
@ -181,6 +183,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
<Stack grow> <Stack grow>
<Combobox onSelect={onSelectComboboxOption}> <Combobox onSelect={onSelectComboboxOption}>
<ComboboxInput <ComboboxInput
key={resetContentKey}
as={ChatTextarea} as={ChatTextarea}
autoFocus autoFocus
ref={ref} ref={ref}
@ -252,4 +255,4 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
); );
}); });
export default ChatComposer; export default ChatComposer;

View File

@ -3,10 +3,10 @@ import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import RelativeTimestamp from 'soapbox/components/relative-timestamp'; import RelativeTimestamp from 'soapbox/components/relative-timestamp';
import { Avatar, HStack, IconButton, Stack, Text } from 'soapbox/components/ui'; import { Avatar, HStack, IconButton, Stack, Text } from 'soapbox/components/ui';
import VerificationBadge from 'soapbox/components/verification-badge'; import VerificationBadge from 'soapbox/components/verification-badge';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import { useChatContext } from 'soapbox/contexts/chat-context'; import { useChatContext } from 'soapbox/contexts/chat-context';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { IChat, useChatActions } from 'soapbox/queries/chats'; import { IChat, useChatActions } from 'soapbox/queries/chats';
@ -115,14 +115,14 @@ const ChatListItem: React.FC<IChatListItemInterface> = ({ chat, onClick }) => {
<HStack alignItems='center' space={2}> <HStack alignItems='center' space={2}>
{features.chatsDelete && ( {features.chatsDelete && (
<div className='hidden text-gray-600 hover:text-gray-100 group-hover:block'> <div className='hidden text-gray-600 hover:text-gray-100 group-hover:block'>
<DropdownMenuContainer items={menu}> <DropdownMenu items={menu}>
<IconButton <IconButton
src={require('@tabler/icons/dots.svg')} src={require('@tabler/icons/dots.svg')}
title='Settings' title='Settings'
className='text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-500' className='text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-500'
iconClassName='w-4 h-4' iconClassName='w-4 h-4'
/> />
</DropdownMenuContainer> </DropdownMenu>
</div> </div>
)} )}

View File

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

View File

@ -0,0 +1,51 @@
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)}
offset={[-10, 12]}
all={false}
/>
</React.Fragment>
);
}
export default ChatMessageReactionWrapper;

View File

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

View File

@ -0,0 +1,388 @@
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 DropdownMenu from 'soapbox/components/dropdown-menu';
import { HStack, Icon, Stack, Text } from 'soapbox/components/ui';
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={
clsx({
'group relative px-4 py-2 hover:bg-gray-200/40 dark:hover:bg-gray-800/40': true,
'bg-gray-200/40 dark:bg-gray-800/40': isMenuOpen || isReactionSelectorOpen,
})
}
data-testid='chat-message'
>
<div
className={
clsx({
'p-1 flex items-center space-x-0.5 z-10 absolute opacity-0 transition-opacity group-hover:opacity-100 focus:opacity-100 rounded-md shadow-lg bg-white dark:bg-gray-900 dark:ring-2 dark:ring-primary-700': true,
'top-2 right-2': !isMyMessage,
'top-2 left-2': isMyMessage,
'!opacity-100': isMenuOpen || isReactionSelectorOpen,
})
}
>
{!features.chatEmojiReactions ? (
<ChatMessageReactionWrapper
onOpen={setIsReactionSelectorOpen}
onSelect={(emoji) => createReaction.mutate({ emoji, messageId: chatMessage.id, chatMessage })}
>
<button
title={intl.formatMessage(messages.more)}
className={clsx({
'p-1.5 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-md 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 focus:ring-0': true,
'!text-gray-700 dark:!text-gray-500': isReactionSelectorOpen,
})}
>
<Icon
src={require('@tabler/icons/mood-smile.svg')}
className='h-4 w-4'
/>
</button>
</ChatMessageReactionWrapper>
) : null}
{menu.length > 0 && (
<DropdownMenu
items={menu}
onOpen={() => setIsMenuOpen(true)}
onClose={() => setIsMenuOpen(false)}
>
<button
title={intl.formatMessage(messages.more)}
className={clsx({
'p-1.5 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-md 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 focus:ring-0': true,
'!text-gray-700 dark:!text-gray-500': isMenuOpen,
})}
data-testid='chat-message-menu'
>
<Icon
src={require('@tabler/icons/dots.svg')}
className='h-4 w-4'
/>
</button>
</DropdownMenu>
)}
</div>
<Stack
space={1.5}
className={clsx({
'ml-auto': isMyMessage,
})}
>
<HStack
alignItems='center'
justifyContent={isMyMessage ? 'end' : 'start'}
className={clsx({
'opacity-50': chatMessage.pending,
})}
>
<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>
);
};
export default ChatMessage;

View File

@ -59,6 +59,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
const [attachments, setAttachments] = useState<Attachment[]>([]); const [attachments, setAttachments] = useState<Attachment[]>([]);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [resetContentKey, setResetContentKey] = useState<number>(fileKeyGen());
const [resetFileKey, setResetFileKey] = useState<number>(fileKeyGen()); const [resetFileKey, setResetFileKey] = useState<number>(fileKeyGen());
const [errorMessage, setErrorMessage] = useState<string>(); const [errorMessage, setErrorMessage] = useState<string>();
@ -88,6 +89,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
setIsUploading(false); setIsUploading(false);
setUploadProgress(0); setUploadProgress(0);
setResetFileKey(fileKeyGen()); setResetFileKey(fileKeyGen());
setResetContentKey(fileKeyGen());
}; };
const sendMessage = () => { const sendMessage = () => {
@ -183,6 +185,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
errorMessage={errorMessage} errorMessage={errorMessage}
onSelectFile={handleFiles} onSelectFile={handleFiles}
resetFileKey={resetFileKey} resetFileKey={resetFileKey}
resetContentKey={resetContentKey}
onPaste={handlePaste} onPaste={handlePaste}
attachments={attachments} attachments={attachments}
onDeleteAttachment={handleRemoveFile} onDeleteAttachment={handleRemoveFile}

View File

@ -242,7 +242,10 @@ const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
<div className={clsx('privacy-dropdown', placement, { active: open })} onKeyDown={handleKeyDown} ref={node}> <div className={clsx('privacy-dropdown', placement, { active: open })} onKeyDown={handleKeyDown} ref={node}>
<div className={clsx('privacy-dropdown__value', { active: valueOption && options.indexOf(valueOption) === 0 })}> <div className={clsx('privacy-dropdown__value', { active: valueOption && options.indexOf(valueOption) === 0 })}>
<IconButton <IconButton
className='text-gray-600 hover:text-gray-700 dark:hover:text-gray-500' className={clsx({
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': !open,
'text-primary-500 hover:text-primary-600 dark:text-primary-500 dark:hover:text-primary-400': open,
})}
src={valueOption?.icon} src={valueOption?.icon}
title={intl.formatMessage(messages.change_privacy)} title={intl.formatMessage(messages.change_privacy)}
onClick={handleToggle} onClick={handleToggle}

View File

@ -1,3 +1,4 @@
import clsx from 'clsx';
import React from 'react'; import React from 'react';
import AttachmentThumbs from 'soapbox/components/attachment-thumbs'; import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
@ -8,12 +9,13 @@ import { isRtl } from 'soapbox/rtl';
import type { Status } from 'soapbox/types/entities'; import type { Status } from 'soapbox/types/entities';
interface IReplyIndicator { interface IReplyIndicator {
className?: string,
status?: Status, status?: Status,
onCancel?: () => void, onCancel?: () => void,
hideActions: boolean, hideActions: boolean,
} }
const ReplyIndicator: React.FC<IReplyIndicator> = ({ status, hideActions, onCancel }) => { const ReplyIndicator: React.FC<IReplyIndicator> = ({ className, status, hideActions, onCancel }) => {
const handleClick = () => { const handleClick = () => {
onCancel!(); onCancel!();
}; };
@ -33,7 +35,7 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ status, hideActions, onCanc
} }
return ( return (
<Stack space={2} className='rounded-lg bg-gray-100 p-4 dark:bg-gray-800'> <Stack space={2} className={clsx('rounded-lg bg-gray-100 p-4 dark:bg-gray-800', className)}>
<AccountContainer <AccountContainer
{...actions} {...actions}
id={status.getIn(['account', 'id']) as string} id={status.getIn(['account', 'id']) as string}

View File

@ -21,4 +21,4 @@ const Indicator: React.FC<IIndicator> = ({ state = 'inactive', size = 'sm' }) =>
); );
}; };
export default Indicator; export default Indicator;

View File

@ -152,14 +152,14 @@ const ProfileField: StreamfieldComponent<AccountCredentialsField> = ({ value, on
<HStack space={2} grow> <HStack space={2} grow>
<Input <Input
type='text' type='text'
outerClassName='w-2/5 flex-grow' outerClassName='w-2/5 grow'
value={value.name} value={value.name}
onChange={handleChange('name')} onChange={handleChange('name')}
placeholder={intl.formatMessage(messages.metaFieldLabel)} placeholder={intl.formatMessage(messages.metaFieldLabel)}
/> />
<Input <Input
type='text' type='text'
outerClassName='w-3/5 flex-grow' outerClassName='w-3/5 grow'
value={value.value} value={value.value}
onChange={handleChange('value')} onChange={handleChange('value')}
placeholder={intl.formatMessage(messages.metaFieldContent)} placeholder={intl.formatMessage(messages.metaFieldContent)}

View File

@ -1,4 +1,6 @@
// @ts-ignore no types
import Emoji from 'emoji-mart/dist-es/components/emoji/emoji'; import Emoji from 'emoji-mart/dist-es/components/emoji/emoji';
// @ts-ignore no types
import Picker from 'emoji-mart/dist-es/components/picker/picker'; import Picker from 'emoji-mart/dist-es/components/picker/picker';
export { export {

View File

@ -396,7 +396,7 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
return <MenuDivider key={idx} />; return <MenuDivider key={idx} />;
} else { } else {
const Comp = (menuItem.action ? MenuItem : MenuLink) as any; const Comp = (menuItem.action ? MenuItem : MenuLink) as any;
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' }; const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.target || '_self' };
return ( return (
<Comp key={idx} {...itemProps} className='group'> <Comp key={idx} {...itemProps} className='group'>

View File

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

View File

@ -103,71 +103,6 @@ export const SimpleInput: React.FC<ISimpleInput> = (props) => {
); );
}; };
interface ISimpleTextarea {
label?: React.ReactNode,
hint?: React.ReactNode,
value?: string,
onChange?: React.ChangeEventHandler<HTMLTextAreaElement>,
rows?: number,
name?: string,
maxLength?: number,
required?: boolean,
}
export const SimpleTextarea: React.FC<ISimpleTextarea> = (props) => {
const { hint, label, ...rest } = props;
const Input = label ? LabelTextarea : 'textarea';
return (
<InputContainer {...props}>
<Input {...rest} />
</InputContainer>
);
};
interface ISimpleForm {
className?: string,
onSubmit?: React.FormEventHandler,
acceptCharset?: string,
style?: React.CSSProperties,
children: React.ReactNode,
}
export const SimpleForm: React.FC<ISimpleForm> = (props) => {
const {
className,
children,
onSubmit = () => {},
acceptCharset = 'UTF-8',
...rest
} = props;
const handleSubmit: React.FormEventHandler = e => {
onSubmit(e);
e.preventDefault();
};
return (
<form
className={clsx('simple_form', className)}
method='post'
onSubmit={handleSubmit}
acceptCharset={acceptCharset}
{...rest}
>
{children}
</form>
);
};
interface IFieldsGroup {
children: React.ReactNode,
}
export const FieldsGroup: React.FC<IFieldsGroup> = ({ children }) => (
<div className='fields-group'>{children}</div>
);
interface ICheckbox { interface ICheckbox {
label?: React.ReactNode, label?: React.ReactNode,
hint?: React.ReactNode, hint?: React.ReactNode,

View File

@ -180,7 +180,7 @@ const GroupMember: React.FC<IGroupMember> = ({ accountId, accountRole, groupId,
return <MenuDivider key={idx} />; return <MenuDivider key={idx} />;
} else { } else {
const Comp = (menuItem.action ? MenuItem : MenuLink) as any; const Comp = (menuItem.action ? MenuItem : MenuLink) as any;
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' }; const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.target || '_self' };
return ( return (
<Comp key={idx} {...itemProps} className='group'> <Comp key={idx} {...itemProps} className='group'>

View File

@ -36,7 +36,7 @@ const ListForm = () => {
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<HStack space={2}> <HStack space={2}>
<Input <Input
outerClassName='flex-grow' outerClassName='grow'
type='text' type='text'
value={value} value={value}
onChange={handleChange} onChange={handleChange}

View File

@ -1,18 +0,0 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Icon from 'soapbox/components/icon';
interface IClearColumnButton {
onClick: React.MouseEventHandler<HTMLButtonElement>;
}
const ClearColumnButton: React.FC<IClearColumnButton> = ({ onClick }) => (
<button className='text-btn column-header__setting-btn' tabIndex={0} onClick={onClick}>
<Icon src={require('@tabler/icons/eraser.svg')} />
{' '}
<FormattedMessage id='notifications.clear' defaultMessage='Clear notifications' />
</button>
);
export default ClearColumnButton;

View File

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

View File

@ -78,7 +78,7 @@ const PlaceholderMediaGallery: React.FC<IPlaceholderMediaGallery> = ({ media, de
const float = dimensions.float as any || 'left'; const float = dimensions.float as any || 'left';
const position = dimensions.pos as any || 'relative'; const position = dimensions.pos as any || 'relative';
return <div key={i} className='media-gallery__item' style={{ position, float, left, top, right, bottom, height, width }} />; return <div key={i} className='media-gallery__item animate-pulse bg-primary-200' style={{ position, float, left, top, right, bottom, height, width }} />;
}; };
const sizeData = getSizeData(media.size); const sizeData = getSizeData(media.size);

View File

@ -1,58 +0,0 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { getSettings, changeSettingImmediate } from 'soapbox/actions/settings';
import List, { ListItem } from 'soapbox/components/list';
import { Card, CardBody, CardHeader, CardTitle } from 'soapbox/components/ui';
import { SimpleForm, SelectDropdown } from 'soapbox/features/forms';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
const messages = defineMessages({
mediaDisplay: { id: 'preferences.fields.media_display_label', defaultMessage: 'Media display' },
display_media_default: { id: 'preferences.fields.display_media.default', defaultMessage: 'Hide media marked as sensitive' },
display_media_hide_all: { id: 'preferences.fields.display_media.hide_all', defaultMessage: 'Always hide media' },
display_media_show_all: { id: 'preferences.fields.display_media.show_all', defaultMessage: 'Always show media' },
});
const MediaDisplay = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const settings = useAppSelector((state) => getSettings(state));
const displayMediaOptions = {
default: intl.formatMessage(messages.display_media_default),
hide_all: intl.formatMessage(messages.display_media_hide_all),
show_all: intl.formatMessage(messages.display_media_show_all),
};
const onSelectChange: (path: string[]) => React.ChangeEventHandler<HTMLSelectElement> = path => {
return e => {
dispatch(changeSettingImmediate(path, e.target.value));
};
};
return (
<Card variant='rounded'>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.mediaDisplay)} />
</CardHeader>
<CardBody>
<SimpleForm className='space-y-3 p-0'>
<List>
<ListItem label={intl.formatMessage(messages.mediaDisplay)}>
<SelectDropdown
items={displayMediaOptions}
defaultValue={settings.get('displayMedia') as string}
onChange={onSelectChange(['displayMedia'])}
/>
</ListItem>
</List>
</SimpleForm>
</CardBody>
</Card>
);
};
export default MediaDisplay;

View File

@ -25,21 +25,21 @@ const CryptoAddressInput: StreamfieldComponent<CryptoAddress> = ({ value, onChan
<HStack space={2} grow> <HStack space={2} grow>
<Input <Input
type='text' type='text'
outerClassName='w-1/6 flex-grow' outerClassName='w-1/6 grow'
value={value.ticker} value={value.ticker}
onChange={handleChange('ticker')} onChange={handleChange('ticker')}
placeholder={intl.formatMessage(messages.ticker)} placeholder={intl.formatMessage(messages.ticker)}
/> />
<Input <Input
type='text' type='text'
outerClassName='w-3/6 flex-grow' outerClassName='w-3/6 grow'
value={value.address} value={value.address}
onChange={handleChange('address')} onChange={handleChange('address')}
placeholder={intl.formatMessage(messages.address)} placeholder={intl.formatMessage(messages.address)}
/> />
<Input <Input
type='text' type='text'
outerClassName='w-2/6 flex-grow' outerClassName='w-2/6 grow'
value={value.note} value={value.note}
onChange={handleChange('note')} onChange={handleChange('note')}
placeholder={intl.formatMessage(messages.note)} placeholder={intl.formatMessage(messages.note)}

View File

@ -24,14 +24,14 @@ const PromoPanelInput: StreamfieldComponent<FooterItem> = ({ value, onChange })
<HStack space={2} grow> <HStack space={2} grow>
<Input <Input
type='text' type='text'
outerClassName='w-full flex-grow' outerClassName='w-full grow'
placeholder={intl.formatMessage(messages.label)} placeholder={intl.formatMessage(messages.label)}
value={value.title} value={value.title}
onChange={handleChange('title')} onChange={handleChange('title')}
/> />
<Input <Input
type='text' type='text'
outerClassName='w-full flex-grow' outerClassName='w-full grow'
placeholder={intl.formatMessage(messages.url)} placeholder={intl.formatMessage(messages.url)}
value={value.url} value={value.url}
onChange={handleChange('url')} onChange={handleChange('url')}

View File

@ -36,14 +36,14 @@ const PromoPanelInput: StreamfieldComponent<PromoPanelItem> = ({ value, onChange
<Input <Input
type='text' type='text'
outerClassName='w-full flex-grow' outerClassName='w-full grow'
placeholder={intl.formatMessage(messages.label)} placeholder={intl.formatMessage(messages.label)}
value={value.text} value={value.text}
onChange={handleChange('text')} onChange={handleChange('text')}
/> />
<Input <Input
type='text' type='text'
outerClassName='w-full flex-grow' outerClassName='w-full grow'
placeholder={intl.formatMessage(messages.url)} placeholder={intl.formatMessage(messages.url)}
value={value.url} value={value.url}
onChange={handleChange('url')} onChange={handleChange('url')}

View File

@ -1,4 +1,4 @@
import classnames from 'clsx'; import clsx from 'clsx';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
@ -112,7 +112,7 @@ const Card: React.FC<ICard> = ({
const interactive = card.type !== 'link'; const interactive = card.type !== 'link';
horizontal = typeof horizontal === 'boolean' ? horizontal : interactive || embedded; horizontal = typeof horizontal === 'boolean' ? horizontal : interactive || embedded;
const className = classnames('status-card', { horizontal, compact, interactive }, `status-card--${card.type}`); const className = clsx('status-card', { horizontal, compact, interactive }, `status-card--${card.type}`);
const ratio = getRatio(card); const ratio = getRatio(card);
const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio); const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
@ -223,7 +223,7 @@ const Card: React.FC<ICard> = ({
); );
} else if (card.image) { } else if (card.image) {
embed = ( embed = (
<div className={classnames( <div className={clsx(
'status-card__image', 'status-card__image',
'w-full flex-none rounded-l md:h-auto md:w-auto md:flex-auto', 'w-full flex-none rounded-l md:h-auto md:w-auto md:flex-auto',
{ {

View File

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

View File

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

View File

@ -5,9 +5,9 @@ import { v4 as uuidv4 } from 'uuid';
import { updateSoapboxConfig } from 'soapbox/actions/admin'; import { updateSoapboxConfig } from 'soapbox/actions/admin';
import { getHost } from 'soapbox/actions/instance'; import { getHost } from 'soapbox/actions/instance';
import { fetchSoapboxConfig } from 'soapbox/actions/soapbox'; import { fetchSoapboxConfig } from 'soapbox/actions/soapbox';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import List, { ListItem } from 'soapbox/components/list'; import List, { ListItem } from 'soapbox/components/list';
import { Button, Column, Form, FormActions } from 'soapbox/components/ui'; import { Button, Column, Form, FormActions } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import ColorWithPicker from 'soapbox/features/soapbox-config/components/color-with-picker'; import ColorWithPicker from 'soapbox/features/soapbox-config/components/color-with-picker';
import { useAppDispatch, useAppSelector, useSoapboxConfig } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useSoapboxConfig } from 'soapbox/hooks';
import { normalizeSoapboxConfig } from 'soapbox/normalizers'; import { normalizeSoapboxConfig } from 'soapbox/normalizers';
@ -194,7 +194,7 @@ const ThemeEditor: React.FC<IThemeEditor> = () => {
</List> </List>
<FormActions> <FormActions>
<DropdownMenuContainer <DropdownMenu
items={[{ items={[{
text: intl.formatMessage(messages.restore), text: intl.formatMessage(messages.restore),
action: restoreDefaultTheme, action: restoreDefaultTheme,

View File

@ -39,4 +39,4 @@ const FloatingActionButton: React.FC<IFloatingActionButton> = () => {
); );
}; };
export default FloatingActionButton; export default FloatingActionButton;

View File

@ -162,4 +162,4 @@ class ImageLoader extends React.PureComponent<IImageLoader> {
} }
export default ImageLoader; export default ImageLoader;

View File

@ -4,8 +4,8 @@ import React from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import { Widget } from 'soapbox/components/ui'; import { Widget } from 'soapbox/components/ui';
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
import InstanceRestrictions from 'soapbox/features/federation-restrictions/components/instance-restrictions'; import InstanceRestrictions from 'soapbox/features/federation-restrictions/components/instance-restrictions';
import { useAppSelector, useAppDispatch, useOwnAccount } from 'soapbox/hooks'; import { useAppSelector, useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import { makeGetRemoteInstance } from 'soapbox/selectors'; import { makeGetRemoteInstance } from 'soapbox/selectors';

View File

@ -4,9 +4,8 @@ import { FormattedMessage } from 'react-intl';
import { spring } from 'react-motion'; import { spring } from 'react-motion';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import StatusContent from 'soapbox/components/status-content'; import { HStack } from 'soapbox/components/ui';
import { HStack, Stack } from 'soapbox/components/ui'; import ReplyIndicator from 'soapbox/features/compose/components/reply-indicator';
import AccountContainer from 'soapbox/containers/account-container';
import Motion from '../../util/optional-motion'; import Motion from '../../util/optional-motion';
@ -26,7 +25,7 @@ const ActionsModal: React.FC<IActionsModal> = ({ status, actions, onClick, onClo
return <li key={`sep-${i}`} className='dropdown-menu__separator' />; return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
} }
const { icon = null, text, meta = null, active = false, href = '#', isLogout, destructive } = action; const { icon = null, text, meta = null, active = false, href = '#', destructive } = action;
const Comp = href === '#' ? 'button' : 'a'; const Comp = href === '#' ? 'button' : 'a';
const compProps = href === '#' ? { onClick: onClick } : { href: href, rel: 'noopener' }; const compProps = href === '#' ? { onClick: onClick } : { href: href, rel: 'noopener' };
@ -38,7 +37,6 @@ const ActionsModal: React.FC<IActionsModal> = ({ status, actions, onClick, onClo
space={2.5} space={2.5}
data-index={i} data-index={i}
className={clsx('w-full', { active, destructive })} className={clsx('w-full', { active, destructive })}
data-method={isLogout ? 'delete' : null}
element={Comp} element={Comp}
> >
{icon && <Icon title={text} src={icon} role='presentation' tabIndex={-1} />} {icon && <Icon title={text} src={icon} role='presentation' tabIndex={-1} />}
@ -56,16 +54,7 @@ const ActionsModal: React.FC<IActionsModal> = ({ status, actions, onClick, onClo
{({ top }) => ( {({ top }) => (
<div className='modal-root__modal actions-modal' style={{ top: `${top}%` }}> <div className='modal-root__modal actions-modal' style={{ top: `${top}%` }}>
{status && ( {status && (
<Stack space={2} className='border-b border-solid border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800'> <ReplyIndicator className='actions-modal__status rounded-b-none' status={status} hideActions />
<AccountContainer
key={status.account as string}
id={status.account as string}
showProfileHoverCard={false}
withLinkToProfile={false}
timestamp={status.created_at}
/>
<StatusContent status={status} />
</Stack>
)} )}
<ul className={clsx({ 'with-status': !!status })}> <ul className={clsx({ 'with-status': !!status })}>

View File

@ -3,7 +3,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { remoteInteraction } from 'soapbox/actions/interactions'; import { remoteInteraction } from 'soapbox/actions/interactions';
import { Button, Modal, Stack, Text } from 'soapbox/components/ui'; import { Button, Form, Input, Modal, Stack, Text } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch, useFeatures, useInstance, useRegistrationStatus } from 'soapbox/hooks'; import { useAppSelector, useAppDispatch, useFeatures, useInstance, useRegistrationStatus } from 'soapbox/hooks';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
@ -104,9 +104,8 @@ const UnauthorizedModal: React.FC<IUnauthorizedModal> = ({ action, onClose, acco
secondaryText={isOpen ? <FormattedMessage id='account.register' defaultMessage='Sign up' /> : undefined} secondaryText={isOpen ? <FormattedMessage id='account.register' defaultMessage='Sign up' /> : undefined}
> >
<div className='remote-interaction-modal__content'> <div className='remote-interaction-modal__content'>
<form className='simple_form remote-interaction-modal__fields' onSubmit={onSubmit}> <Form className='remote-interaction-modal__fields' onSubmit={onSubmit}>
<input <Input
type='text'
placeholder={intl.formatMessage(messages.accountPlaceholder)} placeholder={intl.formatMessage(messages.accountPlaceholder)}
name='remote_follow[acct]' name='remote_follow[acct]'
value={account} value={account}
@ -116,7 +115,7 @@ const UnauthorizedModal: React.FC<IUnauthorizedModal> = ({ action, onClose, acco
required required
/> />
<Button type='submit' theme='primary'>{button}</Button> <Button type='submit' theme='primary'>{button}</Button>
</form> </Form>
<div className='remote-interaction-modal__divider'> <div className='remote-interaction-modal__divider'>
<Text align='center'> <Text align='center'>
<FormattedMessage id='remote_interaction.divider' defaultMessage='or' /> <FormattedMessage id='remote_interaction.divider' defaultMessage='or' />

View File

@ -56,7 +56,7 @@ const UploadArea: React.FC<IUploadArea> = ({ active, onClose }) => {
<Stack space={3} justifyContent='center' alignItems='center'> <Stack space={3} justifyContent='center' alignItems='center'>
<Icon <Icon
src={require('@tabler/icons/cloud-upload.svg')} src={require('@tabler/icons/cloud-upload.svg')}
className='h-12 w-12 text-white text-opacity-90' className='h-12 w-12 text-white/90'
/> />
<Text size='xl' theme='white'> <Text size='xl' theme='white'>

View File

@ -72,7 +72,6 @@ import {
Lists, Lists,
Bookmarks, Bookmarks,
Settings, Settings,
MediaDisplay,
EditProfile, EditProfile,
EditEmail, EditEmail,
EditPassword, EditPassword,
@ -301,7 +300,6 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
<WrappedRoute path='/settings/email' page={DefaultPage} component={EditEmail} content={children} /> <WrappedRoute path='/settings/email' page={DefaultPage} component={EditEmail} content={children} />
<WrappedRoute path='/settings/password' page={DefaultPage} component={EditPassword} content={children} /> <WrappedRoute path='/settings/password' page={DefaultPage} component={EditPassword} content={children} />
<WrappedRoute path='/settings/account' page={DefaultPage} component={DeleteAccount} content={children} /> <WrappedRoute path='/settings/account' page={DefaultPage} component={DeleteAccount} content={children} />
<WrappedRoute path='/settings/media_display' page={DefaultPage} component={MediaDisplay} content={children} />
<WrappedRoute path='/settings/mfa' page={DefaultPage} component={MfaForm} exact /> <WrappedRoute path='/settings/mfa' page={DefaultPage} component={MfaForm} exact />
<WrappedRoute path='/settings/tokens' page={DefaultPage} component={AuthTokenList} content={children} /> <WrappedRoute path='/settings/tokens' page={DefaultPage} component={AuthTokenList} content={children} />
<WrappedRoute path='/settings' page={DefaultPage} component={Settings} content={children} /> <WrappedRoute path='/settings' page={DefaultPage} component={Settings} content={children} />
@ -357,7 +355,7 @@ const UI: React.FC<IUI> = ({ children }) => {
const features = useFeatures(); const features = useFeatures();
const vapidKey = useAppSelector(state => getVapidKey(state)); const vapidKey = useAppSelector(state => getVapidKey(state));
const dropdownMenuIsOpen = useAppSelector(state => state.dropdown_menu.openId !== null); const dropdownMenuIsOpen = useAppSelector(state => state.dropdown_menu.isOpen);
const accessToken = useAppSelector(state => getAccessToken(state)); const accessToken = useAppSelector(state => getAccessToken(state));
const streamingUrl = instance.urls.get('streaming_api'); const streamingUrl = instance.urls.get('streaming_api');
const standalone = useAppSelector(isStandalone); const standalone = useAppSelector(isStandalone);

View File

@ -238,10 +238,6 @@ export function Settings() {
return import(/* webpackChunkName: "features/settings" */'../../settings'); return import(/* webpackChunkName: "features/settings" */'../../settings');
} }
export function MediaDisplay() {
return import(/* webpackChunkName: "features/settings" */'../../settings/media-display');
}
export function EditProfile() { export function EditProfile() {
return import(/* webpackChunkName: "features/edit_profile" */'../../edit-profile'); return import(/* webpackChunkName: "features/edit_profile" */'../../edit-profile');
} }

View File

@ -51,21 +51,23 @@ const TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => {
}; };
return ( return (
<Provider store={props.store}> <div id='soapbox'>
<MemoryRouter {...routerProps}> <Provider store={props.store}>
<StatProvider> <MemoryRouter {...routerProps}>
<QueryClientProvider client={queryClient}> <StatProvider>
<ChatProvider> <QueryClientProvider client={queryClient}>
<IntlProvider locale={props.locale}> <ChatProvider>
{children} <IntlProvider locale={props.locale}>
{children}
<Toaster /> <Toaster />
</IntlProvider> </IntlProvider>
</ChatProvider> </ChatProvider>
</QueryClientProvider> </QueryClientProvider>
</StatProvider> </StatProvider>
</MemoryRouter> </MemoryRouter>
</Provider> </Provider>
</div>
); );
}; };

View File

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

View File

@ -706,8 +706,6 @@
"filters.context_header": "Filter contexts", "filters.context_header": "Filter contexts",
"filters.context_hint": "One or multiple contexts where the filter should apply", "filters.context_hint": "One or multiple contexts where the filter should apply",
"filters.filters_list_context_label": "Filter contexts:", "filters.filters_list_context_label": "Filter contexts:",
"filters.filters_list_delete": "Delete",
"filters.filters_list_details_label": "Filter settings:",
"filters.filters_list_drop": "Drop", "filters.filters_list_drop": "Drop",
"filters.filters_list_hide": "Hide", "filters.filters_list_hide": "Hide",
"filters.filters_list_phrase_label": "Keyword or phrase:", "filters.filters_list_phrase_label": "Keyword or phrase:",

View File

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

View File

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

View File

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

View File

@ -7,7 +7,9 @@ import {
import { normalizeAttachment } from 'soapbox/normalizers/attachment'; import { normalizeAttachment } from 'soapbox/normalizers/attachment';
import type { Attachment, Card, Emoji } from 'soapbox/types/entities'; import { normalizeEmojiReaction } from './emoji-reaction';
import type { Attachment, Card, Emoji, EmojiReaction } from 'soapbox/types/entities';
export const ChatMessageRecord = ImmutableRecord({ export const ChatMessageRecord = ImmutableRecord({
account_id: '', account_id: '',
@ -17,6 +19,8 @@ export const ChatMessageRecord = ImmutableRecord({
content: '', content: '',
created_at: '', created_at: '',
emojis: ImmutableList<Emoji>(), emojis: ImmutableList<Emoji>(),
expiration: null as number | null,
emoji_reactions: ImmutableList<EmojiReaction>(),
id: '', id: '',
unread: false, unread: false,
deleting: false, deleting: false,
@ -36,10 +40,21 @@ const normalizeMedia = (status: ImmutableMap<string, any>) => {
} }
}; };
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 chatMessage;
}
};
export const normalizeChatMessage = (chatMessage: Record<string, any>) => { export const normalizeChatMessage = (chatMessage: Record<string, any>) => {
return ChatMessageRecord( return ChatMessageRecord(
ImmutableMap(fromJS(chatMessage)).withMutations(chatMessage => { ImmutableMap(fromJS(chatMessage)).withMutations(chatMessage => {
normalizeMedia(chatMessage); normalizeMedia(chatMessage);
normalizeChatMessageEmojiReaction(chatMessage);
}), }),
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,9 +3,7 @@ import reducer from '../dropdown-menu';
describe('dropdown_menu reducer', () => { describe('dropdown_menu reducer', () => {
it('should return the initial state', () => { it('should return the initial state', () => {
expect(reducer(undefined, {} as any).toJS()).toEqual({ expect(reducer(undefined, {} as any).toJS()).toEqual({
openId: null, isOpen: false,
placement: null,
keyboard: false,
}); });
}); });
}); });

View File

@ -6,12 +6,9 @@ import {
} from '../actions/dropdown-menu'; } from '../actions/dropdown-menu';
import type { AnyAction } from 'redux'; import type { AnyAction } from 'redux';
import type { DropdownPlacement } from 'soapbox/components/dropdown-menu';
const ReducerRecord = ImmutableRecord({ const ReducerRecord = ImmutableRecord({
openId: null as number | null, isOpen: false,
placement: null as any as DropdownPlacement,
keyboard: false,
}); });
type State = ReturnType<typeof ReducerRecord>; type State = ReturnType<typeof ReducerRecord>;
@ -19,9 +16,9 @@ type State = ReturnType<typeof ReducerRecord>;
export default function dropdownMenu(state: State = ReducerRecord(), action: AnyAction) { export default function dropdownMenu(state: State = ReducerRecord(), action: AnyAction) {
switch (action.type) { switch (action.type) {
case DROPDOWN_MENU_OPEN: case DROPDOWN_MENU_OPEN:
return state.merge({ openId: action.id, placement: action.placement, keyboard: action.keyboard }); return state.set('isOpen', true);
case DROPDOWN_MENU_CLOSE: case DROPDOWN_MENU_CLOSE:
return state.openId === action.id ? state.set('openId', null) : state; return state.set('isOpen', false);
default: default:
return state; return state;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -248,6 +248,11 @@ const getInstanceFeatures = (instance: Instance) => {
*/ */
chatAcceptance: v.software === TRUTHSOCIAL, chatAcceptance: v.software === TRUTHSOCIAL,
/**
* Ability to add reactions to chat messages.
*/
chatEmojiReactions: false, // v.software === TRUTHSOCIAL,
/** /**
* Pleroma chats API. * Pleroma chats API.
* @see {@link https://docs.pleroma.social/backend/development/API/chats/} * @see {@link https://docs.pleroma.social/backend/development/API/chats/}
@ -374,10 +379,10 @@ const getInstanceFeatures = (instance: Instance) => {
emojiReacts: v.software === PLEROMA && gte(v.version, '2.0.0'), emojiReacts: v.software === PLEROMA && gte(v.version, '2.0.0'),
/** /**
* The backend allows only RGI ("Recommended for General Interchange") emoji reactions. * The backend allows only non-RGI ("Recommended for General Interchange") emoji reactions.
* @see PUT /api/v1/pleroma/statuses/:id/reactions/:emoji * @see PUT /api/v1/pleroma/statuses/:id/reactions/:emoji
*/ */
emojiReactsRGI: v.software === PLEROMA && gte(v.version, '2.2.49'), emojiReactsNonRGI: v.software === PLEROMA && lt(v.version, '2.2.49'),
/** /**
* Sign in with an Ethereum wallet. * Sign in with an Ethereum wallet.

View File

@ -1,34 +0,0 @@
// THEME MIXINS
// standard container drop shadow
@mixin standard-panel-shadow {
box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.1);
}
// common properties for all standard containers
@mixin standard-panel {
@include standard-panel-shadow;
border-radius: 10px;
background: var(--foreground-color);
}
// SHORTCUTS
@mixin input-placeholder($color) {
&::-webkit-input-placeholder { color: $color; }
&::-moz-placeholder { color: $color; }
&:-ms-input-placeholder { color: $color; }
&:-moz-placeholder { color: $color; }
}
@mixin avatar-radius {
border-radius: 50%;
background: transparent no-repeat;
background-position: 50%;
background-clip: padding-box;
}
@mixin avatar-size($size: 48px) {
width: $size;
height: $size;
background-size: $size $size;
}

View File

@ -1,5 +1,3 @@
@import 'mixins';
@import 'themes';
@import 'variables'; @import 'variables';
@import 'fonts'; @import 'fonts';
@import 'basics'; @import 'basics';
@ -9,15 +7,12 @@
@import 'rtl'; @import 'rtl';
@import 'accessibility'; @import 'accessibility';
@import 'navigation'; @import 'navigation';
@import 'placeholder';
@import 'autosuggest'; @import 'autosuggest';
// COMPONENTS // COMPONENTS
@import 'components/buttons'; @import 'components/buttons';
@import 'components/dropdown-menu';
@import 'components/modal'; @import 'components/modal';
@import 'components/compose-form'; @import 'components/compose-form';
@import 'components/emoji-reacts';
@import 'components/status'; @import 'components/status';
@import 'components/reply-mentions'; @import 'components/reply-mentions';
@import 'components/detailed-status'; @import 'components/detailed-status';
@ -29,7 +24,6 @@
@import 'components/react-toggle'; @import 'components/react-toggle';
@import 'components/video-player'; @import 'components/video-player';
@import 'components/audio-player'; @import 'components/audio-player';
@import 'components/filters';
@import 'components/crypto-donate'; @import 'components/crypto-donate';
@import 'components/aliases'; @import 'components/aliases';
@import 'components/icon'; @import 'components/icon';

View File

@ -1,8 +1,3 @@
.autosuggest-input {
position: relative;
}
.autosuggest-input input,
.react-datepicker__input-container input { .react-datepicker__input-container input {
// display: block; // display: block;
// box-sizing: border-box; // box-sizing: border-box;

View File

@ -8,64 +8,9 @@ body.with-modals {
@apply overflow-hidden; @apply overflow-hidden;
} }
body {
&.lighter {
background: var(--brand-color--med);
}
&.player {
text-align: center;
}
&.embed {
background: var(--brand-color--faint);
margin: 0;
padding-bottom: 0;
.container {
position: absolute;
width: 100%;
height: 100%;
overflow: hidden;
}
}
&.admin {
background: var(--brand-color--med);
position: fixed;
width: 100%;
height: 100%;
padding: 0;
}
&.error {
@apply text-gray-400;
position: absolute;
text-align: center;
background: var(--brand-color--med);
width: 100%;
height: 100%;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
.dialog {
vertical-align: middle;
margin: 20px;
h1 {
font-size: 20px;
line-height: 28px;
font-weight: 400;
}
}
}
}
// Note: this is needed for React HotKeys performance. Removing this // Note: this is needed for React HotKeys performance. Removing this
// will cause severe performance degradation on Safari. // will cause severe performance degradation on Safari.
div[tabindex="-1"]:focus { div[tabindex='-1']:focus {
outline: 0; outline: 0;
} }
@ -75,28 +20,6 @@ div[tabindex="-1"]:focus {
noscript { noscript {
text-align: center; text-align: center;
img {
width: 200px;
opacity: 0.5;
animation: flicker 4s infinite;
}
div {
@apply text-gray-400;
font-size: 14px;
margin: 30px auto;
max-width: 400px;
a {
color: var(--highlight-text-color);
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
}
} }
.emojione { .emojione {
@ -105,6 +28,6 @@ noscript {
// Virtuoso empty placeholder fix. // Virtuoso empty placeholder fix.
// https://gitlab.com/petyosi/soapbox-fe/-/commit/1e22c39934b60e5e186de804060ecfdf1955b506 // https://gitlab.com/petyosi/soapbox-fe/-/commit/1e22c39934b60e5e186de804060ecfdf1955b506
div[data-viewport-type="window"] { div[data-viewport-type='window'] {
position: static !important; position: static !important;
} }

View File

@ -1,69 +1,51 @@
.audio-player { .audio-player {
overflow: hidden; @apply relative box-border overflow-hidden rounded-[10px] bg-black pb-11;
box-sizing: border-box;
position: relative;
background: $base-shadow-color;
border-radius: 10px;
padding-bottom: 44px;
direction: ltr; direction: ltr;
&.editable { &.editable {
border-radius: 0; @apply rounded-none h-full;
height: 100%;
} }
.video-player__volume::before, .video-player__volume::before,
.video-player__seek::before { .video-player__seek::before {
background: currentcolor; @apply bg-current opacity-[15];
opacity: 0.15;
} }
.video-player__seek__buffer { .video-player__seek__buffer {
background: currentcolor; @apply bg-current opacity-20;
opacity: 0.2;
} }
.video-player__buttons button { .video-player__buttons button {
color: currentcolor; @apply text-current opacity-[75];
opacity: 0.75;
&:active, &:active,
&:hover, &:hover,
&:focus { &:focus {
color: currentcolor; @apply text-current opacity-100;
opacity: 1;
} }
} }
.video-player__time-sep, .video-player__time-sep,
.video-player__time-total, .video-player__time-total,
.video-player__time-current { .video-player__time-current {
color: currentcolor; @apply text-current;
} }
.video-player__seek::before, .video-player__seek::before,
.video-player__seek__buffer, .video-player__seek__buffer,
.video-player__seek__progress { .video-player__seek__progress {
top: 0; @apply top-0;
} }
.video-player__seek__handle { .video-player__seek__handle {
top: -4px; @apply -top-1;
} }
.video-player__controls { .video-player__controls {
padding-top: 10px; @apply pt-2.5 bg-transparent;
background: transparent;
} }
} }
.media-spoiler-audio { .media-spoiler-audio {
background-size: cover; @apply relative mt-2 block cursor-pointer border-0 bg-cover bg-center bg-no-repeat;
background-repeat: no-repeat;
background-position: center;
cursor: pointer;
margin-top: 8px;
position: relative;
border: 0;
display: block;
} }

View File

@ -1,79 +1,3 @@
.column {
width: 350px;
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
flex: 1 1 100%;
}
@media screen and (min-width: 631px) {
.column {
flex: 0 0 auto;
padding: 10px;
padding-left: 5px;
padding-right: 5px;
&:first-child {
padding-left: 10px;
}
&:last-child {
padding-right: 10px;
}
}
}
.column-link {
@apply text-gray-900;
background: var(--brand-color--med);
display: flex;
align-items: center;
font-size: 16px;
padding: 15px;
text-decoration: none;
&:hover,
&:focus,
&:active {
background: var(--brand-color--faint);
}
&:focus {
outline: 0;
}
&--transparent {
@apply bg-transparent;
color: var(--background-color);
&:hover,
&:focus,
&:active {
@apply text-gray-900 bg-transparent;
}
&.active {
color: var(--brand-color);
}
}
}
.svg-icon.column-link__icon {
display: inline-block;
margin-right: 5px;
}
.column-header__setting-btn {
&--link {
text-decoration: none;
}
&:hover {
@apply text-gray-400 underline;
}
}
.empty-column-indicator, .empty-column-indicator,
.error-column { .error-column {
@apply bg-primary-50 dark:bg-gray-700 text-gray-900 dark:text-gray-300 text-center p-10 flex flex-1 items-center justify-center min-h-[160px] rounded-lg; @apply bg-primary-50 dark:bg-gray-700 text-gray-900 dark:text-gray-300 text-center p-10 flex flex-1 items-center justify-center min-h-[160px] rounded-lg;

View File

@ -13,7 +13,6 @@
} }
a { a {
color: var(--brand-color--hicontrast);
font-weight: 500; font-weight: 500;
text-decoration: underline; text-decoration: underline;
@ -28,171 +27,153 @@
&__modifiers { &__modifiers {
@apply text-gray-900 text-sm; @apply text-gray-900 text-sm;
font-family: inherit; font-family: inherit;
background: var(--background-color);
} }
}
.compose-form__upload-wrapper { overflow: hidden; } &__upload-wrapper { overflow: hidden; }
.compose-form__uploads-wrapper { &__uploads-wrapper {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
&.contains-media { &.contains-media {
padding: 5px; padding: 5px;
border-top: 1px solid var(--foreground-color);
}
}
.compose-form__upload {
flex: 1 1 0;
min-width: 40%;
margin: 5px;
position: relative;
border-radius: 4px;
overflow: hidden;
&__actions {
@apply bg-gradient-to-b from-gray-900/80 via-gray-900/50 to-transparent flex items-start justify-between opacity-0 transition-opacity duration-100 ease-linear;
&.active {
@apply opacity-100;
}
.icon-button {
@apply text-gray-200 hover:text-white text-sm font-medium p-2.5 space-x-1 rtl:space-x-reverse flex items-center;
} }
} }
&-description { &__upload {
@apply bg-gradient-to-b from-transparent via-gray-900/50 to-gray-900/80 absolute z-[2px] bottom-0 left-0 right-0 p-2.5 opacity-0 transition-opacity duration-100 ease-linear; flex: 1 1 0;
min-width: 40%;
margin: 5px;
position: relative;
border-radius: 4px;
overflow: hidden;
&.active { &__actions {
@apply opacity-100; @apply bg-gradient-to-b from-gray-900/80 via-gray-900/50 to-transparent flex items-start justify-between opacity-0 transition-opacity duration-100 ease-linear;
}
textarea { &.active {
@apply bg-transparent text-white border-solid border border-white/25 p-2.5 rounded-md text-sm w-full m-0; @apply opacity-100;
&::placeholder {
@apply text-white/60;
} }
}
}
&-preview {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: -1;
video {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
.compose-form__upload-thumbnail {
background-position: center;
background-size: contain;
background-repeat: no-repeat;
height: 160px;
width: 100%;
overflow: hidden;
position: relative;
&.video {
background-image: url('../assets/images/video-placeholder.png');
background-size: cover;
}
&.audio {
background-image: url('../assets/images/audio-placeholder.png');
background-size: cover;
}
}
.privacy-dropdown__dropdown {
@apply absolute bg-white dark:bg-gray-900 z-[1000] rounded-md shadow-lg ml-10 text-sm;
&.top {
transform-origin: 50% 100%;
}
&.bottom {
transform-origin: 50% 0;
}
}
.privacy-dropdown__option {
@apply flex p-2.5 text-sm text-gray-700 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer;
&.active {
@apply bg-gray-100 dark:bg-gray-800;
}
&:hover,
&.active {
.privacy-dropdown__option__content,
.privacy-dropdown__option__content strong {
@apply text-black dark:text-white;
}
}
&.active {
@apply hover:bg-gray-200 dark:hover:bg-gray-700;
}
}
.privacy-dropdown__option__icon {
@apply flex items-center justify-center mr-2.5;
}
.privacy-dropdown__option__content {
@apply flex-auto text-primary-600 dark:text-primary-400;
strong {
@apply block font-medium text-black dark:text-white;
@each $lang in $cjk-langs {
&:lang(#{$lang}) {
@apply font-bold;
}
}
}
}
.privacy-dropdown.active {
.privacy-dropdown__value {
background: var(--foreground-color);
border-radius: 4px 4px 0 0;
box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);
.icon-button {
transition: none;
}
&.active {
background: var(--brand-color);
.icon-button { .icon-button {
@apply text-gray-900; @apply text-gray-200 hover:text-white text-sm font-medium p-2.5 space-x-1 rtl:space-x-reverse flex items-center;
}
}
&-description {
@apply bg-gradient-to-b from-transparent via-gray-900/50 to-gray-900/80 absolute z-[2px] bottom-0 left-0 right-0 p-2.5 opacity-0 transition-opacity duration-100 ease-linear;
&.active {
@apply opacity-100;
}
textarea {
@apply bg-transparent text-white border-solid border border-white/25 p-2.5 rounded-md text-sm w-full m-0;
&::placeholder {
@apply text-white/60;
}
}
}
&-preview {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: -1;
video {
width: 100%;
height: 100%;
object-fit: cover;
} }
} }
} }
&.top .privacy-dropdown__value { &__upload-thumbnail {
@apply rounded-t-md; background-position: center;
} background-size: contain;
background-repeat: no-repeat;
height: 160px;
width: 100%;
overflow: hidden;
position: relative;
.privacy-dropdown__dropdown { &.video {
@apply block shadow-md; background-image: url('../assets/images/video-placeholder.png');
background-size: cover;
}
&.audio {
background-image: url('../assets/images/audio-placeholder.png');
background-size: cover;
}
}
}
.privacy-dropdown {
&.active {
&.top .privacy-dropdown__value {
@apply rounded-t-md;
}
.privacy-dropdown__dropdown {
@apply block shadow-md;
}
}
&__dropdown {
@apply absolute bg-white dark:bg-gray-900 z-[1000] rounded-md shadow-lg ml-10 text-sm overflow-hidden;
&.top {
transform-origin: 50% 100%;
}
&.bottom {
transform-origin: 50% 0;
}
}
&__option {
@apply flex p-2.5 text-sm text-gray-700 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer;
&.active {
@apply bg-gray-100 dark:bg-gray-800;
}
&:hover,
&.active {
.privacy-dropdown__option__content,
.privacy-dropdown__option__content strong {
@apply text-black dark:text-white;
}
}
&.active {
@apply hover:bg-gray-200 dark:hover:bg-gray-700;
}
&__icon {
@apply flex items-center justify-center mr-2.5 rtl:mr-0 rtl:ml-2.5;
}
&__content {
@apply flex-auto text-primary-600 dark:text-primary-400;
strong {
@apply block font-medium text-black dark:text-white;
@each $lang in $cjk-langs {
&:lang(#{$lang}) {
@apply font-bold;
}
}
}
}
} }
} }

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