diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f2f769441..7fb8999e2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,12 +3,13 @@ image: node:18 variables: NODE_ENV: test -cache: +cache: &cache key: files: - yarn.lock paths: - node_modules/ + policy: pull stages: - deps @@ -21,6 +22,17 @@ deps: only: changes: - yarn.lock + cache: + <<: *cache + policy: push + +danger: + stage: test + script: + # https://github.com/danger/danger-js/issues/1029#issuecomment-998915436 + - export CI_MERGE_REQUEST_IID=${CI_OPEN_MERGE_REQUESTS#*!} + - npx danger ci + allow_failure: true lint-js: stage: test diff --git a/app/soapbox/actions/__tests__/accounts.test.ts b/app/soapbox/actions/__tests__/accounts.test.ts index b02469527..2ea60bd80 100644 --- a/app/soapbox/actions/__tests__/accounts.test.ts +++ b/app/soapbox/actions/__tests__/accounts.test.ts @@ -435,10 +435,14 @@ describe('followAccount()', () => { skipLoading: true, }, ]; - await store.dispatch(followAccount(id)); - const actions = store.getActions(); - expect(actions).toEqual(expectedActions); + try { + await store.dispatch(followAccount(id)); + } catch (e) { + const actions = store.getActions(); + expect(actions).toEqual(expectedActions); + expect(e).toEqual(new Error('Network Error')); + } }); }); }); diff --git a/app/soapbox/actions/accounts.js b/app/soapbox/actions/accounts.js index 5cc0008a4..63314a6b1 100644 --- a/app/soapbox/actions/accounts.js +++ b/app/soapbox/actions/accounts.js @@ -240,7 +240,10 @@ export function followAccount(id, options = { reblogs: true }) { return api(getState) .post(`/api/v1/accounts/${id}/follow`, options) .then(response => dispatch(followAccountSuccess(response.data, alreadyFollowing))) - .catch(error => dispatch(followAccountFail(error, locked))); + .catch(error => { + dispatch(followAccountFail(error, locked)); + throw error; + }); }; } diff --git a/app/soapbox/actions/alerts.ts b/app/soapbox/actions/alerts.ts index 2fc253158..bbb492e42 100644 --- a/app/soapbox/actions/alerts.ts +++ b/app/soapbox/actions/alerts.ts @@ -38,7 +38,7 @@ function showAlert( } const showAlertForError = (error: AxiosError) => (dispatch: React.Dispatch, _getState: any) => { - if (error.response) { + if (error?.response) { const { data, status, statusText } = error.response; if (status === 502) { @@ -52,7 +52,7 @@ const showAlertForError = (error: AxiosError) => (dispatch: React.Dispatch< let message: string | undefined = statusText; - if (data.error) { + if (data?.error) { message = data.error; } diff --git a/app/soapbox/actions/auth.ts b/app/soapbox/actions/auth.ts index 49de0e475..e148e0535 100644 --- a/app/soapbox/actions/auth.ts +++ b/app/soapbox/actions/auth.ts @@ -229,9 +229,6 @@ export const logIn = (username: string, password: string) => throw error; }); -export const deleteSession = () => - (dispatch: AppDispatch, getState: () => any) => api(getState).delete('/api/sign_out'); - export const logOut = () => (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); @@ -246,10 +243,7 @@ export const logOut = () => token: state.auth.getIn(['users', account.url, 'access_token']), }; - return Promise.all([ - dispatch(revokeOAuthToken(params)), - dispatch(deleteSession()), - ]).finally(() => { + return dispatch(revokeOAuthToken(params)).finally(() => { dispatch({ type: AUTH_LOGGED_OUT, account, standalone }); return dispatch(snackbar.success(messages.loggedOut)); }); diff --git a/app/soapbox/actions/compose.js b/app/soapbox/actions/compose.js index fe54c843e..c42176fa2 100644 --- a/app/soapbox/actions/compose.js +++ b/app/soapbox/actions/compose.js @@ -1,6 +1,6 @@ import { CancelToken, isCancel } from 'axios'; import { OrderedSet as ImmutableOrderedSet } from 'immutable'; -import { throttle } from 'lodash'; +import throttle from 'lodash/throttle'; import { defineMessages } from 'react-intl'; import snackbar from 'soapbox/actions/snackbar'; diff --git a/app/soapbox/actions/instance.ts b/app/soapbox/actions/instance.ts index cabccdd33..60a6b2e89 100644 --- a/app/soapbox/actions/instance.ts +++ b/app/soapbox/actions/instance.ts @@ -1,5 +1,5 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; -import { get } from 'lodash'; +import get from 'lodash/get'; import KVStore from 'soapbox/storage/kv_store'; import { RootState } from 'soapbox/store'; diff --git a/app/soapbox/actions/media.js b/app/soapbox/actions/media.js index 460c2f079..ce1550ba4 100644 --- a/app/soapbox/actions/media.js +++ b/app/soapbox/actions/media.js @@ -2,7 +2,7 @@ import { getFeatures } from 'soapbox/utils/features'; import api from '../api'; -const noOp = () => {}; +const noOp = (e) => {}; export function fetchMedia(mediaId) { return (dispatch, getState) => { diff --git a/app/soapbox/actions/preload.js b/app/soapbox/actions/preload.js index d14c6f9fe..abde36da6 100644 --- a/app/soapbox/actions/preload.js +++ b/app/soapbox/actions/preload.js @@ -1,4 +1,4 @@ -import { mapValues } from 'lodash'; +import mapValues from 'lodash/mapValues'; import { verifyCredentials } from './auth'; import { importFetchedAccounts } from './importer'; diff --git a/app/soapbox/build_config.js b/app/soapbox/build_config.js index 6ddb309cb..04b48bf78 100644 --- a/app/soapbox/build_config.js +++ b/app/soapbox/build_config.js @@ -4,7 +4,8 @@ * @module soapbox/build_config */ -const { trim, trimEnd } = require('lodash'); +const trim = require('lodash/trim'); +const trimEnd = require('lodash/trimEnd'); const { NODE_ENV, diff --git a/app/soapbox/components/autosuggest_account_input.tsx b/app/soapbox/components/autosuggest_account_input.tsx index e103d7722..79d247fd9 100644 --- a/app/soapbox/components/autosuggest_account_input.tsx +++ b/app/soapbox/components/autosuggest_account_input.tsx @@ -1,5 +1,5 @@ import { OrderedSet as ImmutableOrderedSet } from 'immutable'; -import { throttle } from 'lodash'; +import throttle from 'lodash/throttle'; import React, { useState, useRef, useCallback, useEffect } from 'react'; import { accountSearch } from 'soapbox/actions/accounts'; diff --git a/app/soapbox/components/filter_bar.js b/app/soapbox/components/filter_bar.js index 46a7dc748..7b0651d01 100644 --- a/app/soapbox/components/filter_bar.js +++ b/app/soapbox/components/filter_bar.js @@ -1,5 +1,5 @@ import classNames from 'classnames'; -import { debounce } from 'lodash'; +import debounce from 'lodash/debounce'; import PropTypes from 'prop-types'; import React from 'react'; import { withRouter } from 'react-router-dom'; diff --git a/app/soapbox/components/hover_ref_wrapper.tsx b/app/soapbox/components/hover_ref_wrapper.tsx index 2ef2d8372..2090543cc 100644 --- a/app/soapbox/components/hover_ref_wrapper.tsx +++ b/app/soapbox/components/hover_ref_wrapper.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames'; -import { debounce } from 'lodash'; +import debounce from 'lodash/debounce'; import React, { useRef } from 'react'; import { useDispatch } from 'react-redux'; @@ -15,7 +15,7 @@ const showProfileHoverCard = debounce((dispatch, ref, accountId) => { interface IHoverRefWrapper { accountId: string, - inline: boolean, + inline?: boolean, className?: string, } diff --git a/app/soapbox/components/scroll-top-button.tsx b/app/soapbox/components/scroll-top-button.tsx index 5de90abb6..f68a22d3f 100644 --- a/app/soapbox/components/scroll-top-button.tsx +++ b/app/soapbox/components/scroll-top-button.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames'; -import { throttle } from 'lodash'; +import throttle from 'lodash/throttle'; import React, { useState, useEffect, useCallback } from 'react'; import { useIntl, MessageDescriptor } from 'react-intl'; diff --git a/app/soapbox/components/scrollable_list.tsx b/app/soapbox/components/scrollable_list.tsx index 8b803be66..31b892e6d 100644 --- a/app/soapbox/components/scrollable_list.tsx +++ b/app/soapbox/components/scrollable_list.tsx @@ -1,4 +1,4 @@ -import { debounce } from 'lodash'; +import debounce from 'lodash/debounce'; import React, { useEffect, useRef, useMemo, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import { Virtuoso, Components, VirtuosoProps, VirtuosoHandle, ListRange, IndexLocationWithAlign } from 'react-virtuoso'; diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx index 37e37f59d..6eac9c7b2 100644 --- a/app/soapbox/components/status_list.tsx +++ b/app/soapbox/components/status_list.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames'; -import { debounce } from 'lodash'; +import debounce from 'lodash/debounce'; import React, { useRef, useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; diff --git a/app/soapbox/components/sub_navigation.js b/app/soapbox/components/sub_navigation.js index 0d24dfffb..f75ca802f 100644 --- a/app/soapbox/components/sub_navigation.js +++ b/app/soapbox/components/sub_navigation.js @@ -1,4 +1,4 @@ -import { throttle } from 'lodash'; +import throttle from 'lodash/throttle'; import PropTypes from 'prop-types'; import React from 'react'; import { injectIntl, defineMessages } from 'react-intl'; diff --git a/app/soapbox/components/upload-progress.tsx b/app/soapbox/components/upload-progress.tsx new file mode 100644 index 000000000..d910747cb --- /dev/null +++ b/app/soapbox/components/upload-progress.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { spring } from 'react-motion'; + +import { HStack, Icon, Stack, Text } from 'soapbox/components/ui'; +import Motion from 'soapbox/features/ui/util/optional_motion'; + +interface IUploadProgress { + /** Number between 0 and 1 to represent the percentage complete. */ + progress: number, +} + +/** Displays a progress bar for uploading files. */ +const UploadProgress: React.FC = ({ progress }) => { + return ( + + + + + + + + +
+ + {({ width }) => + (
) + } + +
+ + + ); +}; + +export default UploadProgress; diff --git a/app/soapbox/features/account/components/header.js b/app/soapbox/features/account/components/header.js index b76a156a5..c7da47cf1 100644 --- a/app/soapbox/features/account/components/header.js +++ b/app/soapbox/features/account/components/header.js @@ -1,7 +1,7 @@ 'use strict'; import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; -import { debounce } from 'lodash'; +import debounce from 'lodash/debounce'; import PropTypes from 'prop-types'; import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; @@ -17,6 +17,7 @@ import StillImage from 'soapbox/components/still_image'; import { HStack, IconButton, Menu, MenuButton, MenuItem, MenuList, MenuLink, MenuDivider } from 'soapbox/components/ui'; import SvgIcon from 'soapbox/components/ui/icon/svg-icon'; import ActionButton from 'soapbox/features/ui/components/action-button'; +import SubscriptionButton from 'soapbox/features/ui/components/subscription-button'; import { isLocal, isRemote, @@ -61,8 +62,6 @@ const messages = defineMessages({ promoteToModerator: { id: 'admin.users.actions.promote_to_moderator', defaultMessage: 'Promote @{name} to a moderator' }, demoteToModerator: { id: 'admin.users.actions.demote_to_moderator', defaultMessage: 'Demote @{name} to a moderator' }, demoteToUser: { id: 'admin.users.actions.demote_to_user', defaultMessage: 'Demote @{name} to a regular user' }, - subscribe: { id: 'account.subscribe', defaultMessage: 'Subscribe to notifications from @{name}' }, - unsubscribe: { id: 'account.unsubscribe', defaultMessage: 'Unsubscribe to notifications from @{name}' }, suggestUser: { id: 'admin.users.actions.suggest_user', defaultMessage: 'Suggest @{name}' }, unsuggestUser: { id: 'admin.users.actions.unsuggest_user', defaultMessage: 'Unsuggest @{name}' }, }); @@ -250,22 +249,6 @@ class Header extends ImmutablePureComponent { }); } - if (features.accountSubscriptions) { - if (account.relationship?.subscribing) { - menu.push({ - text: intl.formatMessage(messages.unsubscribe, { name: account.get('username') }), - action: this.props.onSubscriptionToggle, - icon: require('@tabler/icons/icons/bell.svg'), - }); - } else { - menu.push({ - text: intl.formatMessage(messages.subscribe, { name: account.get('username') }), - action: this.props.onSubscriptionToggle, - icon: require('@tabler/icons/icons/bell-off.svg'), - }); - } - } - if (features.lists) { menu.push({ text: intl.formatMessage(messages.add_or_remove_from_list), @@ -476,7 +459,7 @@ class Header extends ImmutablePureComponent { } + title={} />, ); } @@ -578,11 +561,6 @@ class Header extends ImmutablePureComponent { const menu = this.makeMenu(); const header = account.get('header', ''); - // NOTE: Removing Subscription element - // {features.accountSubscriptions &&
- // - //
} - return (
@@ -618,6 +596,8 @@ class Header extends ImmutablePureComponent {
+ + {me && ( {}); + .catch(() => { }); } componentDidMount() { diff --git a/app/soapbox/features/audio/index.js b/app/soapbox/features/audio/index.js index 6fa5418bc..067856abf 100644 --- a/app/soapbox/features/audio/index.js +++ b/app/soapbox/features/audio/index.js @@ -1,5 +1,6 @@ import classNames from 'classnames'; -import { debounce, throttle } from 'lodash'; +import debounce from 'lodash/debounce'; +import throttle from 'lodash/throttle'; import PropTypes from 'prop-types'; import React from 'react'; import { defineMessages, injectIntl } from 'react-intl'; diff --git a/app/soapbox/features/auth_login/components/registration_form.tsx b/app/soapbox/features/auth_login/components/registration_form.tsx index 0c3e048ce..eba70437a 100644 --- a/app/soapbox/features/auth_login/components/registration_form.tsx +++ b/app/soapbox/features/auth_login/components/registration_form.tsx @@ -1,6 +1,6 @@ import axios from 'axios'; import { Map as ImmutableMap } from 'immutable'; -import { debounce } from 'lodash'; +import debounce from 'lodash/debounce'; import React, { useState, useRef, useCallback } from 'react'; import { useIntl, FormattedMessage, defineMessages } from 'react-intl'; import { Link, useHistory } from 'react-router-dom'; diff --git a/app/soapbox/features/blocks/index.tsx b/app/soapbox/features/blocks/index.tsx index 8b56fc262..c3ea6585f 100644 --- a/app/soapbox/features/blocks/index.tsx +++ b/app/soapbox/features/blocks/index.tsx @@ -1,4 +1,4 @@ -import { debounce } from 'lodash'; +import debounce from 'lodash/debounce'; import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { useDispatch } from 'react-redux'; diff --git a/app/soapbox/features/bookmarks/index.tsx b/app/soapbox/features/bookmarks/index.tsx index 343d7ffc6..c9e3e5de3 100644 --- a/app/soapbox/features/bookmarks/index.tsx +++ b/app/soapbox/features/bookmarks/index.tsx @@ -1,4 +1,4 @@ -import { debounce } from 'lodash'; +import debounce from 'lodash/debounce'; import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; diff --git a/app/soapbox/features/chats/chat-room.tsx b/app/soapbox/features/chats/chat-room.tsx new file mode 100644 index 000000000..7762fc216 --- /dev/null +++ b/app/soapbox/features/chats/chat-room.tsx @@ -0,0 +1,68 @@ +import { Map as ImmutableMap } from 'immutable'; +import React, { useEffect, useRef } from 'react'; + +import { fetchChat, markChatRead } from 'soapbox/actions/chats'; +import { Column } from 'soapbox/components/ui'; +import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; +import { makeGetChat } from 'soapbox/selectors'; +import { getAcct } from 'soapbox/utils/accounts'; +import { displayFqn as getDisplayFqn } from 'soapbox/utils/state'; + +import ChatBox from './components/chat-box'; + +const getChat = makeGetChat(); + +interface IChatRoom { + params: { + chatId: string, + } +} + +/** Fullscreen chat UI. */ +const ChatRoom: React.FC = ({ params }) => { + const dispatch = useAppDispatch(); + const displayFqn = useAppSelector(getDisplayFqn); + const inputElem = useRef(null); + + const chat = useAppSelector(state => { + const chat = state.chats.items.get(params.chatId, ImmutableMap()).toJS() as any; + return getChat(state, chat); + }); + + const focusInput = () => { + inputElem.current?.focus(); + }; + + const handleInputRef = (el: HTMLTextAreaElement) => { + inputElem.current = el; + focusInput(); + }; + + const markRead = () => { + if (!chat) return; + dispatch(markChatRead(chat.id)); + }; + + useEffect(() => { + dispatch(fetchChat(params.chatId)); + markRead(); + }, [params.chatId]); + + // If this component is loaded at all, we can instantly mark new messages as read. + useEffect(() => { + markRead(); + }, [chat?.unread]); + + if (!chat) return null; + + return ( + + + + ); +}; + +export default ChatRoom; diff --git a/app/soapbox/features/chats/chat_room.js b/app/soapbox/features/chats/chat_room.js deleted file mode 100644 index 4d9140650..000000000 --- a/app/soapbox/features/chats/chat_room.js +++ /dev/null @@ -1,95 +0,0 @@ -import { Map as ImmutableMap } from 'immutable'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; - -import { fetchChat, markChatRead } from 'soapbox/actions/chats'; -import { Column } from 'soapbox/components/ui'; -import { makeGetChat } from 'soapbox/selectors'; -import { getAcct } from 'soapbox/utils/accounts'; -import { displayFqn } from 'soapbox/utils/state'; - -import ChatBox from './components/chat_box'; - -const mapStateToProps = (state, { params }) => { - const getChat = makeGetChat(); - const chat = state.getIn(['chats', 'items', params.chatId], ImmutableMap()).toJS(); - - return { - me: state.get('me'), - chat: getChat(state, chat), - displayFqn: displayFqn(state), - }; -}; - -export default @connect(mapStateToProps) -@injectIntl -class ChatRoom extends ImmutablePureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - chat: ImmutablePropTypes.map, - displayFqn: PropTypes.bool, - me: PropTypes.node, - } - - handleInputRef = (el) => { - this.inputElem = el; - this.focusInput(); - }; - - focusInput = () => { - if (!this.inputElem) return; - this.inputElem.focus(); - } - - markRead = () => { - const { dispatch, chat } = this.props; - if (!chat) return; - dispatch(markChatRead(chat.get('id'))); - } - - componentDidMount() { - const { dispatch, params } = this.props; - dispatch(fetchChat(params.chatId)); - this.markRead(); - } - - componentDidUpdate(prevProps) { - const markReadConditions = [ - () => this.props.chat, - () => this.props.chat.get('unread') > 0, - ]; - - if (markReadConditions.every(c => c())) - this.markRead(); - } - - render() { - const { chat, displayFqn } = this.props; - if (!chat) return null; - const account = chat.get('account'); - - return ( - - {/*
- - -
- @{getAcct(account, displayFqn)} -
- -
*/} - -
- ); - } - -} diff --git a/app/soapbox/features/chats/components/audio_toggle.tsx b/app/soapbox/features/chats/components/audio-toggle.tsx similarity index 100% rename from app/soapbox/features/chats/components/audio_toggle.tsx rename to app/soapbox/features/chats/components/audio-toggle.tsx diff --git a/app/soapbox/features/chats/components/chat-box.tsx b/app/soapbox/features/chats/components/chat-box.tsx new file mode 100644 index 000000000..668c323ca --- /dev/null +++ b/app/soapbox/features/chats/components/chat-box.tsx @@ -0,0 +1,192 @@ +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import React, { useRef, useState } from 'react'; +import { useIntl, defineMessages } from 'react-intl'; + +import { + sendChatMessage, + markChatRead, +} from 'soapbox/actions/chats'; +import { uploadMedia } from 'soapbox/actions/media'; +import IconButton from 'soapbox/components/icon_button'; +import UploadProgress from 'soapbox/components/upload-progress'; +import UploadButton from 'soapbox/features/compose/components/upload_button'; +import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; +import { truncateFilename } from 'soapbox/utils/media'; + +import ChatMessageList from './chat-message-list'; + +const messages = defineMessages({ + placeholder: { id: 'chat_box.input.placeholder', defaultMessage: 'Send a message…' }, + send: { id: 'chat_box.actions.send', defaultMessage: 'Send' }, +}); + +const fileKeyGen = (): number => Math.floor((Math.random() * 0x10000)); + +interface IChatBox { + chatId: string, + onSetInputRef: (el: HTMLTextAreaElement) => void, +} + +/** + * Chat UI with just the messages and textarea. + * Reused between floating desktop chats and fullscreen/mobile chats. + */ +const ChatBox: React.FC = ({ chatId, onSetInputRef }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const chatMessageIds = useAppSelector(state => state.chat_message_lists.get(chatId, ImmutableOrderedSet())); + + const [content, setContent] = useState(''); + const [attachment, setAttachment] = useState(undefined); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [resetFileKey, setResetFileKey] = useState(fileKeyGen()); + + const inputElem = useRef(null); + + const clearState = () => { + setContent(''); + setAttachment(undefined); + setIsUploading(false); + setUploadProgress(0); + setResetFileKey(fileKeyGen()); + }; + + const getParams = () => { + return { + content, + media_id: attachment && attachment.id, + }; + }; + + const canSubmit = () => { + const conds = [ + content.length > 0, + attachment, + ]; + + return conds.some(c => c); + }; + + const sendMessage = () => { + if (canSubmit() && !isUploading) { + const params = getParams(); + + dispatch(sendChatMessage(chatId, params)); + clearState(); + } + }; + + const insertLine = () => { + setContent(content + '\n'); + }; + + const handleKeyDown: React.KeyboardEventHandler = (e) => { + markRead(); + if (e.key === 'Enter' && e.shiftKey) { + insertLine(); + e.preventDefault(); + } else if (e.key === 'Enter') { + sendMessage(); + e.preventDefault(); + } + }; + + const handleContentChange: React.ChangeEventHandler = (e) => { + setContent(e.target.value); + }; + + const markRead = () => { + dispatch(markChatRead(chatId)); + }; + + const handleHover = () => { + markRead(); + }; + + const setInputRef = (el: HTMLTextAreaElement) => { + inputElem.current = el; + onSetInputRef(el); + }; + + const handleRemoveFile = () => { + setAttachment(undefined); + setResetFileKey(fileKeyGen()); + }; + + const onUploadProgress = (e: ProgressEvent) => { + const { loaded, total } = e; + setUploadProgress(loaded / total); + }; + + const handleFiles = (files: FileList) => { + setIsUploading(true); + + const data = new FormData(); + data.append('file', files[0]); + + dispatch(uploadMedia(data, onUploadProgress)).then((response: any) => { + setAttachment(response.data); + setIsUploading(false); + }).catch(() => { + setIsUploading(false); + }); + }; + + const renderAttachment = () => { + if (!attachment) return null; + + return ( +
+
+ {truncateFilename(attachment.preview_url, 20)} +
+
+ +
+
+ ); + }; + + const renderActionButton = () => { + return canSubmit() ? ( + + ) : ( + + ); + }; + + if (!chatMessageIds) return null; + + return ( +
+ + {renderAttachment()} + {isUploading && ( + + )} +
+
+ {renderActionButton()} +
+