sforkowany z mirror/soapbox
Porównaj commity
22 Commity
develop
...
gleasonato
Autor | SHA1 | Data |
---|---|---|
![]() |
bdb8ce8004 | |
![]() |
5a2ca1ef34 | |
![]() |
2aff9c9091 | |
![]() |
f730897323 | |
![]() |
50457d3b8d | |
![]() |
6eadaf2942 | |
![]() |
64f17ef013 | |
![]() |
072e058764 | |
![]() |
ef5001d38b | |
![]() |
81bb8b933a | |
![]() |
f68ce9c47b | |
![]() |
0631657278 | |
![]() |
9ac2764bfa | |
![]() |
dc597ac765 | |
![]() |
ec7f9b9950 | |
![]() |
53a930c75c | |
![]() |
7f6b19aa4a | |
![]() |
aa8f84d352 | |
![]() |
f6f3973eac | |
![]() |
a433d22ba3 | |
![]() |
ab8d162f03 | |
![]() |
cb74b0a37c |
|
@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- Posts: don't have to click the play button twice for embedded videos.
|
- Posts: don't have to click the play button twice for embedded videos.
|
||||||
- index.html: remove `referrer` meta tag so it doesn't conflict with backend's `Referrer-Policy` header.
|
- index.html: remove `referrer` meta tag so it doesn't conflict with backend's `Referrer-Policy` header.
|
||||||
- Modals: fix media modal automatically switching to video.
|
- Modals: fix media modal automatically switching to video.
|
||||||
|
- Navigation: profile dropdown erratic behavior.
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
- Admin: single user mode. Now the homepage can be redirected to any URL.
|
- Admin: single user mode. Now the homepage can be redirected to any URL.
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import React from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||||
import { Link, useHistory } from 'react-router-dom';
|
import { Link, useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
|
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
|
||||||
import VerificationBadge from 'soapbox/components/verification-badge';
|
import VerificationBadge from 'soapbox/components/verification-badge';
|
||||||
import ActionButton from 'soapbox/features/ui/components/action-button';
|
import ActionButton from 'soapbox/features/ui/components/action-button';
|
||||||
import { useAppSelector, useOnScreen } from 'soapbox/hooks';
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
import { getAcct } from 'soapbox/utils/accounts';
|
import { getAcct } from 'soapbox/utils/accounts';
|
||||||
import { displayFqn } from 'soapbox/utils/state';
|
import { displayFqn } from 'soapbox/utils/state';
|
||||||
|
|
||||||
|
@ -117,19 +117,14 @@ const Account = ({
|
||||||
emoji,
|
emoji,
|
||||||
note,
|
note,
|
||||||
}: IAccount) => {
|
}: IAccount) => {
|
||||||
const overflowRef = React.useRef<HTMLDivElement>(null);
|
const overflowRef = useRef<HTMLDivElement>(null);
|
||||||
const actionRef = React.useRef<HTMLDivElement>(null);
|
const actionRef = useRef<HTMLDivElement>(null);
|
||||||
// @ts-ignore
|
|
||||||
const isOnScreen = useOnScreen(overflowRef);
|
|
||||||
|
|
||||||
const [style, setStyle] = React.useState<React.CSSProperties>({ visibility: 'hidden' });
|
|
||||||
|
|
||||||
const me = useAppSelector((state) => state.me);
|
const me = useAppSelector((state) => state.me);
|
||||||
const username = useAppSelector((state) => account ? getAcct(account, displayFqn(state)) : null);
|
const username = useAppSelector((state) => account ? getAcct(account, displayFqn(state)) : null);
|
||||||
|
|
||||||
const handleAction = () => {
|
const handleAction = () => {
|
||||||
// @ts-ignore
|
onActionClick!(account);
|
||||||
onActionClick(account);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderAction = () => {
|
const renderAction = () => {
|
||||||
|
@ -162,19 +157,6 @@ const Account = ({
|
||||||
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const style: React.CSSProperties = {};
|
|
||||||
const actionWidth = actionRef.current?.clientWidth || 0;
|
|
||||||
|
|
||||||
if (overflowRef.current) {
|
|
||||||
style.maxWidth = overflowRef.current.clientWidth - 30 - avatarSize - actionWidth;
|
|
||||||
} else {
|
|
||||||
style.visibility = 'hidden';
|
|
||||||
}
|
|
||||||
|
|
||||||
setStyle(style);
|
|
||||||
}, [isOnScreen, overflowRef, actionRef]);
|
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -195,7 +177,7 @@ const Account = ({
|
||||||
return (
|
return (
|
||||||
<div data-testid='account' className='group block w-full shrink-0' ref={overflowRef}>
|
<div data-testid='account' className='group block w-full shrink-0' ref={overflowRef}>
|
||||||
<HStack alignItems={actionAlignment} justifyContent='between'>
|
<HStack alignItems={actionAlignment} justifyContent='between'>
|
||||||
<HStack alignItems={withAccountNote || note ? 'top' : 'center'} space={3}>
|
<HStack alignItems={withAccountNote || note ? 'top' : 'center'} space={3} className='overflow-hidden'>
|
||||||
<ProfilePopper
|
<ProfilePopper
|
||||||
condition={showProfileHoverCard}
|
condition={showProfileHoverCard}
|
||||||
wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>}
|
wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>}
|
||||||
|
@ -215,7 +197,7 @@ const Account = ({
|
||||||
</LinkEl>
|
</LinkEl>
|
||||||
</ProfilePopper>
|
</ProfilePopper>
|
||||||
|
|
||||||
<div className='grow'>
|
<div className='grow overflow-hidden'>
|
||||||
<ProfilePopper
|
<ProfilePopper
|
||||||
condition={showProfileHoverCard}
|
condition={showProfileHoverCard}
|
||||||
wrapper={(children) => <HoverRefWrapper accountId={account.id} inline>{children}</HoverRefWrapper>}
|
wrapper={(children) => <HoverRefWrapper accountId={account.id} inline>{children}</HoverRefWrapper>}
|
||||||
|
@ -225,7 +207,7 @@ const Account = ({
|
||||||
title={account.acct}
|
title={account.acct}
|
||||||
onClick={(event: React.MouseEvent) => event.stopPropagation()}
|
onClick={(event: React.MouseEvent) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<HStack space={1} alignItems='center' grow style={style}>
|
<HStack space={1} alignItems='center' grow>
|
||||||
<Text
|
<Text
|
||||||
size='sm'
|
size='sm'
|
||||||
weight='semibold'
|
weight='semibold'
|
||||||
|
@ -241,7 +223,7 @@ const Account = ({
|
||||||
</ProfilePopper>
|
</ProfilePopper>
|
||||||
|
|
||||||
<Stack space={withAccountNote || note ? 1 : 0}>
|
<Stack space={withAccountNote || note ? 1 : 0}>
|
||||||
<HStack alignItems='center' space={1} style={style}>
|
<HStack alignItems='center' space={1}>
|
||||||
<Text theme='muted' size='sm' direction='ltr' truncate>@{username}</Text>
|
<Text theme='muted' size='sm' direction='ltr' truncate>@{username}</Text>
|
||||||
|
|
||||||
{account.favicon && (
|
{account.favicon && (
|
||||||
|
|
|
@ -31,7 +31,3 @@ div:focus[data-reach-menu-list] {
|
||||||
[data-reach-menu-link][data-disabled] {
|
[data-reach-menu-link][data-disabled] {
|
||||||
@apply opacity-25 cursor-default;
|
@apply opacity-25 cursor-default;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-reach-menu-popover] hr {
|
|
||||||
@apply my-1 mx-2 border-t-2 border-gray-100 dark:border-gray-800;
|
|
||||||
}
|
|
||||||
|
|
|
@ -37,6 +37,6 @@ const MenuList: React.FC<IMenuList> = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Divides menu items. */
|
/** Divides menu items. */
|
||||||
const MenuDivider = () => <hr />;
|
const MenuDivider = () => <hr className='my-1 mx-2 border-t-2 border-gray-100 dark:border-gray-800' />;
|
||||||
|
|
||||||
export { Menu, MenuButton, MenuDivider, MenuItems, MenuItem, MenuList, MenuLink };
|
export { Menu, MenuButton, MenuDivider, MenuItems, MenuItem, MenuList, MenuLink };
|
||||||
|
|
|
@ -43,7 +43,7 @@ interface IChatComposer extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaEl
|
||||||
onSelectFile: (files: FileList, intl: IntlShape) => void
|
onSelectFile: (files: FileList, intl: IntlShape) => void
|
||||||
resetFileKey: number | null
|
resetFileKey: number | null
|
||||||
attachments?: Attachment[]
|
attachments?: Attachment[]
|
||||||
onDeleteAttachment?: () => void
|
onDeleteAttachment?: (i: number) => void
|
||||||
isUploading?: boolean
|
isUploading?: boolean
|
||||||
uploadProgress?: number
|
uploadProgress?: number
|
||||||
}
|
}
|
||||||
|
@ -73,13 +73,14 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
||||||
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 isBlocking = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocking']));
|
const isBlocking = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocking']));
|
||||||
const maxCharacterCount = useAppSelector((state) => state.instance.getIn(['configuration', 'chats', 'max_characters']) as number);
|
const maxCharacterCount = useAppSelector((state) => state.instance.getIn(['configuration', 'chats', 'max_characters']) as number);
|
||||||
|
const attachmentLimit = useAppSelector(state => state.instance.configuration.getIn(['chats', 'max_media_attachments']) as number);
|
||||||
|
|
||||||
const [suggestions, setSuggestions] = useState<Suggestion>(initialSuggestionState);
|
const [suggestions, setSuggestions] = useState<Suggestion>(initialSuggestionState);
|
||||||
const isSuggestionsAvailable = suggestions.list.length > 0;
|
const isSuggestionsAvailable = suggestions.list.length > 0;
|
||||||
|
|
||||||
const hasAttachment = attachments.length > 0;
|
const hasAttachment = attachments.length > 0;
|
||||||
const isOverCharacterLimit = maxCharacterCount && value?.length > maxCharacterCount;
|
const isOverCharacterLimit = maxCharacterCount && value?.length > maxCharacterCount;
|
||||||
const isSubmitDisabled = disabled || isOverCharacterLimit || (value.length === 0 && !hasAttachment);
|
const isSubmitDisabled = disabled || isUploading || isOverCharacterLimit || (value.length === 0 && !hasAttachment);
|
||||||
|
|
||||||
const overLimitText = maxCharacterCount ? maxCharacterCount - value?.length : '';
|
const overLimitText = maxCharacterCount ? maxCharacterCount - value?.length : '';
|
||||||
|
|
||||||
|
@ -172,6 +173,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
||||||
resetFileKey={resetFileKey}
|
resetFileKey={resetFileKey}
|
||||||
iconClassName='w-5 h-5'
|
iconClassName='w-5 h-5'
|
||||||
className='text-primary-500'
|
className='text-primary-500'
|
||||||
|
disabled={isUploading || (attachments.length >= attachmentLimit)}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Textarea } from 'soapbox/components/ui';
|
import { HStack, Textarea } from 'soapbox/components/ui';
|
||||||
import { Attachment } from 'soapbox/types/entities';
|
import { Attachment } from 'soapbox/types/entities';
|
||||||
|
|
||||||
import ChatPendingUpload from './chat-pending-upload';
|
import ChatPendingUpload from './chat-pending-upload';
|
||||||
|
@ -8,7 +8,7 @@ import ChatUpload from './chat-upload';
|
||||||
|
|
||||||
interface IChatTextarea extends React.ComponentProps<typeof Textarea> {
|
interface IChatTextarea extends React.ComponentProps<typeof Textarea> {
|
||||||
attachments?: Attachment[]
|
attachments?: Attachment[]
|
||||||
onDeleteAttachment?: () => void
|
onDeleteAttachment?: (i: number) => void
|
||||||
isUploading?: boolean
|
isUploading?: boolean
|
||||||
uploadProgress?: number
|
uploadProgress?: number
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,14 @@ const ChatTextarea: React.FC<IChatTextarea> = ({
|
||||||
uploadProgress = 0,
|
uploadProgress = 0,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
|
const handleDeleteAttachment = (i: number) => {
|
||||||
|
return () => {
|
||||||
|
if (onDeleteAttachment) {
|
||||||
|
onDeleteAttachment(i);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`
|
<div className={`
|
||||||
block
|
block
|
||||||
|
@ -35,19 +43,23 @@ const ChatTextarea: React.FC<IChatTextarea> = ({
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{(!!attachments?.length || isUploading) && (
|
{(!!attachments?.length || isUploading) && (
|
||||||
<div className='flex p-3 pb-0'>
|
<HStack className='-ml-2 -mt-2 p-3 pb-0' wrap>
|
||||||
{isUploading && (
|
{attachments?.map((attachment, i) => (
|
||||||
<ChatPendingUpload progress={uploadProgress} />
|
<div className='ml-2 mt-2 flex'>
|
||||||
)}
|
<ChatUpload
|
||||||
|
key={attachment.id}
|
||||||
{attachments?.map(attachment => (
|
attachment={attachment}
|
||||||
<ChatUpload
|
onDelete={handleDeleteAttachment(i)}
|
||||||
key={attachment.id}
|
/>
|
||||||
attachment={attachment}
|
</div>
|
||||||
onDelete={onDeleteAttachment}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
|
{isUploading && (
|
||||||
|
<div className='ml-2 mt-2 flex'>
|
||||||
|
<ChatPendingUpload progress={uploadProgress} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Textarea theme='transparent' {...rest} />
|
<Textarea theme='transparent' {...rest} />
|
||||||
|
|
|
@ -21,6 +21,7 @@ const ChatUploadPreview: React.FC<IChatUploadPreview> = ({ className, attachment
|
||||||
|
|
||||||
switch (attachment.type) {
|
switch (attachment.type) {
|
||||||
case 'image':
|
case 'image':
|
||||||
|
case 'gifv':
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
className='pointer-events-none h-full w-full object-cover'
|
className='pointer-events-none h-full w-full object-cover'
|
||||||
|
|
|
@ -5,17 +5,21 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { uploadMedia } from 'soapbox/actions/media';
|
import { uploadMedia } from 'soapbox/actions/media';
|
||||||
import { Stack } from 'soapbox/components/ui';
|
import { Stack } from 'soapbox/components/ui';
|
||||||
import { useAppDispatch } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
import { normalizeAttachment } from 'soapbox/normalizers';
|
import { normalizeAttachment } from 'soapbox/normalizers';
|
||||||
import { IChat, useChatActions } from 'soapbox/queries/chats';
|
import { IChat, useChatActions } from 'soapbox/queries/chats';
|
||||||
|
import toast from 'soapbox/toast';
|
||||||
|
|
||||||
import ChatComposer from './chat-composer';
|
import ChatComposer from './chat-composer';
|
||||||
import ChatMessageList from './chat-message-list';
|
import ChatMessageList from './chat-message-list';
|
||||||
|
|
||||||
|
import type { Attachment } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const fileKeyGen = (): number => Math.floor((Math.random() * 0x10000));
|
const fileKeyGen = (): number => Math.floor((Math.random() * 0x10000));
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
failedToSend: { id: 'chat.failed_to_send', defaultMessage: 'Message failed to send.' },
|
failedToSend: { id: 'chat.failed_to_send', defaultMessage: 'Message failed to send.' },
|
||||||
|
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ChatInterface {
|
interface ChatInterface {
|
||||||
|
@ -49,18 +53,19 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const { createChatMessage, acceptChat } = useChatActions(chat.id);
|
const { createChatMessage, acceptChat } = useChatActions(chat.id);
|
||||||
|
const attachmentLimit = useAppSelector(state => state.instance.configuration.getIn(['chats', 'max_media_attachments']) as number);
|
||||||
|
|
||||||
const [content, setContent] = useState<string>('');
|
const [content, setContent] = useState<string>('');
|
||||||
const [attachment, setAttachment] = useState<any>(undefined);
|
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 [resetFileKey, setResetFileKey] = useState<number>(fileKeyGen());
|
const [resetFileKey, setResetFileKey] = useState<number>(fileKeyGen());
|
||||||
const [errorMessage, setErrorMessage] = useState<string>();
|
const [errorMessage, setErrorMessage] = useState<string>();
|
||||||
|
|
||||||
const isSubmitDisabled = content.length === 0 && !attachment;
|
const isSubmitDisabled = content.length === 0 && attachments.length === 0;
|
||||||
|
|
||||||
const submitMessage = () => {
|
const submitMessage = () => {
|
||||||
createChatMessage.mutate({ chatId: chat.id, content, mediaId: attachment?.id }, {
|
createChatMessage.mutate({ chatId: chat.id, content, mediaIds: attachments.map(a => a.id) }, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setErrorMessage(undefined);
|
setErrorMessage(undefined);
|
||||||
},
|
},
|
||||||
|
@ -79,7 +84,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
||||||
clearNativeInputValue(inputRef.current);
|
clearNativeInputValue(inputRef.current);
|
||||||
}
|
}
|
||||||
setContent('');
|
setContent('');
|
||||||
setAttachment(undefined);
|
setAttachments([]);
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
setResetFileKey(fileKeyGen());
|
setResetFileKey(fileKeyGen());
|
||||||
|
@ -126,8 +131,10 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
||||||
|
|
||||||
const handleMouseOver = () => markRead();
|
const handleMouseOver = () => markRead();
|
||||||
|
|
||||||
const handleRemoveFile = () => {
|
const handleRemoveFile = (i: number) => {
|
||||||
setAttachment(undefined);
|
const newAttachments = [...attachments];
|
||||||
|
newAttachments.splice(i, 1);
|
||||||
|
setAttachments(newAttachments);
|
||||||
setResetFileKey(fileKeyGen());
|
setResetFileKey(fileKeyGen());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -137,13 +144,18 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFiles = (files: FileList) => {
|
const handleFiles = (files: FileList) => {
|
||||||
|
if (files.length + attachments.length > attachmentLimit) {
|
||||||
|
toast.error(messages.uploadErrorLimit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
|
||||||
const data = new FormData();
|
const data = new FormData();
|
||||||
data.append('file', files[0]);
|
data.append('file', files[0]);
|
||||||
|
|
||||||
dispatch(uploadMedia(data, onUploadProgress)).then((response: any) => {
|
dispatch(uploadMedia(data, onUploadProgress)).then((response: any) => {
|
||||||
setAttachment(normalizeAttachment(response.data));
|
setAttachments([...attachments, normalizeAttachment(response.data)]);
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
|
@ -172,7 +184,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
||||||
onSelectFile={handleFiles}
|
onSelectFile={handleFiles}
|
||||||
resetFileKey={resetFileKey}
|
resetFileKey={resetFileKey}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
attachments={attachment ? [attachment] : []}
|
attachments={attachments}
|
||||||
onDeleteAttachment={handleRemoveFile}
|
onDeleteAttachment={handleRemoveFile}
|
||||||
isUploading={isUploading}
|
isUploading={isUploading}
|
||||||
uploadProgress={uploadProgress}
|
uploadProgress={uploadProgress}
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
|
import { useFloating } from '@floating-ui/react';
|
||||||
|
import clsx from 'clsx';
|
||||||
import throttle from 'lodash/throttle';
|
import throttle from 'lodash/throttle';
|
||||||
import React from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { fetchOwnAccounts, logOut, switchAccount } from 'soapbox/actions/auth';
|
import { fetchOwnAccounts, logOut, switchAccount } from 'soapbox/actions/auth';
|
||||||
import Account from 'soapbox/components/account';
|
import Account from 'soapbox/components/account';
|
||||||
import { Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
|
import { MenuDivider } from 'soapbox/components/ui';
|
||||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector, useClickOutside, useFeatures } from 'soapbox/hooks';
|
||||||
import { makeGetAccount } from 'soapbox/selectors';
|
import { makeGetAccount } from 'soapbox/selectors';
|
||||||
|
|
||||||
import ThemeToggle from './theme-toggle';
|
import ThemeToggle from './theme-toggle';
|
||||||
|
@ -39,6 +41,8 @@ const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const { x, y, strategy, refs } = useFloating<HTMLButtonElement>({ placement: 'bottom-end' });
|
||||||
const authUsers = useAppSelector((state) => state.auth.users);
|
const authUsers = useAppSelector((state) => state.auth.users);
|
||||||
const otherAccounts = useAppSelector((state) => authUsers.map((authUser: any) => getAccount(state, authUser.id)!));
|
const otherAccounts = useAppSelector((state) => authUsers.map((authUser: any) => getAccount(state, authUser.id)!));
|
||||||
|
|
||||||
|
@ -62,7 +66,7 @@ const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const menu: IMenuItem[] = React.useMemo(() => {
|
const menu: IMenuItem[] = useMemo(() => {
|
||||||
const menu: IMenuItem[] = [];
|
const menu: IMenuItem[] = [];
|
||||||
|
|
||||||
menu.push({ text: renderAccount(account), to: `/@${account.acct}` });
|
menu.push({ text: renderAccount(account), to: `/@${account.acct}` });
|
||||||
|
@ -96,42 +100,82 @@ const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
|
||||||
return menu;
|
return menu;
|
||||||
}, [account, authUsers, features]);
|
}, [account, authUsers, features]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const toggleVisible = () => setVisible(!visible);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
fetchOwnAccountThrottled();
|
fetchOwnAccountThrottled();
|
||||||
}, [account, authUsers]);
|
}, [account, authUsers]);
|
||||||
|
|
||||||
|
useClickOutside(refs, () => {
|
||||||
|
setVisible(false);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu>
|
<>
|
||||||
<MenuButton>
|
<button type='button' ref={refs.setReference} onClick={toggleVisible}>
|
||||||
{children}
|
{children}
|
||||||
</MenuButton>
|
</button>
|
||||||
|
|
||||||
<MenuList>
|
{visible && (
|
||||||
{menu.map((menuItem, idx) => {
|
<div
|
||||||
if (menuItem.toggle) {
|
ref={refs.setFloating}
|
||||||
return (
|
className='z-[1003] mt-2 max-w-xs rounded-md bg-white shadow-lg focus:outline-none dark:bg-gray-900 dark:ring-2 dark:ring-primary-700'
|
||||||
<div key={idx} className='flex flex-row items-center justify-between space-x-4 px-4 py-1 text-sm text-gray-700 dark:text-gray-400'>
|
style={{
|
||||||
<span>{menuItem.text}</span>
|
position: strategy,
|
||||||
|
top: y ?? 0,
|
||||||
{menuItem.toggle}
|
left: x ?? 0,
|
||||||
</div>
|
width: 'max-content',
|
||||||
);
|
}}
|
||||||
} else if (!menuItem.text) {
|
>
|
||||||
return <MenuDivider key={idx} />;
|
{menu.map((menuItem, i) => (
|
||||||
} else {
|
<MenuItem key={i} menuItem={menuItem} />
|
||||||
const Comp: any = menuItem.action ? MenuItem : MenuLink;
|
))}
|
||||||
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link };
|
</div>
|
||||||
|
)}
|
||||||
return (
|
</>
|
||||||
<Comp key={idx} {...itemProps} className='truncate'>
|
|
||||||
{menuItem.text}
|
|
||||||
</Comp>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</MenuList>
|
|
||||||
</Menu>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface MenuItemProps {
|
||||||
|
className?: string
|
||||||
|
menuItem: IMenuItem
|
||||||
|
}
|
||||||
|
|
||||||
|
const MenuItem: React.FC<MenuItemProps> = ({ className, menuItem }) => {
|
||||||
|
const baseClassName = clsx(className, 'block w-full cursor-pointer truncate px-4 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-100 rtl:text-right dark:text-gray-500 dark:hover:bg-gray-800');
|
||||||
|
|
||||||
|
if (menuItem.toggle) {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-row items-center justify-between space-x-4 px-4 py-1 text-sm text-gray-700 dark:text-gray-400'>
|
||||||
|
<span>{menuItem.text}</span>
|
||||||
|
|
||||||
|
{menuItem.toggle}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (!menuItem.text) {
|
||||||
|
return <MenuDivider />;
|
||||||
|
} else if (menuItem.action) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={menuItem.action}
|
||||||
|
className={baseClassName}
|
||||||
|
>
|
||||||
|
{menuItem.text}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
} else if (menuItem.to) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={menuItem.to}
|
||||||
|
className={baseClassName}
|
||||||
|
>
|
||||||
|
{menuItem.text}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw menuItem;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default ProfileDropdown;
|
export default ProfileDropdown;
|
||||||
|
|
|
@ -2,6 +2,7 @@ export { useAccount } from './useAccount';
|
||||||
export { useApi } from './useApi';
|
export { useApi } from './useApi';
|
||||||
export { useAppDispatch } from './useAppDispatch';
|
export { useAppDispatch } from './useAppDispatch';
|
||||||
export { useAppSelector } from './useAppSelector';
|
export { useAppSelector } from './useAppSelector';
|
||||||
|
export { useClickOutside } from './useClickOutside';
|
||||||
export { useCompose } from './useCompose';
|
export { useCompose } from './useCompose';
|
||||||
export { useDebounce } from './useDebounce';
|
export { useDebounce } from './useDebounce';
|
||||||
export { useDimensions } from './useDimensions';
|
export { useDimensions } from './useDimensions';
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { ExtendedRefs } from '@floating-ui/react';
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
/** Trigger `callback` when a Floating UI element is clicked outside from. */
|
||||||
|
export const useClickOutside = <T extends HTMLElement>(
|
||||||
|
refs: ExtendedRefs<T>,
|
||||||
|
callback: (e: MouseEvent) => void,
|
||||||
|
) => {
|
||||||
|
const handleWindowClick = useCallback((e: MouseEvent) => {
|
||||||
|
if (e.target) {
|
||||||
|
const target = e.target as Node;
|
||||||
|
|
||||||
|
const floating = refs.floating.current;
|
||||||
|
const reference = refs.reference.current as T | undefined;
|
||||||
|
|
||||||
|
if (!(floating?.contains(target) || reference?.contains(target))) {
|
||||||
|
callback(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [refs.floating.current, refs.reference.current]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('click', handleWindowClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('click', handleWindowClick);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
};
|
|
@ -1,13 +1,17 @@
|
||||||
import React from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
export const useOnScreen = (ref: React.MutableRefObject<HTMLElement>) => {
|
/** Detect whether a given element is on the screen. */
|
||||||
const [isIntersecting, setIntersecting] = React.useState(false);
|
// https://stackoverflow.com/a/64892655
|
||||||
|
export const useOnScreen = <T>(ref: React.RefObject<T & Element>) => {
|
||||||
|
const [isIntersecting, setIntersecting] = useState(false);
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = useMemo(() => {
|
||||||
([entry]) => setIntersecting(entry.isIntersecting),
|
return new IntersectionObserver(
|
||||||
);
|
([entry]) => setIntersecting(entry.isIntersecting),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
observer.observe(ref.current);
|
observer.observe(ref.current);
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,8 @@ describe('normalizeInstance()', () => {
|
||||||
configuration: {
|
configuration: {
|
||||||
media_attachments: {},
|
media_attachments: {},
|
||||||
chats: {
|
chats: {
|
||||||
max_characters: 500,
|
max_characters: 5000,
|
||||||
|
max_media_attachments: 1,
|
||||||
},
|
},
|
||||||
polls: {
|
polls: {
|
||||||
max_options: 4,
|
max_options: 4,
|
||||||
|
|
|
@ -22,7 +22,8 @@ export const InstanceRecord = ImmutableRecord({
|
||||||
configuration: ImmutableMap<string, any>({
|
configuration: ImmutableMap<string, any>({
|
||||||
media_attachments: ImmutableMap<string, any>(),
|
media_attachments: ImmutableMap<string, any>(),
|
||||||
chats: ImmutableMap<string, number>({
|
chats: ImmutableMap<string, number>({
|
||||||
max_characters: 500,
|
max_characters: 5000,
|
||||||
|
max_media_attachments: 1,
|
||||||
}),
|
}),
|
||||||
polls: ImmutableMap<string, number>({
|
polls: ImmutableMap<string, number>({
|
||||||
max_options: 4,
|
max_options: 4,
|
||||||
|
|
|
@ -231,9 +231,13 @@ const useChatActions = (chatId: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const createChatMessage = useMutation(
|
const createChatMessage = useMutation(
|
||||||
(
|
({ chatId, content, mediaIds }: { chatId: string, content: string, mediaIds?: string[] }) => {
|
||||||
{ chatId, content, mediaId }: { chatId: string, content: string, mediaId?: string },
|
return api.post<ChatMessage>(`/api/v1/pleroma/chats/${chatId}/messages`, {
|
||||||
) => api.post<ChatMessage>(`/api/v1/pleroma/chats/${chatId}/messages`, { content, media_id: mediaId, media_ids: [mediaId] }),
|
content,
|
||||||
|
media_id: (mediaIds && mediaIds.length === 1) ? mediaIds[0] : undefined, // Pleroma backwards-compat
|
||||||
|
media_ids: mediaIds,
|
||||||
|
});
|
||||||
|
},
|
||||||
{
|
{
|
||||||
retry: false,
|
retry: false,
|
||||||
onMutate: async (variables) => {
|
onMutate: async (variables) => {
|
||||||
|
|
|
@ -13,7 +13,8 @@ describe('instance reducer', () => {
|
||||||
description_limit: 1500,
|
description_limit: 1500,
|
||||||
configuration: {
|
configuration: {
|
||||||
chats: {
|
chats: {
|
||||||
max_characters: 500,
|
max_characters: 5000,
|
||||||
|
max_media_attachments: 1,
|
||||||
},
|
},
|
||||||
statuses: {
|
statuses: {
|
||||||
max_characters: 500,
|
max_characters: 500,
|
||||||
|
|
|
@ -380,10 +380,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')) || v.software === TRUTHSOCIAL,
|
emojiReactsNonRGI: v.software === PLEROMA && lt(v.version, '2.2.49'),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign in with an Ethereum wallet.
|
* Sign in with an Ethereum wallet.
|
||||||
|
|
|
@ -46,6 +46,7 @@
|
||||||
"@babel/preset-react": "^7.18.6",
|
"@babel/preset-react": "^7.18.6",
|
||||||
"@babel/preset-typescript": "^7.18.6",
|
"@babel/preset-typescript": "^7.18.6",
|
||||||
"@babel/runtime": "^7.20.13",
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@floating-ui/react": "^0.19.1",
|
||||||
"@fontsource/inter": "^4.5.1",
|
"@fontsource/inter": "^4.5.1",
|
||||||
"@fontsource/roboto-mono": "^4.5.8",
|
"@fontsource/roboto-mono": "^4.5.8",
|
||||||
"@gamestdio/websocket": "^0.3.2",
|
"@gamestdio/websocket": "^0.3.2",
|
||||||
|
|
40
yarn.lock
40
yarn.lock
|
@ -1722,6 +1722,34 @@
|
||||||
minimatch "^3.1.2"
|
minimatch "^3.1.2"
|
||||||
strip-json-comments "^3.1.1"
|
strip-json-comments "^3.1.1"
|
||||||
|
|
||||||
|
"@floating-ui/core@^1.2.0":
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.2.0.tgz#ae7ae7923d41f3d84cb2fd88740a89436610bbec"
|
||||||
|
integrity sha512-GHUXPEhMEmTpnpIfesFA2KAoMJPb1SPQw964tToQwt+BbGXdhqTCWT1rOb0VURGylsxsYxiGMnseJ3IlclVpVA==
|
||||||
|
|
||||||
|
"@floating-ui/dom@^1.1.1":
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.2.0.tgz#a60212069cc58961c478037c30eba4b191c75316"
|
||||||
|
integrity sha512-QXzg57o1cjLz3cGETzKXjI3kx1xyS49DW9l7kV2jw2c8Yftd434t2hllX0sVGn2Q8MtcW/4pNm8bfE1/4n6mng==
|
||||||
|
dependencies:
|
||||||
|
"@floating-ui/core" "^1.2.0"
|
||||||
|
|
||||||
|
"@floating-ui/react-dom@^1.2.2":
|
||||||
|
version "1.2.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-1.2.2.tgz#ed256992fd44fcfcddc96da68b4b92f123d61871"
|
||||||
|
integrity sha512-DbmFBLwFrZhtXgCI2ra7wXYT8L2BN4/4AMQKyu05qzsVji51tXOfF36VE2gpMB6nhJGHa85PdEg75FB4+vnLFQ==
|
||||||
|
dependencies:
|
||||||
|
"@floating-ui/dom" "^1.1.1"
|
||||||
|
|
||||||
|
"@floating-ui/react@^0.19.1":
|
||||||
|
version "0.19.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.19.1.tgz#bcaeaf3856dfeea388816f7e66750cab26208376"
|
||||||
|
integrity sha512-h7hr53rLp+VVvWvbu0dOBvGsLeeZwn1DTLIllIaLYjGWw20YhAgEqegHU+nc7BJ30ttxq4Sq6hqARm0ne6chXQ==
|
||||||
|
dependencies:
|
||||||
|
"@floating-ui/react-dom" "^1.2.2"
|
||||||
|
aria-hidden "^1.1.3"
|
||||||
|
tabbable "^6.0.1"
|
||||||
|
|
||||||
"@fontsource/inter@^4.5.1":
|
"@fontsource/inter@^4.5.1":
|
||||||
version "4.5.1"
|
version "4.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/@fontsource/inter/-/inter-4.5.1.tgz#058d8a02354f3c78e369d452c15d33557ec1b705"
|
resolved "https://registry.yarnpkg.com/@fontsource/inter/-/inter-4.5.1.tgz#058d8a02354f3c78e369d452c15d33557ec1b705"
|
||||||
|
@ -5381,6 +5409,13 @@ argparse@^2.0.1:
|
||||||
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
|
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
|
||||||
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
|
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
|
||||||
|
|
||||||
|
aria-hidden@^1.1.3:
|
||||||
|
version "1.2.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.2.tgz#8c4f7cc88d73ca42114106fdf6f47e68d31475b8"
|
||||||
|
integrity sha512-6y/ogyDTk/7YAe91T3E2PR1ALVKyM2QbTio5HwM+N1Q6CMlCKhvClyIjkckBswa0f2xJhjsfzIGa1yVSe1UMVA==
|
||||||
|
dependencies:
|
||||||
|
tslib "^2.0.0"
|
||||||
|
|
||||||
aria-query@^4.2.2:
|
aria-query@^4.2.2:
|
||||||
version "4.2.2"
|
version "4.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-4.2.2.tgz#0d2ca6c9aceb56b8977e9fed6aed7e15bbd2f83b"
|
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-4.2.2.tgz#0d2ca6c9aceb56b8977e9fed6aed7e15bbd2f83b"
|
||||||
|
@ -16517,6 +16552,11 @@ tabbable@^5.3.3:
|
||||||
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.3.3.tgz#aac0ff88c73b22d6c3c5a50b1586310006b47fbf"
|
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.3.3.tgz#aac0ff88c73b22d6c3c5a50b1586310006b47fbf"
|
||||||
integrity sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA==
|
integrity sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA==
|
||||||
|
|
||||||
|
tabbable@^6.0.1:
|
||||||
|
version "6.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.0.1.tgz#427a09b13c83ae41eed3e88abb76a4af28bde1a6"
|
||||||
|
integrity sha512-SYJSIgeyXW7EuX1ytdneO5e8jip42oHWg9xl/o3oTYhmXusZVgiA+VlPvjIN+kHii9v90AmzTZEBcsEvuAY+TA==
|
||||||
|
|
||||||
table@^6.8.1:
|
table@^6.8.1:
|
||||||
version "6.8.1"
|
version "6.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/table/-/table-6.8.1.tgz#ea2b71359fe03b017a5fbc296204471158080bdf"
|
resolved "https://registry.yarnpkg.com/table/-/table-6.8.1.tgz#ea2b71359fe03b017a5fbc296204471158080bdf"
|
||||||
|
|
Ładowanie…
Reference in New Issue