2022-12-10 12:14:48 +03:00
import './compose.css' ;
import '@github/text-expander-element' ;
2024-07-01 12:41:21 +03:00
2024-08-13 10:26:23 +03:00
import { msg , plural , t , Trans } from '@lingui/macro' ;
import { useLingui } from '@lingui/react' ;
2023-12-27 18:33:59 +03:00
import { MenuItem } from '@szhsin/react-menu' ;
2024-01-06 07:31:25 +03:00
import { deepEqual } from 'fast-equals' ;
2024-05-01 19:14:25 +03:00
import Fuse from 'fuse.js' ;
2024-07-01 12:41:21 +03:00
import { forwardRef , memo } from 'preact/compat' ;
2024-05-01 19:14:25 +03:00
import {
useCallback ,
useEffect ,
useMemo ,
useRef ,
useState ,
} from 'preact/hooks' ;
2024-04-15 19:09:53 +03:00
import { useHotkeys } from 'react-hotkeys-hook' ;
2022-12-10 15:46:56 +03:00
import stringLength from 'string-length' ;
2024-05-28 12:59:17 +03:00
import { detectAll } from 'tinyld/light' ;
2023-01-11 09:44:20 +03:00
import { uid } from 'uid/single' ;
2023-11-09 14:11:00 +03:00
import { useDebouncedCallback , useThrottledCallback } from 'use-debounce' ;
2023-01-04 14:03:11 +03:00
import { useSnapshot } from 'valtio' ;
2022-12-10 12:14:48 +03:00
2024-04-02 12:51:48 +03:00
import poweredByGiphyURL from '../assets/powered-by-giphy.svg' ;
2023-12-27 18:33:59 +03:00
import Menu2 from '../components/menu2' ;
2022-12-27 13:09:23 +03:00
import supportedLanguages from '../data/status-supported-languages' ;
2022-12-23 11:45:02 +03:00
import urlRegex from '../data/url-regex' ;
2023-02-05 19:17:19 +03:00
import { api } from '../utils/api' ;
2023-01-13 10:30:09 +03:00
import db from '../utils/db' ;
2022-12-10 12:14:48 +03:00
import emojifyText from '../utils/emojify-text' ;
2024-08-13 10:26:23 +03:00
import i18nDuration from '../utils/i18n-duration' ;
2024-08-04 08:32:30 +03:00
import isRTL from '../utils/is-rtl' ;
2023-05-20 09:14:35 +03:00
import localeMatch from '../utils/locale-match' ;
2024-05-19 11:27:59 +03:00
import localeCode2Text from '../utils/localeCode2Text' ;
2024-08-13 10:26:23 +03:00
import mem from '../utils/mem' ;
2022-12-13 16:54:16 +03:00
import openCompose from '../utils/open-compose' ;
2024-05-01 19:14:25 +03:00
import pmem from '../utils/pmem' ;
2024-08-13 10:26:23 +03:00
import prettyBytes from '../utils/pretty-bytes' ;
2024-05-25 06:06:58 +03:00
import { fetchRelationships } from '../utils/relationships' ;
2023-10-30 04:22:19 +03:00
import shortenNumber from '../utils/shorten-number' ;
2023-12-27 18:33:59 +03:00
import showToast from '../utils/show-toast' ;
2023-02-10 06:35:47 +03:00
import states , { saveStatus } from '../utils/states' ;
2022-12-10 12:14:48 +03:00
import store from '../utils/store' ;
2023-02-12 20:21:18 +03:00
import {
getCurrentAccount ,
getCurrentAccountNS ,
getCurrentInstance ,
2023-10-24 09:30:50 +03:00
getCurrentInstanceConfiguration ,
2023-02-12 20:21:18 +03:00
} from '../utils/store-utils' ;
import supports from '../utils/supports' ;
2023-12-20 16:02:22 +03:00
import useCloseWatcher from '../utils/useCloseWatcher' ;
2023-01-13 10:30:09 +03:00
import useInterval from '../utils/useInterval' ;
2022-12-10 12:14:48 +03:00
import visibilityIconsMap from '../utils/visibility-icons-map' ;
2023-09-03 14:48:36 +03:00
import AccountBlock from './account-block' ;
// import Avatar from './avatar';
2022-12-10 12:14:48 +03:00
import Icon from './icon' ;
import Loader from './loader' ;
2023-01-05 20:51:39 +03:00
import Modal from './modal' ;
2022-12-10 12:14:48 +03:00
import Status from './status' ;
2024-04-02 12:51:48 +03:00
const {
PHANPY _IMG _ALT _API _URL : IMG _ALT _API _URL ,
PHANPY _GIPHY _API _KEY : GIPHY _API _KEY ,
} = import . meta . env ;
2023-12-27 18:33:59 +03:00
2022-12-27 13:09:23 +03:00
const supportedLanguagesMap = supportedLanguages . reduce ( ( acc , l ) => {
const [ code , common , native ] = l ;
acc [ code ] = {
common ,
native ,
} ;
return acc ;
} , { } ) ;
2022-12-10 12:14:48 +03:00
/ * N O T E S :
- Max character limit includes BOTH status text and Content Warning text
* /
2022-12-14 16:48:17 +03:00
const expiryOptions = {
2024-08-13 10:26:23 +03:00
300 : i18nDuration ( 5 , 'minute' ) ,
1 _800 : i18nDuration ( 30 , 'minute' ) ,
3 _600 : i18nDuration ( 1 , 'hour' ) ,
21 _600 : i18nDuration ( 6 , 'hour' ) ,
86 _400 : i18nDuration ( 1 , 'day' ) ,
259 _200 : i18nDuration ( 3 , 'day' ) ,
604 _800 : i18nDuration ( 1 , 'week' ) ,
2022-12-14 16:48:17 +03:00
} ;
2024-08-13 10:26:23 +03:00
const expirySeconds = Object . keys ( expiryOptions ) ;
2022-12-14 16:48:17 +03:00
const oneDay = 24 * 60 * 60 ;
const expiresInFromExpiresAt = ( expiresAt ) => {
if ( ! expiresAt ) return oneDay ;
const delta = ( new Date ( expiresAt ) . getTime ( ) - Date . now ( ) ) / 1000 ;
return expirySeconds . find ( ( s ) => s >= delta ) || oneDay ;
} ;
2022-12-22 14:24:07 +03:00
const menu = document . createElement ( 'ul' ) ;
menu . role = 'listbox' ;
menu . className = 'text-expander-menu' ;
2023-01-29 16:45:59 +03:00
// Set IntersectionObserver on menu, reposition it because text-expander doesn't handle it
const windowMargin = 16 ;
const observer = new IntersectionObserver ( ( entries ) => {
entries . forEach ( ( entry ) => {
if ( entry . isIntersecting ) {
const { left , width } = entry . boundingClientRect ;
const { innerWidth } = window ;
if ( left + width > innerWidth ) {
2024-08-04 08:32:30 +03:00
const insetInlineStart = isRTL ( ) ? 'right' : 'left' ;
menu . style [ insetInlineStart ] = innerWidth - width - windowMargin + 'px' ;
2023-01-29 16:45:59 +03:00
}
}
} ) ;
} ) ;
observer . observe ( menu ) ;
2023-05-20 09:14:35 +03:00
const DEFAULT _LANG = localeMatch (
2023-03-02 06:13:52 +03:00
[ new Intl . DateTimeFormat ( ) . resolvedOptions ( ) . locale , ... navigator . languages ] ,
supportedLanguages . map ( ( l ) => l [ 0 ] ) ,
'en' ,
) ;
2022-12-27 17:02:55 +03:00
2023-01-04 14:03:11 +03:00
// https://github.com/mastodon/mastodon/blob/c4a429ed47e85a6bbf0d470a41cc2f64cf120c19/app/javascript/mastodon/features/compose/util/counter.js
const urlRegexObj = new RegExp ( urlRegex . source , urlRegex . flags ) ;
const usernameRegex = /(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/gi ;
const urlPlaceholder = '$2xxxxxxxxxxxxxxxxxxxxxxx' ;
function countableText ( inputText ) {
return inputText
. replace ( urlRegexObj , urlPlaceholder )
. replace ( usernameRegex , '$1@$3' ) ;
}
2023-11-08 18:16:16 +03:00
// https://github.com/mastodon/mastodon/blob/c03bd2a238741a012aa4b98dc4902d6cf948ab63/app/models/account.rb#L69
const USERNAME _RE = /[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?/i ;
const MENTION _RE = new RegExp (
2023-12-17 12:52:56 +03:00
` (^|[^= \\ / \\ w])(@ ${ USERNAME _RE . source } (?:@[ \\ p{L} \\ w.-]+[ \\ w]+)?) ` ,
2023-12-05 13:30:15 +03:00
'uig' ,
2023-11-08 18:16:16 +03:00
) ;
// AI-generated, all other regexes are too complicated
const HASHTAG _RE = new RegExp (
2024-04-12 19:03:28 +03:00
` (^|[^= \\ / \\ w])(#[a-z0-9_]+([a-z0-9_.]+[a-z0-9_]+)?)(?![ \\ / \\ w]) ` ,
2023-11-08 18:16:16 +03:00
'ig' ,
) ;
// https://github.com/mastodon/mastodon/blob/23e32a4b3031d1da8b911e0145d61b4dd47c4f96/app/models/custom_emoji.rb#L31
const SHORTCODE _RE _FRAGMENT = '[a-zA-Z0-9_]{2,}' ;
const SCAN _RE = new RegExp (
2024-04-19 03:41:16 +03:00
` (^|[^= \\ / \\ w])(: ${ SHORTCODE _RE _FRAGMENT } :)(?=[^A-Za-z0-9_:]| $ ) ` ,
2023-11-08 18:16:16 +03:00
'g' ,
) ;
2024-03-04 14:38:46 +03:00
const segmenter = new Intl . Segmenter ( ) ;
2024-07-07 17:56:24 +03:00
function escapeHTML ( text ) {
return text
2024-01-17 06:31:33 +03:00
. replace ( /&/g , '&' )
. replace ( /</g , '<' )
. replace ( />/g , '>' )
. replace ( /"/g , '"' )
. replace ( /'/g , ''' ) ;
2024-07-07 17:56:24 +03:00
}
function highlightText ( text , { maxCharacters = Infinity } ) {
2023-11-08 18:16:16 +03:00
// Exceeded characters limit
const { composerCharacterCount } = states ;
if ( composerCharacterCount > maxCharacters ) {
// Highlight exceeded characters
2024-03-04 14:38:46 +03:00
let withinLimitHTML = '' ,
exceedLimitHTML = '' ;
2024-07-07 17:56:24 +03:00
const htmlSegments = segmenter . segment ( text ) ;
2024-03-04 14:38:46 +03:00
for ( const { segment , index } of htmlSegments ) {
if ( index < maxCharacters ) {
withinLimitHTML += segment ;
} else {
exceedLimitHTML += segment ;
}
}
if ( exceedLimitHTML ) {
exceedLimitHTML =
'<mark class="compose-highlight-exceeded">' +
2024-07-07 17:56:24 +03:00
escapeHTML ( exceedLimitHTML ) +
2024-03-04 14:38:46 +03:00
'</mark>' ;
}
2024-07-07 17:56:24 +03:00
return escapeHTML ( withinLimitHTML ) + exceedLimitHTML ;
2023-11-08 18:16:16 +03:00
}
2024-07-07 17:56:24 +03:00
return escapeHTML ( text )
2023-11-08 18:16:16 +03:00
. replace ( urlRegexObj , '$2<mark class="compose-highlight-url">$3</mark>' ) // URLs
2023-11-19 07:06:03 +03:00
. replace ( MENTION _RE , '$1<mark class="compose-highlight-mention">$2</mark>' ) // Mentions
. replace ( HASHTAG _RE , '$1<mark class="compose-highlight-hashtag">$2</mark>' ) // Hashtags
2023-11-08 18:16:16 +03:00
. replace (
SCAN _RE ,
2023-11-19 07:06:03 +03:00
'$1<mark class="compose-highlight-emoji-shortcode">$2</mark>' ,
2023-11-08 18:16:16 +03:00
) ; // Emoji shortcodes
}
2024-08-13 10:26:23 +03:00
// const rtf = new Intl.RelativeTimeFormat();
const RTF = mem ( ( locale ) => new Intl . RelativeTimeFormat ( locale || undefined ) ) ;
2024-03-13 08:30:58 +03:00
2024-05-01 19:14:25 +03:00
const CUSTOM _EMOJIS _COUNT = 100 ;
2022-12-13 19:39:35 +03:00
function Compose ( {
2022-12-13 15:42:09 +03:00
onClose ,
replyToStatus ,
editStatus ,
draftStatus ,
standalone ,
2022-12-14 16:48:17 +03:00
hasOpener ,
2022-12-13 19:39:35 +03:00
} ) {
2024-08-13 10:26:23 +03:00
const { i18n } = useLingui ( ) ;
const rtf = RTF ( i18n . locale ) ;
2023-01-04 14:03:11 +03:00
console . warn ( 'RENDER COMPOSER' ) ;
2023-02-24 07:20:31 +03:00
const { masto , instance } = api ( ) ;
2022-12-10 12:14:48 +03:00
const [ uiState , setUIState ] = useState ( 'default' ) ;
2023-01-13 10:30:09 +03:00
const UID = useRef ( draftStatus ? . uid || uid ( ) ) ;
2023-01-11 12:07:47 +03:00
console . log ( 'Compose UID' , UID . current ) ;
2022-12-10 12:14:48 +03:00
2023-01-11 08:28:42 +03:00
const currentAccount = getCurrentAccount ( ) ;
const currentAccountInfo = currentAccount . info ;
2022-12-10 12:14:48 +03:00
2023-10-24 09:30:50 +03:00
const configuration = getCurrentInstanceConfiguration ( ) ;
2023-02-12 20:21:18 +03:00
console . log ( '⚙️ Configuration' , configuration ) ;
2022-12-10 12:14:48 +03:00
const {
2023-10-24 09:30:50 +03:00
statuses : {
maxCharacters ,
maxMediaAttachments ,
charactersReservedPerUrl ,
} = { } ,
2022-12-10 12:14:48 +03:00
mediaAttachments : {
2023-10-24 09:30:50 +03:00
supportedMimeTypes = [ ] ,
2022-12-10 12:14:48 +03:00
imageSizeLimit ,
imageMatrixLimit ,
videoSizeLimit ,
videoMatrixLimit ,
videoFrameRateLimit ,
2023-10-24 09:30:50 +03:00
} = { } ,
polls : {
maxOptions ,
maxCharactersPerOption ,
maxExpiration ,
minExpiration ,
} = { } ,
} = configuration || { } ;
2022-12-10 12:14:48 +03:00
const textareaRef = useRef ( ) ;
2022-12-14 16:48:17 +03:00
const spoilerTextRef = useRef ( ) ;
2022-12-13 15:42:09 +03:00
const [ visibility , setVisibility ] = useState ( 'public' ) ;
const [ sensitive , setSensitive ] = useState ( false ) ;
2022-12-27 13:09:23 +03:00
const [ language , setLanguage ] = useState (
2022-12-27 17:02:55 +03:00
store . session . get ( 'currentLanguage' ) || DEFAULT _LANG ,
2022-12-27 13:09:23 +03:00
) ;
2023-03-19 10:04:42 +03:00
const prevLanguage = useRef ( language ) ;
2022-12-14 16:48:17 +03:00
const [ mediaAttachments , setMediaAttachments ] = useState ( [ ] ) ;
const [ poll , setPoll ] = useState ( null ) ;
2022-12-10 12:14:48 +03:00
2023-02-09 18:59:57 +03:00
const prefs = store . account . get ( 'preferences' ) || { } ;
2022-12-23 14:33:51 +03:00
const oninputTextarea = ( ) => {
if ( ! textareaRef . current ) return ;
textareaRef . current . dispatchEvent ( new Event ( 'input' ) ) ;
} ;
const focusTextarea = ( ) => {
setTimeout ( ( ) => {
2024-03-09 16:29:44 +03:00
if ( ! textareaRef . current ) return ;
// status starts with newline, focus on first position
if ( draftStatus ? . status ? . startsWith ? . ( '\n' ) ) {
textareaRef . current . selectionStart = 0 ;
textareaRef . current . selectionEnd = 0 ;
}
2023-01-07 16:02:46 +03:00
console . debug ( 'FOCUS textarea' ) ;
2022-12-23 14:33:51 +03:00
textareaRef . current ? . focus ( ) ;
2023-01-01 10:32:36 +03:00
} , 300 ) ;
2022-12-23 14:33:51 +03:00
} ;
2022-12-10 12:14:48 +03:00
useEffect ( ( ) => {
2022-12-13 15:42:09 +03:00
if ( replyToStatus ) {
2022-12-27 13:09:23 +03:00
const { spoilerText , visibility , language , sensitive } = replyToStatus ;
2022-12-10 12:14:48 +03:00
if ( spoilerText && spoilerTextRef . current ) {
spoilerTextRef . current . value = spoilerText ;
}
2022-12-23 14:33:51 +03:00
const mentions = new Set ( [
replyToStatus . account . acct ,
... replyToStatus . mentions . map ( ( m ) => m . acct ) ,
] ) ;
const allMentions = [ ... mentions ] . filter (
( m ) => m !== currentAccountInfo . acct ,
) ;
if ( allMentions . length > 0 ) {
textareaRef . current . value = ` ${ allMentions
. map ( ( m ) => ` @ ${ m } ` )
. join ( ' ' ) } ` ;
oninputTextarea ( ) ;
}
focusTextarea ( ) ;
2023-07-09 11:32:09 +03:00
setVisibility (
visibility === 'public' && prefs [ 'posting:default:visibility' ]
2024-08-09 07:43:12 +03:00
? prefs [ 'posting:default:visibility' ] . toLowerCase ( )
2023-07-09 11:32:09 +03:00
: visibility ,
) ;
2024-08-09 07:43:12 +03:00
setLanguage (
language ||
prefs [ 'posting:default:language' ] ? . toLowerCase ( ) ||
DEFAULT _LANG ,
) ;
2023-10-15 06:24:44 +03:00
setSensitive ( sensitive && ! ! spoilerText ) ;
2022-12-13 15:42:09 +03:00
} else if ( editStatus ) {
2022-12-27 13:09:23 +03:00
const { visibility , language , sensitive , poll , mediaAttachments } =
editStatus ;
2022-12-14 16:48:17 +03:00
const composablePoll = ! ! poll ? . options && {
... poll ,
options : poll . options . map ( ( o ) => o ? . title || o ) ,
expiresIn : poll ? . expiresIn || expiresInFromExpiresAt ( poll . expiresAt ) ,
} ;
2022-12-12 16:54:31 +03:00
setUIState ( 'loading' ) ;
( async ( ) => {
try {
2023-10-12 07:48:09 +03:00
const statusSource = await masto . v1 . statuses
. $select ( editStatus . id )
. source . fetch ( ) ;
2022-12-12 16:54:31 +03:00
console . log ( { statusSource } ) ;
const { text , spoilerText } = statusSource ;
textareaRef . current . value = text ;
textareaRef . current . dataset . source = text ;
2022-12-23 14:33:51 +03:00
oninputTextarea ( ) ;
focusTextarea ( ) ;
2022-12-12 16:54:31 +03:00
spoilerTextRef . current . value = spoilerText ;
setVisibility ( visibility ) ;
2024-08-09 07:43:12 +03:00
setLanguage (
language ||
prefs [ 'posting:default:language' ] ? . toLowerCase ( ) ||
DEFAULT _LANG ,
) ;
2022-12-12 16:54:31 +03:00
setSensitive ( sensitive ) ;
2024-03-27 04:46:37 +03:00
if ( composablePoll ) setPoll ( composablePoll ) ;
2022-12-12 16:54:31 +03:00
setMediaAttachments ( mediaAttachments ) ;
setUIState ( 'default' ) ;
} catch ( e ) {
console . error ( e ) ;
alert ( e ? . reason || e ) ;
setUIState ( 'error' ) ;
}
} ) ( ) ;
2022-12-23 14:33:51 +03:00
} else {
focusTextarea ( ) ;
2023-02-09 18:59:57 +03:00
console . log ( 'Apply prefs' , prefs ) ;
2023-06-23 16:20:11 +03:00
if ( prefs [ 'posting:default:visibility' ] ) {
2024-08-09 07:43:12 +03:00
setVisibility ( prefs [ 'posting:default:visibility' ] . toLowerCase ( ) ) ;
2023-02-09 18:59:57 +03:00
}
2023-06-23 16:20:11 +03:00
if ( prefs [ 'posting:default:language' ] ) {
2024-08-09 07:43:12 +03:00
setLanguage ( prefs [ 'posting:default:language' ] . toLowerCase ( ) ) ;
2023-02-09 18:59:57 +03:00
}
2023-06-23 16:20:11 +03:00
if ( prefs [ 'posting:default:sensitive' ] ) {
2024-08-09 07:43:12 +03:00
setSensitive ( ! ! prefs [ 'posting:default:sensitive' ] ) ;
2023-02-09 18:59:57 +03:00
}
2022-12-12 16:54:31 +03:00
}
2023-02-19 19:46:21 +03:00
if ( draftStatus ) {
const {
status ,
spoilerText ,
visibility ,
language ,
sensitive ,
poll ,
mediaAttachments ,
} = draftStatus ;
const composablePoll = ! ! poll ? . options && {
... poll ,
options : poll . options . map ( ( o ) => o ? . title || o ) ,
expiresIn : poll ? . expiresIn || expiresInFromExpiresAt ( poll . expiresAt ) ,
} ;
textareaRef . current . value = status ;
oninputTextarea ( ) ;
focusTextarea ( ) ;
2023-03-18 11:24:04 +03:00
if ( spoilerText ) spoilerTextRef . current . value = spoilerText ;
if ( visibility ) setVisibility ( visibility ) ;
2024-08-09 07:43:12 +03:00
setLanguage (
language ||
prefs [ 'posting:default:language' ] ? . toLowerCase ( ) ||
DEFAULT _LANG ,
) ;
2023-03-18 11:24:04 +03:00
if ( sensitive !== null ) setSensitive ( sensitive ) ;
if ( composablePoll ) setPoll ( composablePoll ) ;
if ( mediaAttachments ) setMediaAttachments ( mediaAttachments ) ;
2023-02-19 19:46:21 +03:00
}
2022-12-13 15:42:09 +03:00
} , [ draftStatus , editStatus , replyToStatus ] ) ;
2022-12-12 16:54:31 +03:00
2022-12-10 12:14:48 +03:00
const formRef = useRef ( ) ;
2024-08-13 10:26:23 +03:00
const beforeUnloadCopy = t ` You have unsaved changes. Discard this post? ` ;
2022-12-10 12:14:48 +03:00
const canClose = ( ) => {
2022-12-12 16:54:31 +03:00
const { value , dataset } = textareaRef . current ;
2022-12-13 15:42:09 +03:00
2022-12-20 20:03:24 +03:00
// check if loading
if ( uiState === 'loading' ) {
console . log ( 'canClose' , { uiState } ) ;
return false ;
}
2022-12-13 19:20:24 +03:00
// check for status and media attachments
2024-05-22 14:12:13 +03:00
const hasValue = ( value || '' )
. trim ( )
. replace ( /^\p{White_Space}+|\p{White_Space}+$/gu , '' ) ;
2022-12-13 19:20:24 +03:00
const hasMediaAttachments = mediaAttachments . length > 0 ;
2024-05-22 14:12:13 +03:00
if ( ! hasValue && ! hasMediaAttachments ) {
2022-12-13 16:54:16 +03:00
console . log ( 'canClose' , { value , mediaAttachments } ) ;
return true ;
}
2022-12-12 16:54:31 +03:00
2022-12-13 19:20:24 +03:00
// check if all media attachments have IDs
2022-12-14 16:48:17 +03:00
const hasIDMediaAttachments =
mediaAttachments . length > 0 &&
mediaAttachments . every ( ( media ) => media . id ) ;
2022-12-13 19:20:24 +03:00
if ( hasIDMediaAttachments ) {
console . log ( 'canClose' , { hasIDMediaAttachments } ) ;
return true ;
}
2022-12-13 15:42:09 +03:00
// check if status contains only "@acct", if replying
2023-01-11 08:28:42 +03:00
const isSelf = replyToStatus ? . account . id === currentAccountInfo . id ;
2022-12-13 16:54:16 +03:00
const hasOnlyAcct =
2022-12-13 15:42:09 +03:00
replyToStatus && value . trim ( ) === ` @ ${ replyToStatus . account . acct } ` ;
2022-12-15 19:53:04 +03:00
// TODO: check for mentions, or maybe just generic "@username<space>", including multiple mentions like "@username1<space>@username2<space>"
2022-12-13 16:54:16 +03:00
if ( ! isSelf && hasOnlyAcct ) {
console . log ( 'canClose' , { isSelf , hasOnlyAcct } ) ;
return true ;
}
2022-12-13 15:42:09 +03:00
2022-12-13 16:54:16 +03:00
// check if status is same with source
const sameWithSource = value === dataset ? . source ;
if ( sameWithSource ) {
console . log ( 'canClose' , { sameWithSource } ) ;
return true ;
}
2022-12-13 15:42:09 +03:00
2022-12-13 19:20:24 +03:00
console . log ( 'canClose' , {
value ,
hasMediaAttachments ,
hasIDMediaAttachments ,
2022-12-14 16:48:17 +03:00
poll ,
2022-12-13 19:20:24 +03:00
isSelf ,
hasOnlyAcct ,
sameWithSource ,
2022-12-20 08:27:14 +03:00
uiState ,
2022-12-13 19:20:24 +03:00
} ) ;
2022-12-13 16:54:16 +03:00
return false ;
} ;
2022-12-13 15:42:09 +03:00
2022-12-13 16:54:16 +03:00
const confirmClose = ( ) => {
2022-12-13 17:26:29 +03:00
if ( ! canClose ( ) ) {
2022-12-10 12:14:48 +03:00
const yes = confirm ( beforeUnloadCopy ) ;
return yes ;
}
return true ;
} ;
useEffect ( ( ) => {
// Show warning if user tries to close window with unsaved changes
const handleBeforeUnload = ( e ) => {
if ( ! canClose ( ) ) {
e . preventDefault ( ) ;
e . returnValue = beforeUnloadCopy ;
}
} ;
window . addEventListener ( 'beforeunload' , handleBeforeUnload , {
capture : true ,
} ) ;
return ( ) =>
window . removeEventListener ( 'beforeunload' , handleBeforeUnload , {
capture : true ,
} ) ;
} , [ ] ) ;
2022-12-23 11:45:02 +03:00
const getCharCount = ( ) => {
const { value } = textareaRef . current ;
const { value : spoilerText } = spoilerTextRef . current ;
return stringLength ( countableText ( value ) ) + stringLength ( spoilerText ) ;
} ;
const updateCharCount = ( ) => {
2023-01-04 14:03:11 +03:00
const count = getCharCount ( ) ;
states . composerCharacterCount = count ;
2022-12-23 11:45:02 +03:00
} ;
2023-01-04 14:03:11 +03:00
useEffect ( updateCharCount , [ ] ) ;
2022-12-23 11:45:02 +03:00
2023-12-20 16:02:22 +03:00
const supportsCloseWatcher = window . CloseWatcher ;
2023-03-08 09:49:52 +03:00
const escDownRef = useRef ( false ) ;
2023-01-01 14:41:42 +03:00
useHotkeys (
'esc' ,
( ) => {
2023-03-08 09:49:52 +03:00
escDownRef . current = true ;
// This won't be true if this event is already handled and not propagated 🤞
} ,
{
2023-12-20 16:02:22 +03:00
enabled : ! supportsCloseWatcher ,
2023-03-08 09:49:52 +03:00
enableOnFormTags : true ,
} ,
) ;
useHotkeys (
'esc' ,
( ) => {
if ( ! standalone && escDownRef . current && confirmClose ( ) ) {
2023-01-01 14:41:42 +03:00
onClose ( ) ;
}
2023-03-08 09:49:52 +03:00
escDownRef . current = false ;
2023-01-01 14:41:42 +03:00
} ,
{
2023-12-20 16:02:22 +03:00
enabled : ! supportsCloseWatcher ,
2023-01-01 14:41:42 +03:00
enableOnFormTags : true ,
2023-02-14 14:38:17 +03:00
// Use keyup because Esc keydown will close the confirm dialog on Safari
keyup : true ,
2023-10-26 06:16:34 +03:00
ignoreEventWhen : ( e ) => {
const modals = document . querySelectorAll ( '#modal-container > *' ) ;
const hasModal = ! ! modals ;
const hasOnlyComposer =
modals . length === 1 && modals [ 0 ] . querySelector ( '#compose-container' ) ;
return hasModal && ! hasOnlyComposer ;
} ,
2023-01-01 14:41:42 +03:00
} ,
) ;
2023-12-20 16:02:22 +03:00
useCloseWatcher ( ( ) => {
if ( ! standalone && confirmClose ( ) ) {
onClose ( ) ;
}
} , [ standalone , confirmClose , onClose ] ) ;
2023-01-01 14:41:42 +03:00
2023-01-13 10:30:09 +03:00
const prevBackgroundDraft = useRef ( { } ) ;
const draftKey = ( ) => {
const ns = getCurrentAccountNS ( ) ;
return ` ${ ns } # ${ UID . current } ` ;
} ;
const saveUnsavedDraft = ( ) => {
// Not enabling this for editing status
// I don't think this warrant a draft mode for a status that's already posted
// Maybe it could be a big edit change but it should be rare
if ( editStatus ) return ;
2024-05-24 07:30:20 +03:00
if ( states . composerState . minimized ) return ;
2023-01-13 10:30:09 +03:00
const key = draftKey ( ) ;
const backgroundDraft = {
key ,
replyTo : replyToStatus
? {
/ * S m a l l e r p a y l o a d o f r e p l y T o S t a t u s . R e a s o n s :
- No point storing whole thing
- Could have media attachments
- Could be deleted / edited later
* /
id : replyToStatus . id ,
account : {
id : replyToStatus . account . id ,
username : replyToStatus . account . username ,
acct : replyToStatus . account . acct ,
} ,
}
: null ,
draftStatus : {
uid : UID . current ,
status : textareaRef . current . value ,
spoilerText : spoilerTextRef . current . value ,
visibility ,
language ,
sensitive ,
poll ,
mediaAttachments ,
} ,
} ;
2024-01-06 07:31:25 +03:00
if (
! deepEqual ( backgroundDraft , prevBackgroundDraft . current ) &&
! canClose ( )
) {
2023-01-13 10:30:09 +03:00
console . debug ( 'not equal' , backgroundDraft , prevBackgroundDraft . current ) ;
db . drafts
. set ( key , {
... backgroundDraft ,
state : 'unsaved' ,
updatedAt : Date . now ( ) ,
} )
. then ( ( ) => {
console . debug ( 'DRAFT saved' , key , backgroundDraft ) ;
} )
. catch ( ( e ) => {
console . error ( 'DRAFT failed' , key , e ) ;
} ) ;
prevBackgroundDraft . current = structuredClone ( backgroundDraft ) ;
}
} ;
useInterval ( saveUnsavedDraft , 5000 ) ; // background save every 5s
useEffect ( ( ) => {
saveUnsavedDraft ( ) ;
// If unmounted, means user discarded the draft
// Also means pop-out 🙈, but it's okay because the pop-out will persist the ID and re-create the draft
return ( ) => {
db . drafts . del ( draftKey ( ) ) ;
} ;
} , [ ] ) ;
2023-01-16 04:42:32 +03:00
useEffect ( ( ) => {
const handleItems = ( e ) => {
const { items } = e . clipboardData || e . dataTransfer ;
const files = [ ] ;
for ( let i = 0 ; i < items . length ; i ++ ) {
const item = items [ i ] ;
if ( item . kind === 'file' ) {
const file = item . getAsFile ( ) ;
if ( file && supportedMimeTypes . includes ( file . type ) ) {
files . push ( file ) ;
}
}
}
2023-03-18 11:24:04 +03:00
if ( files . length > 0 && mediaAttachments . length >= maxMediaAttachments ) {
2024-08-13 10:26:23 +03:00
alert (
plural ( maxMediaAttachments , {
one : 'You can only attach up to 1 file.' ,
other : 'You can only attach up to # files.' ,
} ) ,
) ;
2023-03-18 11:24:04 +03:00
return ;
}
2023-01-16 04:42:32 +03:00
console . log ( { files } ) ;
if ( files . length > 0 ) {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
// Auto-cut-off files to avoid exceeding maxMediaAttachments
const max = maxMediaAttachments - mediaAttachments . length ;
const allowedFiles = files . slice ( 0 , max ) ;
if ( allowedFiles . length <= 0 ) {
2024-08-13 10:26:23 +03:00
alert (
plural ( maxMediaAttachments , {
one : 'You can only attach up to 1 file.' ,
other : 'You can only attach up to # files.' ,
} ) ,
) ;
2023-01-16 04:42:32 +03:00
return ;
}
const mediaFiles = allowedFiles . map ( ( file ) => ( {
file ,
type : file . type ,
size : file . size ,
url : URL . createObjectURL ( file ) ,
id : null ,
description : null ,
} ) ) ;
setMediaAttachments ( [ ... mediaAttachments , ... mediaFiles ] ) ;
}
} ;
window . addEventListener ( 'paste' , handleItems ) ;
const handleDragover = ( e ) => {
// Prevent default if there's files
if ( e . dataTransfer . items . length > 0 ) {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
}
} ;
window . addEventListener ( 'dragover' , handleDragover ) ;
window . addEventListener ( 'drop' , handleItems ) ;
return ( ) => {
window . removeEventListener ( 'paste' , handleItems ) ;
window . removeEventListener ( 'dragover' , handleDragover ) ;
window . removeEventListener ( 'drop' , handleItems ) ;
} ;
} , [ mediaAttachments ] ) ;
2024-05-25 06:06:58 +03:00
const [ showMentionPicker , setShowMentionPicker ] = useState ( false ) ;
2023-03-24 17:30:05 +03:00
const [ showEmoji2Picker , setShowEmoji2Picker ] = useState ( false ) ;
2024-04-02 12:51:48 +03:00
const [ showGIFPicker , setShowGIFPicker ] = useState ( false ) ;
2023-03-24 17:30:05 +03:00
2024-05-28 12:59:17 +03:00
const [ autoDetectedLanguages , setAutoDetectedLanguages ] = useState ( null ) ;
2023-10-30 18:50:15 +03:00
const [ topSupportedLanguages , restSupportedLanguages ] = useMemo ( ( ) => {
const topLanguages = [ ] ;
const restLanguages = [ ] ;
const { contentTranslationHideLanguages = [ ] } = states . settings ;
supportedLanguages . forEach ( ( l ) => {
const [ code ] = l ;
if (
code === language ||
code === prevLanguage . current ||
code === DEFAULT _LANG ||
2024-05-28 12:59:17 +03:00
contentTranslationHideLanguages . includes ( code ) ||
( autoDetectedLanguages ? . length && autoDetectedLanguages . includes ( code ) )
2023-10-30 18:50:15 +03:00
) {
topLanguages . push ( l ) ;
} else {
restLanguages . push ( l ) ;
}
} ) ;
topLanguages . sort ( ( [ codeA , commonA ] , [ codeB , commonB ] ) => {
if ( codeA === language ) return - 1 ;
if ( codeB === language ) return 1 ;
return commonA . localeCompare ( commonB ) ;
} ) ;
restLanguages . sort ( ( [ codeA , commonA ] , [ codeB , commonB ] ) =>
commonA . localeCompare ( commonB ) ,
) ;
return [ topLanguages , restLanguages ] ;
2024-05-28 12:59:17 +03:00
} , [ language , autoDetectedLanguages ] ) ;
2023-10-30 18:50:15 +03:00
2024-03-13 08:30:58 +03:00
const replyToStatusMonthsAgo = useMemo (
( ) =>
! ! replyToStatus ? . createdAt &&
Math . floor (
( Date . now ( ) - new Date ( replyToStatus . createdAt ) ) /
( 1000 * 60 * 60 * 24 * 30 ) ,
) ,
[ replyToStatus ] ,
) ;
2024-05-24 07:30:20 +03:00
const onMinimize = ( ) => {
saveUnsavedDraft ( ) ;
states . composerState . minimized = true ;
} ;
2022-12-10 12:14:48 +03:00
return (
2023-03-23 11:13:22 +03:00
< div id = "compose-container-outer" >
< div id = "compose-container" class = { standalone ? 'standalone' : '' } >
< div class = "compose-top" >
{ currentAccountInfo ? . avatarStatic && (
2023-09-03 14:48:36 +03:00
// <Avatar
// url={currentAccountInfo.avatarStatic}
// size="xl"
// alt={currentAccountInfo.username}
// squircle={currentAccountInfo?.bot}
// />
< AccountBlock
account = { currentAccountInfo }
accountInstance = { currentAccount . instanceURL }
hideDisplayName
2023-11-12 06:01:44 +03:00
useAvatarStatic
2023-03-23 11:13:22 +03:00
/ >
) }
{ ! standalone ? (
2024-05-25 08:39:11 +03:00
< span class = "compose-controls" >
2023-03-23 11:13:22 +03:00
< button
type = "button"
2024-05-25 08:39:11 +03:00
class = "plain4 pop-button"
2023-03-23 11:13:22 +03:00
disabled = { uiState === 'loading' }
onClick = { ( ) => {
// If there are non-ID media attachments (not yet uploaded), show confirmation dialog because they are not going to be passed to the new window
// const containNonIDMediaAttachments =
// mediaAttachments.length > 0 &&
// mediaAttachments.some((media) => !media.id);
// if (containNonIDMediaAttachments) {
// const yes = confirm(
// 'You have media attachments that are not yet uploaded. Opening a new window will discard them and you will need to re-attach them. Are you sure you want to continue?',
// );
// if (!yes) {
// return;
// }
// }
2023-01-02 07:03:06 +03:00
2023-03-23 11:13:22 +03:00
// const mediaAttachmentsWithIDs = mediaAttachments.filter(
// (media) => media.id,
// );
2022-12-13 15:42:09 +03:00
2023-03-23 11:13:22 +03:00
const newWin = openCompose ( {
editStatus ,
replyToStatus ,
draftStatus : {
uid : UID . current ,
status : textareaRef . current . value ,
spoilerText : spoilerTextRef . current . value ,
visibility ,
language ,
sensitive ,
poll ,
mediaAttachments ,
} ,
} ) ;
2022-12-13 16:54:16 +03:00
2023-03-23 11:13:22 +03:00
if ( ! newWin ) {
return ;
}
2022-12-13 16:54:16 +03:00
2022-12-13 15:42:09 +03:00
onClose ( ) ;
2023-03-23 11:13:22 +03:00
} }
>
2024-08-13 10:26:23 +03:00
< Icon icon = "popout" alt = { t ` Pop out ` } / >
2024-05-25 08:39:11 +03:00
< / button >
2024-05-24 07:30:20 +03:00
< button
type = "button"
2024-05-25 08:39:11 +03:00
class = "plain4 min-button"
2024-05-24 07:30:20 +03:00
onClick = { onMinimize }
>
2024-08-13 10:26:23 +03:00
< Icon icon = "minimize" alt = { t ` Minimize ` } / >
2024-05-24 07:30:20 +03:00
< / button > { ' ' }
2023-03-23 11:13:22 +03:00
< button
type = "button"
class = "light close-button"
disabled = { uiState === 'loading' }
onClick = { ( ) => {
if ( confirmClose ( ) ) {
onClose ( ) ;
}
} }
>
2024-08-13 10:26:23 +03:00
< Icon icon = "x" alt = { t ` Close ` } / >
2023-03-23 11:13:22 +03:00
< / button >
< / span >
) : (
hasOpener && (
< button
type = "button"
class = "light pop-button"
disabled = { uiState === 'loading' }
onClick = { ( ) => {
// If there are non-ID media attachments (not yet uploaded), show confirmation dialog because they are not going to be passed to the new window
// const containNonIDMediaAttachments =
// mediaAttachments.length > 0 &&
// mediaAttachments.some((media) => !media.id);
// if (containNonIDMediaAttachments) {
// const yes = confirm(
// 'You have media attachments that are not yet uploaded. Opening a new window will discard them and you will need to re-attach them. Are you sure you want to continue?',
// );
// if (!yes) {
// return;
// }
// }
2022-12-13 15:42:09 +03:00
2023-03-23 11:13:22 +03:00
if ( ! window . opener ) {
2024-08-13 10:26:23 +03:00
alert ( t ` Looks like you closed the parent window. ` ) ;
2023-03-23 11:13:22 +03:00
return ;
}
2022-12-13 15:42:09 +03:00
2023-03-23 11:13:22 +03:00
if ( window . opener . _ _STATES _ _ . showCompose ) {
2024-05-25 04:15:43 +03:00
if ( window . opener . _ _STATES _ _ . composerState ? . publishing ) {
alert (
2024-08-13 10:26:23 +03:00
t ` Looks like you already have a compose field open in the parent window and currently publishing. Please wait for it to be done and try again later. ` ,
2024-05-25 04:15:43 +03:00
) ;
return ;
}
2024-08-13 10:26:23 +03:00
let confirmText = t ` Looks like you already have a compose field open in the parent window. Popping in this window will discard the changes you made in the parent window. Continue? ` ;
2024-05-25 04:15:43 +03:00
const yes = confirm ( confirmText ) ;
2023-03-23 11:13:22 +03:00
if ( ! yes ) return ;
}
2022-12-15 08:03:20 +03:00
2023-03-23 11:13:22 +03:00
// const mediaAttachmentsWithIDs = mediaAttachments.filter(
// (media) => media.id,
// );
2022-12-13 15:42:09 +03:00
2023-03-23 11:13:22 +03:00
onClose ( {
fn : ( ) => {
const passData = {
editStatus ,
replyToStatus ,
draftStatus : {
uid : UID . current ,
status : textareaRef . current . value ,
spoilerText : spoilerTextRef . current . value ,
visibility ,
language ,
sensitive ,
poll ,
mediaAttachments ,
} ,
} ;
window . opener . _ _COMPOSE _ _ = passData ; // Pass it here instead of `showCompose` due to some weird proxy issue again
2024-05-23 09:14:23 +03:00
if ( window . opener . _ _STATES _ _ . showCompose ) {
window . opener . _ _STATES _ _ . showCompose = false ;
setTimeout ( ( ) => {
window . opener . _ _STATES _ _ . showCompose = true ;
} , 10 ) ;
} else {
window . opener . _ _STATES _ _ . showCompose = true ;
}
2024-05-24 07:30:20 +03:00
if ( window . opener . _ _STATES _ _ . composerState . minimized ) {
// Maximize it
window . opener . _ _STATES _ _ . composerState . minimized = false ;
}
2023-03-23 11:13:22 +03:00
} ,
} ) ;
} }
>
2024-08-13 10:26:23 +03:00
< Icon icon = "popin" alt = { t ` Pop in ` } / >
2023-03-23 11:13:22 +03:00
< / button >
)
) }
< / div >
{ ! ! replyToStatus && (
< div class = "status-preview" >
< Status status = { replyToStatus } size = "s" previewMode / >
< div class = "status-preview-legend reply-to" >
2024-08-13 10:26:23 +03:00
{ replyToStatusMonthsAgo > 0 ? (
< Trans >
Replying to @
{ replyToStatus . account . acct || replyToStatus . account . username }
& rsquo ; s post (
2024-03-13 08:30:58 +03:00
< strong >
{ rtf . format ( - replyToStatusMonthsAgo , 'month' ) }
< / strong >
)
2024-08-13 10:26:23 +03:00
< / Trans >
) : (
< Trans >
Replying to @
{ replyToStatus . account . acct || replyToStatus . account . username }
& rsquo ; s post
< / Trans >
2024-03-13 08:30:58 +03:00
) }
2023-03-23 11:13:22 +03:00
< / div >
< / div >
2022-12-13 15:42:09 +03:00
) }
2023-03-23 11:13:22 +03:00
{ ! ! editStatus && (
< div class = "status-preview" >
< Status status = { editStatus } size = "s" previewMode / >
2024-08-13 10:26:23 +03:00
< div class = "status-preview-legend" >
< Trans > Editing source post < / Trans >
< / div >
2022-12-13 15:42:09 +03:00
< / div >
2023-03-23 11:13:22 +03:00
) }
< form
ref = { formRef }
2023-03-23 20:26:49 +03:00
class = { ` form-visibility- ${ visibility } ` }
2023-03-23 11:13:22 +03:00
style = { {
pointerEvents : uiState === 'loading' ? 'none' : 'auto' ,
opacity : uiState === 'loading' ? 0.5 : 1 ,
} }
onKeyDown = { ( e ) => {
if ( e . key === 'Enter' && ( e . ctrlKey || e . metaKey ) ) {
formRef . current . dispatchEvent (
new Event ( 'submit' , { cancelable : true } ) ,
) ;
}
} }
onSubmit = { ( e ) => {
e . preventDefault ( ) ;
2022-12-10 12:14:48 +03:00
2023-03-23 11:13:22 +03:00
const formData = new FormData ( e . target ) ;
const entries = Object . fromEntries ( formData . entries ( ) ) ;
console . log ( 'ENTRIES' , entries ) ;
let { status , visibility , sensitive , spoilerText } = entries ;
2022-12-10 12:14:48 +03:00
2023-03-23 11:13:22 +03:00
// Pre-cleanup
sensitive = sensitive === 'on' ; // checkboxes return "on" if checked
2022-12-10 12:14:48 +03:00
2023-03-23 11:13:22 +03:00
// Validation
/ * L e t t h e b a c k e n d v a l i d a t e t h i s
2022-12-10 15:46:56 +03:00
if ( stringLength ( status ) > maxCharacters ) {
2022-12-10 12:14:48 +03:00
alert ( ` Status is too long! Max characters: ${ maxCharacters } ` ) ;
return ;
}
2022-12-10 15:46:56 +03:00
if (
sensitive &&
stringLength ( status ) + stringLength ( spoilerText ) > maxCharacters
) {
2022-12-10 12:14:48 +03:00
alert (
` Status and content warning is too long! Max characters: ${ maxCharacters } ` ,
) ;
return ;
}
2022-12-23 11:45:02 +03:00
* /
2023-03-23 11:13:22 +03:00
if ( poll ) {
if ( poll . options . length < 2 ) {
2024-08-13 10:26:23 +03:00
alert ( t ` Poll must have at least 2 options ` ) ;
2023-03-23 11:13:22 +03:00
return ;
}
if ( poll . options . some ( ( option ) => option === '' ) ) {
2024-08-13 10:26:23 +03:00
alert ( t ` Some poll choices are empty ` ) ;
2023-03-23 11:13:22 +03:00
return ;
}
2022-12-14 16:48:17 +03:00
}
2023-03-23 11:13:22 +03:00
// TODO: check for URLs and use `charactersReservedPerUrl` to calculate max characters
2022-12-10 12:14:48 +03:00
2023-03-23 11:13:22 +03:00
if ( mediaAttachments . length > 0 ) {
// If there are media attachments, check if they have no descriptions
const hasNoDescriptions = mediaAttachments . some (
( media ) => ! media . description ? . trim ? . ( ) ,
) ;
if ( hasNoDescriptions ) {
const yes = confirm (
2024-08-13 10:26:23 +03:00
t ` Some media have no descriptions. Continue? ` ,
2023-03-23 11:13:22 +03:00
) ;
if ( ! yes ) return ;
}
2023-03-14 15:42:37 +03:00
}
2023-03-23 11:13:22 +03:00
// Post-cleanup
spoilerText = ( sensitive && spoilerText ) || undefined ;
status = status === '' ? undefined : status ;
2022-12-10 12:14:48 +03:00
2024-05-24 07:30:20 +03:00
// states.composerState.minimized = true;
states . composerState . publishing = true ;
2023-03-23 11:13:22 +03:00
setUIState ( 'loading' ) ;
( async ( ) => {
try {
console . log ( 'MEDIA ATTACHMENTS' , mediaAttachments ) ;
if ( mediaAttachments . length > 0 ) {
// Upload media attachments first
const mediaPromises = mediaAttachments . map ( ( attachment ) => {
const { file , description , id } = attachment ;
console . log ( 'UPLOADING' , attachment ) ;
if ( id ) {
// If already uploaded
return attachment ;
} else {
const params = removeNullUndefined ( {
file ,
description ,
2022-12-25 18:28:55 +03:00
} ) ;
2023-10-12 07:48:09 +03:00
return masto . v2 . media . create ( params ) . then ( ( res ) => {
if ( res . id ) {
attachment . id = res . id ;
}
return res ;
} ) ;
2022-12-10 12:14:48 +03:00
}
} ) ;
2023-03-23 11:13:22 +03:00
const results = await Promise . allSettled ( mediaPromises ) ;
2022-12-10 12:14:48 +03:00
2023-03-23 11:13:22 +03:00
// If any failed, return
if (
results . some ( ( result ) => {
return result . status === 'rejected' || ! result . value ? . id ;
} )
) {
2024-05-24 07:30:20 +03:00
states . composerState . publishing = false ;
states . composerState . publishingError = true ;
2023-03-23 11:13:22 +03:00
setUIState ( 'error' ) ;
// Alert all the reasons
results . forEach ( ( result ) => {
if ( result . status === 'rejected' ) {
console . error ( result ) ;
2024-08-13 10:26:23 +03:00
alert ( result . reason || t ` Attachment # ${ i } failed ` ) ;
2023-03-23 11:13:22 +03:00
}
} ) ;
return ;
}
2022-12-10 12:14:48 +03:00
2023-03-23 11:13:22 +03:00
console . log ( { results , mediaAttachments } ) ;
}
/ * N O T E :
2022-12-26 15:22:13 +03:00
Using snakecase here because masto . js 's `isObject` returns false for `params`, ONLY happens when opening in pop-out window. This is maybe due to `window.masto` variable being passed from the parent window. The check that failed is `x.constructor === Object`, so maybe the `Object` in new window is different than parent window' s ?
Code : https : //github.com/neet/masto.js/blob/dd0d649067b6a2b6e60fbb0a96597c373a255b00/src/serializers/is-object.ts#L2
2023-10-12 07:48:09 +03:00
// TODO: Note above is no longer true in Masto.js v6. Revisit this.
2022-12-26 15:22:13 +03:00
* /
2023-03-23 11:13:22 +03:00
let params = {
status ,
// spoilerText,
spoiler _text : spoilerText ,
language ,
sensitive ,
poll ,
// mediaIds: mediaAttachments.map((attachment) => attachment.id),
media _ids : mediaAttachments . map (
( attachment ) => attachment . id ,
) ,
} ;
if ( editStatus && supports ( '@mastodon/edit-media-attributes' ) ) {
params . media _attributes = mediaAttachments . map (
( attachment ) => {
return {
id : attachment . id ,
description : attachment . description ,
// focus
// thumbnail
} ;
} ,
) ;
} else if ( ! editStatus ) {
params . visibility = visibility ;
// params.inReplyToId = replyToStatus?.id || undefined;
params . in _reply _to _id = replyToStatus ? . id || undefined ;
}
params = removeNullUndefined ( params ) ;
console . log ( 'POST' , params ) ;
2022-12-12 16:54:31 +03:00
2023-03-23 11:13:22 +03:00
let newStatus ;
if ( editStatus ) {
2023-10-12 07:48:09 +03:00
newStatus = await masto . v1 . statuses
. $select ( editStatus . id )
. update ( params ) ;
2023-03-23 11:13:22 +03:00
saveStatus ( newStatus , instance , {
skipThreading : true ,
} ) ;
} else {
2023-05-23 14:16:24 +03:00
try {
newStatus = await masto . v1 . statuses . create ( params , {
2024-04-13 12:07:28 +03:00
requestInit : {
headers : {
'Idempotency-Key' : UID . current ,
} ,
} ,
2023-05-23 14:16:24 +03:00
} ) ;
} catch ( _ ) {
// If idempotency key fails, try again without it
newStatus = await masto . v1 . statuses . create ( params ) ;
}
2023-03-23 11:13:22 +03:00
}
2024-05-24 07:30:20 +03:00
states . composerState . minimized = false ;
states . composerState . publishing = false ;
2023-03-23 11:13:22 +03:00
setUIState ( 'default' ) ;
// Close
onClose ( {
2023-09-28 10:45:38 +03:00
// type: post, reply, edit
type : editStatus ? 'edit' : replyToStatus ? 'reply' : 'post' ,
2023-03-23 11:13:22 +03:00
newStatus ,
instance ,
2023-01-11 09:44:20 +03:00
} ) ;
2023-03-23 11:13:22 +03:00
} catch ( e ) {
2024-05-24 07:30:20 +03:00
states . composerState . publishing = false ;
states . composerState . publishingError = true ;
2023-03-23 11:13:22 +03:00
console . error ( e ) ;
alert ( e ? . reason || e ) ;
setUIState ( 'error' ) ;
2022-12-12 16:54:31 +03:00
}
2023-03-23 11:13:22 +03:00
} ) ( ) ;
} }
>
< div class = "toolbar stretch" >
2022-12-10 12:14:48 +03:00
< input
2023-03-23 11:13:22 +03:00
ref = { spoilerTextRef }
type = "text"
name = "spoilerText"
2024-08-13 10:26:23 +03:00
placeholder = { t ` Content warning ` }
2023-01-03 12:41:18 +03:00
disabled = { uiState === 'loading' }
2023-03-23 11:13:22 +03:00
class = "spoiler-text-field"
lang = { language }
spellCheck = "true"
2023-08-24 04:12:00 +03:00
dir = "auto"
2023-03-23 11:13:22 +03:00
style = { {
opacity : sensitive ? 1 : 0 ,
pointerEvents : sensitive ? 'auto' : 'none' ,
2022-12-10 12:14:48 +03:00
} }
2023-03-23 11:13:22 +03:00
onInput = { ( ) => {
updateCharCount ( ) ;
2022-12-10 12:14:48 +03:00
} }
2023-03-23 11:13:22 +03:00
/ >
< label
class = { ` toolbar-button ${ sensitive ? 'highlight' : '' } ` }
2024-08-13 10:26:23 +03:00
title = { t ` Content warning or sensitive media ` }
2022-12-10 12:14:48 +03:00
>
2023-01-22 14:33:45 +03:00
< input
name = "sensitive"
type = "checkbox"
checked = { sensitive }
disabled = { uiState === 'loading' }
onChange = { ( e ) => {
const sensitive = e . target . checked ;
setSensitive ( sensitive ) ;
2023-03-23 11:13:22 +03:00
if ( sensitive ) {
spoilerTextRef . current ? . focus ( ) ;
} else {
textareaRef . current ? . focus ( ) ;
}
2023-01-22 14:33:45 +03:00
} }
2023-03-23 11:13:22 +03:00
/ >
2023-01-22 14:33:45 +03:00
< Icon icon = { ` eye- ${ sensitive ? 'close' : 'open' } ` } / >
2023-03-23 11:13:22 +03:00
< / label > { ' ' }
< label
class = { ` toolbar-button ${
visibility !== 'public' && ! sensitive ? 'show-field' : ''
} $ { visibility !== 'public' ? 'highlight' : '' } ` }
2024-08-31 17:55:23 +03:00
title = { visibility }
2023-03-23 11:13:22 +03:00
>
< Icon icon = { visibilityIconsMap [ visibility ] } alt = { visibility } / >
< select
name = "visibility"
value = { visibility }
onChange = { ( e ) => {
setVisibility ( e . target . value ) ;
} }
disabled = { uiState === 'loading' || ! ! editStatus }
2024-08-04 08:32:30 +03:00
dir = "auto"
2023-03-23 11:13:22 +03:00
>
< option value = "public" >
2024-08-13 10:26:23 +03:00
< Trans > Public < / Trans >
< / option >
< option value = "unlisted" >
< Trans > Unlisted < / Trans >
< / option >
< option value = "private" >
< Trans > Followers only < / Trans >
< / option >
< option value = "direct" >
< Trans > Private mention < / Trans >
2023-03-23 11:13:22 +03:00
< / option >
< / select >
< / label > { ' ' }
2022-12-10 12:14:48 +03:00
< / div >
2023-03-23 11:13:22 +03:00
< Textarea
ref = { textareaRef }
placeholder = {
replyToStatus
2024-08-13 10:26:23 +03:00
? t ` Post your reply `
2023-03-23 11:13:22 +03:00
: editStatus
2024-08-13 10:26:23 +03:00
? t ` Edit your post `
: t ` What are you doing? `
2023-03-23 11:13:22 +03:00
}
required = { mediaAttachments ? . length === 0 }
2022-12-14 16:48:17 +03:00
disabled = { uiState === 'loading' }
2023-03-23 11:13:22 +03:00
lang = { language }
onInput = { ( ) => {
updateCharCount ( ) ;
} }
maxCharacters = { maxCharacters }
performSearch = { ( params ) => {
2023-09-27 08:37:12 +03:00
const { type , q , limit } = params ;
if ( type === 'accounts' ) {
2023-10-12 07:48:09 +03:00
return masto . v1 . accounts . search . list ( {
2023-09-27 08:37:12 +03:00
q ,
limit ,
resolve : false ,
} ) ;
}
2023-10-12 07:48:09 +03:00
return masto . v2 . search . fetch ( params ) ;
2022-12-14 16:48:17 +03:00
} }
2024-05-22 14:12:13 +03:00
onTrigger = { ( action ) => {
if ( action ? . name === 'custom-emojis' ) {
setShowEmoji2Picker ( {
defaultSearchTerm : action ? . defaultSearchTerm || null ,
} ) ;
2024-05-25 06:06:58 +03:00
} else if ( action ? . name === 'mention' ) {
setShowMentionPicker ( {
defaultSearchTerm : action ? . defaultSearchTerm || null ,
} ) ;
2024-05-28 12:59:17 +03:00
} else if (
action ? . name === 'auto-detect-language' &&
action ? . languages
) {
setAutoDetectedLanguages ( action . languages ) ;
2024-05-22 14:12:13 +03:00
}
} }
2022-12-14 16:48:17 +03:00
/ >
2023-03-23 11:13:22 +03:00
{ mediaAttachments ? . length > 0 && (
< div class = "media-attachments" >
{ mediaAttachments . map ( ( attachment , i ) => {
const { id , file } = attachment ;
const fileID = file ? . size + file ? . type + file ? . name ;
return (
< MediaAttachment
key = { id || fileID || i }
attachment = { attachment }
disabled = { uiState === 'loading' }
lang = { language }
onDescriptionChange = { ( value ) => {
setMediaAttachments ( ( attachments ) => {
const newAttachments = [ ... attachments ] ;
newAttachments [ i ] . description = value ;
return newAttachments ;
} ) ;
} }
onRemove = { ( ) => {
setMediaAttachments ( ( attachments ) => {
return attachments . filter ( ( _ , j ) => j !== i ) ;
} ) ;
} }
/ >
) ;
} ) }
< label class = "media-sensitive" >
< input
name = "sensitive"
type = "checkbox"
checked = { sensitive }
disabled = { uiState === 'loading' }
onChange = { ( e ) => {
const sensitive = e . target . checked ;
setSensitive ( sensitive ) ;
} }
/ > { ' ' }
2024-08-13 10:26:23 +03:00
< span >
< Trans > Mark media as sensitive < / Trans >
< / span > { ' ' }
2023-03-23 11:13:22 +03:00
< Icon icon = { ` eye- ${ sensitive ? 'close' : 'open' } ` } / >
< / label >
< / div >
) }
{ ! ! poll && (
< Poll
lang = { language }
maxOptions = { maxOptions }
maxExpiration = { maxExpiration }
minExpiration = { minExpiration }
maxCharactersPerOption = { maxCharactersPerOption }
poll = { poll }
disabled = { uiState === 'loading' }
onInput = { ( poll ) => {
if ( poll ) {
const newPoll = { ... poll } ;
setPoll ( newPoll ) ;
2022-12-14 16:48:17 +03:00
} else {
2023-03-23 11:13:22 +03:00
setPoll ( null ) ;
2022-12-10 12:14:48 +03:00
}
2022-12-14 16:48:17 +03:00
} }
/ >
2022-12-23 11:45:02 +03:00
) }
2023-03-23 20:26:49 +03:00
< div
class = "toolbar wrap"
style = { {
justifyContent : 'flex-end' ,
} }
>
2023-03-24 17:30:05 +03:00
< span >
< label class = "toolbar-button" >
< input
type = "file"
accept = { supportedMimeTypes . join ( ',' ) }
multiple = { mediaAttachments . length < maxMediaAttachments - 1 }
disabled = {
uiState === 'loading' ||
mediaAttachments . length >= maxMediaAttachments ||
! ! poll
}
onChange = { ( e ) => {
const files = e . target . files ;
if ( ! files ) return ;
const mediaFiles = Array . from ( files ) . map ( ( file ) => ( {
file ,
type : file . type ,
size : file . size ,
url : URL . createObjectURL ( file ) ,
id : null , // indicate uploaded state
description : null ,
} ) ) ;
console . log ( 'MEDIA ATTACHMENTS' , files , mediaFiles ) ;
// Validate max media attachments
if (
mediaAttachments . length + mediaFiles . length >
maxMediaAttachments
) {
alert (
2024-08-13 10:26:23 +03:00
plural ( maxMediaAttachments , {
one : 'You can only attach up to 1 file.' ,
other : 'You can only attach up to # files.' ,
} ) ,
2023-03-24 17:30:05 +03:00
) ;
} else {
setMediaAttachments ( ( attachments ) => {
return attachments . concat ( mediaFiles ) ;
} ) ;
}
// Reset
e . target . value = '' ;
} }
/ >
< Icon icon = "attachment" / >
2024-06-03 13:01:49 +03:00
< / label >
2024-04-18 18:12:29 +03:00
{ /* If maxOptions is not defined or defined and is greater than 1, show poll button */ }
{ maxOptions == null ||
( maxOptions > 1 && (
< >
< button
type = "button"
class = "toolbar-button"
disabled = {
uiState === 'loading' ||
! ! poll ||
! ! mediaAttachments . length
}
onClick = { ( ) => {
setPoll ( {
options : [ '' , '' ] ,
expiresIn : 24 * 60 * 60 , // 1 day
multiple : false ,
} ) ;
} }
>
2024-08-13 10:26:23 +03:00
< Icon icon = "poll" alt = { t ` Add poll ` } / >
2024-06-03 13:01:49 +03:00
< / button >
2024-04-18 18:12:29 +03:00
< / >
) ) }
2024-05-25 06:06:58 +03:00
{ / * < b u t t o n
type = "button"
class = "toolbar-button"
disabled = { uiState === 'loading' }
onClick = { ( ) => {
setShowMentionPicker ( true ) ;
} }
>
< Icon icon = "at" / >
< / button > * / }
2023-03-24 17:30:05 +03:00
< button
type = "button"
class = "toolbar-button"
disabled = { uiState === 'loading' }
onClick = { ( ) => {
setShowEmoji2Picker ( true ) ;
} }
>
2024-08-13 10:26:23 +03:00
< Icon icon = "emoji2" alt = { t ` Add custom emoji ` } / >
2023-03-24 17:30:05 +03:00
< / button >
2024-04-02 12:51:48 +03:00
{ ! ! states . settings . composerGIFPicker && (
< button
type = "button"
class = "toolbar-button gif-picker-button"
2024-06-01 06:51:58 +03:00
disabled = {
uiState === 'loading' ||
mediaAttachments . length >= maxMediaAttachments ||
! ! poll
}
2024-04-02 12:51:48 +03:00
onClick = { ( ) => {
setShowGIFPicker ( true ) ;
} }
>
< span > GIF < / span >
< / button >
) }
2023-03-24 17:30:05 +03:00
< / span >
2023-03-23 11:13:22 +03:00
< div class = "spacer" / >
2023-03-23 20:26:49 +03:00
{ uiState === 'loading' ? (
< Loader abrupt / >
) : (
< CharCountMeter
maxCharacters = { maxCharacters }
hidden = { uiState === 'loading' }
/ >
2023-03-23 11:13:22 +03:00
) }
< label
class = { ` toolbar-button ${
2024-05-28 12:59:17 +03:00
language !== prevLanguage . current ||
( autoDetectedLanguages ? . length &&
2024-05-28 16:03:05 +03:00
! autoDetectedLanguages . includes ( language ) )
2024-05-28 12:59:17 +03:00
? 'highlight'
: ''
2023-03-23 11:13:22 +03:00
} ` }
>
< span class = "icon-text" >
{ supportedLanguagesMap [ language ] ? . native }
< / span >
< select
name = "language"
value = { language }
onChange = { ( e ) => {
const { value } = e . target ;
setLanguage ( value || DEFAULT _LANG ) ;
store . session . set ( 'currentLanguage' , value || DEFAULT _LANG ) ;
} }
disabled = { uiState === 'loading' }
2024-08-04 08:32:30 +03:00
dir = "auto"
2023-03-23 11:13:22 +03:00
>
2024-08-13 10:26:23 +03:00
{ topSupportedLanguages . map ( ( [ code , common , native ] ) => {
const commonText = localeCode2Text ( {
code ,
fallback : common ,
} ) ;
2024-08-16 14:17:57 +03:00
const showCommon = commonText !== native ;
2024-08-13 10:26:23 +03:00
return (
< option value = { code } key = { code } >
2024-08-16 14:17:57 +03:00
{ showCommon ? ` ${ native } - ${ commonText } ` : commonText }
2024-08-13 10:26:23 +03:00
< / option >
) ;
} ) }
2023-10-30 18:50:15 +03:00
< hr / >
2024-08-13 10:26:23 +03:00
{ restSupportedLanguages . map ( ( [ code , common , native ] ) => {
const commonText = localeCode2Text ( {
code ,
fallback : common ,
} ) ;
2024-08-16 14:17:57 +03:00
const showCommon = commonText !== native ;
2024-08-13 10:26:23 +03:00
return (
< option value = { code } key = { code } >
2024-08-16 14:17:57 +03:00
{ showCommon ? ` ${ native } - ${ commonText } ` : commonText }
2024-08-13 10:26:23 +03:00
< / option >
) ;
} ) }
2023-03-23 11:13:22 +03:00
< / select >
< / label > { ' ' }
< button
type = "submit"
class = "large"
2022-12-27 13:09:23 +03:00
disabled = { uiState === 'loading' }
>
2024-08-21 09:37:34 +03:00
{ replyToStatus
? t ` Reply `
: editStatus
? t ` Update `
: t ( {
message : 'Post' ,
context : 'Submit button in composer' ,
} ) }
2023-03-23 11:13:22 +03:00
< / button >
< / div >
< / form >
< / div >
2024-05-25 06:06:58 +03:00
{ showMentionPicker && (
< Modal
onClick = { ( e ) => {
if ( e . target === e . currentTarget ) {
setShowMentionPicker ( false ) ;
}
} }
>
< MentionModal
masto = { masto }
instance = { instance }
onClose = { ( ) => {
setShowMentionPicker ( false ) ;
} }
defaultSearchTerm = { showMentionPicker ? . defaultSearchTerm }
onSelect = { ( socialAddress ) => {
const textarea = textareaRef . current ;
if ( ! textarea ) return ;
const { selectionStart , selectionEnd } = textarea ;
const text = textarea . value ;
const textBeforeMention = text . slice ( 0 , selectionStart ) ;
const spaceBeforeMention = textBeforeMention
? /[\s\t\n\r]$/ . test ( textBeforeMention )
? ''
: ' '
: '' ;
const textAfterMention = text . slice ( selectionEnd ) ;
const spaceAfterMention = /^[\s\t\n\r]/ . test ( textAfterMention )
? ''
: ' ' ;
const newText =
textBeforeMention +
spaceBeforeMention +
'@' +
socialAddress +
spaceAfterMention +
textAfterMention ;
textarea . value = newText ;
textarea . selectionStart = textarea . selectionEnd =
selectionEnd +
1 +
socialAddress . length +
spaceAfterMention . length ;
textarea . focus ( ) ;
textarea . dispatchEvent ( new Event ( 'input' ) ) ;
} }
/ >
< / Modal >
) }
2023-03-24 17:30:05 +03:00
{ showEmoji2Picker && (
< Modal
onClick = { ( e ) => {
if ( e . target === e . currentTarget ) {
setShowEmoji2Picker ( false ) ;
}
} }
>
< CustomEmojisModal
masto = { masto }
instance = { instance }
onClose = { ( ) => {
setShowEmoji2Picker ( false ) ;
} }
2024-05-22 14:12:13 +03:00
defaultSearchTerm = { showEmoji2Picker ? . defaultSearchTerm }
onSelect = { ( emojiShortcode ) => {
2023-03-24 17:30:05 +03:00
const textarea = textareaRef . current ;
if ( ! textarea ) return ;
const { selectionStart , selectionEnd } = textarea ;
const text = textarea . value ;
2024-05-22 14:12:13 +03:00
const textBeforeEmoji = text . slice ( 0 , selectionStart ) ;
2024-05-25 04:16:03 +03:00
const spaceBeforeEmoji = textBeforeEmoji
? /[\s\t\n\r]$/ . test ( textBeforeEmoji )
? ''
: ' '
: '' ;
2024-05-22 14:12:13 +03:00
const textAfterEmoji = text . slice ( selectionEnd ) ;
const spaceAfterEmoji = /^[\s\t\n\r]/ . test ( textAfterEmoji )
? ''
: ' ' ;
2023-03-24 17:30:05 +03:00
const newText =
2024-05-22 14:12:13 +03:00
textBeforeEmoji +
spaceBeforeEmoji +
emojiShortcode +
spaceAfterEmoji +
textAfterEmoji ;
2023-03-24 17:30:05 +03:00
textarea . value = newText ;
textarea . selectionStart = textarea . selectionEnd =
2024-05-22 14:12:13 +03:00
selectionEnd + emojiShortcode . length + spaceAfterEmoji . length ;
2023-03-24 17:30:05 +03:00
textarea . focus ( ) ;
textarea . dispatchEvent ( new Event ( 'input' ) ) ;
} }
/ >
< / Modal >
) }
2024-04-02 12:51:48 +03:00
{ showGIFPicker && (
< Modal
onClick = { ( e ) => {
if ( e . target === e . currentTarget ) {
setShowGIFPicker ( false ) ;
}
} }
>
< GIFPickerModal
onClose = { ( ) => setShowGIFPicker ( false ) }
onSelect = { ( { url , type , alt _text } ) => {
console . log ( 'GIF URL' , url ) ;
if ( mediaAttachments . length >= maxMediaAttachments ) {
alert (
2024-08-13 10:26:23 +03:00
plural ( maxMediaAttachments , {
one : 'You can only attach up to 1 file.' ,
other : 'You can only attach up to # files.' ,
} ) ,
2024-04-02 12:51:48 +03:00
) ;
return ;
}
// Download the GIF and insert it as media attachment
( async ( ) => {
let theToast ;
try {
theToast = showToast ( {
2024-08-13 10:26:23 +03:00
text : t ` Downloading GIF… ` ,
2024-04-02 12:51:48 +03:00
duration : - 1 ,
} ) ;
const blob = await fetch ( url , {
referrerPolicy : 'no-referrer' ,
} ) . then ( ( res ) => res . blob ( ) ) ;
const file = new File (
[ blob ] ,
type === 'video/mp4' ? 'video.mp4' : 'image.gif' ,
{
type ,
} ,
) ;
const newMediaAttachments = [
... mediaAttachments ,
{
file ,
type ,
size : file . size ,
id : null ,
description : alt _text || '' ,
} ,
] ;
setMediaAttachments ( newMediaAttachments ) ;
theToast ? . hideToast ? . ( ) ;
} catch ( err ) {
console . error ( err ) ;
theToast ? . hideToast ? . ( ) ;
2024-08-13 10:26:23 +03:00
showToast ( t ` Failed to download GIF ` ) ;
2024-04-02 12:51:48 +03:00
}
} ) ( ) ;
} }
/ >
< / Modal >
) }
2022-12-10 12:14:48 +03:00
< / div >
) ;
2022-12-13 19:39:35 +03:00
}
2022-12-12 11:27:44 +03:00
2023-09-16 17:57:35 +03:00
function autoResizeTextarea ( textarea ) {
if ( ! textarea ) return ;
const { value , offsetHeight , scrollHeight , clientHeight } = textarea ;
if ( offsetHeight < window . innerHeight ) {
// NOTE: This check is needed because the offsetHeight return 50000 (really large number) on first render
// No idea why it does that, will re-investigate in far future
const offset = offsetHeight - clientHeight ;
2023-11-09 14:11:00 +03:00
const height = value ? scrollHeight + offset + 'px' : null ;
textarea . style . height = height ;
2023-09-16 17:57:35 +03:00
}
}
2024-05-01 19:14:25 +03:00
async function _getCustomEmojis ( instance , masto ) {
const emojis = await masto . v1 . customEmojis . list ( ) ;
const visibleEmojis = emojis . filter ( ( e ) => e . visibleInPicker ) ;
const searcher = new Fuse ( visibleEmojis , {
keys : [ 'shortcode' ] ,
findAllMatches : true ,
} ) ;
return [ visibleEmojis , searcher ] ;
}
const getCustomEmojis = pmem ( _getCustomEmojis , {
// Limit by time to reduce memory usage
// Cached by instance
matchesArg : ( cacheKeyArg , keyArg ) => cacheKeyArg . instance === keyArg . instance ,
maxAge : 30 * 60 * 1000 , // 30 minutes
} ) ;
2024-05-28 12:59:17 +03:00
const detectLangs = ( text ) => {
const langs = detectAll ( text ) ;
if ( langs ? . length ) {
// return max 2
return langs . slice ( 0 , 2 ) . map ( ( lang ) => lang . lang ) ;
}
return null ;
} ;
2023-01-04 14:03:11 +03:00
const Textarea = forwardRef ( ( props , ref ) => {
2024-05-01 19:14:25 +03:00
const { masto , instance } = api ( ) ;
2023-01-04 14:03:11 +03:00
const [ text , setText ] = useState ( ref . current ? . value || '' ) ;
2024-05-22 14:12:13 +03:00
const {
maxCharacters ,
performSearch = ( ) => { } ,
onTrigger = ( ) => { } ,
... textareaProps
} = props ;
2023-12-27 18:33:59 +03:00
// const snapStates = useSnapshot(states);
2023-10-24 18:23:51 +03:00
// const charCount = snapStates.composerCharacterCount;
2023-01-04 14:03:11 +03:00
2024-05-01 19:14:25 +03:00
// const customEmojis = useRef();
const searcherRef = useRef ( ) ;
2023-02-23 06:36:07 +03:00
useEffect ( ( ) => {
2024-05-01 19:14:25 +03:00
getCustomEmojis ( instance , masto )
. then ( ( r ) => {
const [ emojis , searcher ] = r ;
searcherRef . current = searcher ;
} )
. catch ( ( e ) => {
2023-02-23 06:36:07 +03:00
console . error ( e ) ;
2024-05-01 19:14:25 +03:00
} ) ;
2023-02-23 06:36:07 +03:00
} , [ ] ) ;
2023-01-04 14:03:11 +03:00
const textExpanderRef = useRef ( ) ;
const textExpanderTextRef = useRef ( '' ) ;
useEffect ( ( ) => {
let handleChange , handleValue , handleCommited ;
if ( textExpanderRef . current ) {
handleChange = ( e ) => {
// console.log('text-expander-change', e);
const { key , provide , text } = e . detail ;
textExpanderTextRef . current = text ;
if ( text === '' ) {
provide (
Promise . resolve ( {
matched : false ,
} ) ,
) ;
return ;
}
if ( key === ':' ) {
// const emojis = customEmojis.current.filter((emoji) =>
// emoji.shortcode.startsWith(text),
// );
2024-05-01 19:14:25 +03:00
// const emojis = filterShortcodes(customEmojis.current, text);
const results = searcherRef . current ? . search ( text , {
limit : 5 ,
} ) ;
2023-01-04 14:03:11 +03:00
let html = '' ;
2024-05-01 19:14:25 +03:00
results . forEach ( ( { item : emoji } ) => {
2023-01-04 14:03:11 +03:00
const { shortcode , url } = emoji ;
html += `
< li role = "option" data - value = "${encodeHTML(shortcode)}" >
< img src = " $ { encodeHTML (
url ,
) } " width=" 16 " height=" 16 " alt=" " loading=" lazy " / >
2024-05-01 19:14:25 +03:00
$ { encodeHTML ( shortcode ) }
2023-01-04 14:03:11 +03:00
< / li > ` ;
} ) ;
2024-08-13 10:26:23 +03:00
html += ` <li role="option" data-value="" data-more=" ${ text } "> ${ t ` More… ` } </li> ` ;
2023-01-04 14:03:11 +03:00
// console.log({ emojis, html });
menu . innerHTML = html ;
provide (
Promise . resolve ( {
2024-05-01 19:14:25 +03:00
matched : results . length > 0 ,
2023-01-04 14:03:11 +03:00
fragment : menu ,
} ) ,
) ;
return ;
}
const type = {
'@' : 'accounts' ,
'#' : 'hashtags' ,
} [ key ] ;
provide (
new Promise ( ( resolve ) => {
2023-02-05 19:17:19 +03:00
const searchResults = performSearch ( {
2023-01-04 14:03:11 +03:00
type ,
q : text ,
limit : 5 ,
} ) ;
searchResults . then ( ( value ) => {
if ( text !== textExpanderTextRef . current ) {
return ;
}
console . log ( { value , type , v : value [ type ] } ) ;
2023-09-27 08:37:12 +03:00
const results = value [ type ] || value ;
2023-01-04 14:03:11 +03:00
console . log ( 'RESULTS' , value , results ) ;
let html = '' ;
results . forEach ( ( result ) => {
const {
name ,
avatarStatic ,
displayName ,
username ,
acct ,
emojis ,
2023-10-30 04:22:19 +03:00
history ,
2023-01-04 14:03:11 +03:00
} = result ;
const displayNameWithEmoji = emojifyText ( displayName , emojis ) ;
// const item = menuItem.cloneNode();
if ( acct ) {
html += `
< li role = "option" data - value = "${encodeHTML(acct)}" >
< span class = "avatar" >
< img src = " $ { encodeHTML (
avatarStatic ,
) } " width=" 16 " height=" 16 " alt=" " loading=" lazy " / >
< / span >
< span >
< b > $ { displayNameWithEmoji || username } < / b >
2024-08-04 08:32:30 +03:00
< br > < span class = "bidi-isolate" > @ $ { encodeHTML (
acct ,
) } < / span >
2023-01-04 14:03:11 +03:00
< / span >
< / li >
` ;
} else {
2023-10-30 04:22:19 +03:00
const total = history ? . reduce ? . (
( acc , cur ) => acc + + cur . uses ,
0 ,
) ;
2023-01-04 14:03:11 +03:00
html += `
< li role = "option" data - value = "${encodeHTML(name)}" >
2023-10-30 04:22:19 +03:00
< span class = "grow" > # < b > $ { encodeHTML ( name ) } < / b > < / span >
$ {
total
? ` <span class="count"> ${ shortenNumber ( total ) } </span> `
: ''
}
2023-01-04 14:03:11 +03:00
< / li >
` ;
}
} ) ;
2024-05-26 13:15:37 +03:00
if ( type === 'accounts' ) {
2024-08-13 10:26:23 +03:00
html += ` <li role="option" data-value="" data-more=" ${ text } "> ${ t ` More… ` } </li> ` ;
2024-05-26 13:15:37 +03:00
}
2024-05-25 06:06:58 +03:00
menu . innerHTML = html ;
2023-01-04 14:03:11 +03:00
console . log ( 'MENU' , results , menu ) ;
resolve ( {
matched : results . length > 0 ,
fragment : menu ,
} ) ;
} ) ;
} ) ,
) ;
} ;
textExpanderRef . current . addEventListener (
'text-expander-change' ,
handleChange ,
) ;
handleValue = ( e ) => {
const { key , item } = e . detail ;
2024-05-22 14:12:13 +03:00
const { value , more } = item . dataset ;
2023-01-04 14:03:11 +03:00
if ( key === ':' ) {
2024-05-22 14:12:13 +03:00
e . detail . value = value ? ` : ${ value } : ` : ' ' ; // zero-width space
if ( more ) {
// Prevent adding space after the above value
e . detail . continue = true ;
setTimeout ( ( ) => {
onTrigger ? . ( {
name : 'custom-emojis' ,
defaultSearchTerm : more ,
} ) ;
} , 300 ) ;
}
2024-05-25 06:06:58 +03:00
} else if ( key === '@' ) {
e . detail . value = value ? ` @ ${ value } ` : ' ' ; // zero-width space
if ( more ) {
e . detail . continue = true ;
setTimeout ( ( ) => {
onTrigger ? . ( {
name : 'mention' ,
defaultSearchTerm : more ,
} ) ;
} , 300 ) ;
}
2023-01-04 14:03:11 +03:00
} else {
2024-05-22 14:12:13 +03:00
e . detail . value = ` ${ key } ${ value } ` ;
2023-01-04 14:03:11 +03:00
}
} ;
textExpanderRef . current . addEventListener (
'text-expander-value' ,
handleValue ,
) ;
handleCommited = ( e ) => {
const { input } = e . detail ;
setText ( input . value ) ;
2023-11-08 18:16:16 +03:00
// fire input event
if ( ref . current ) {
const event = new Event ( 'input' , { bubbles : true } ) ;
ref . current . dispatchEvent ( event ) ;
}
2023-01-04 14:03:11 +03:00
} ;
textExpanderRef . current . addEventListener (
'text-expander-committed' ,
handleCommited ,
) ;
}
return ( ) => {
if ( textExpanderRef . current ) {
textExpanderRef . current . removeEventListener (
'text-expander-change' ,
handleChange ,
) ;
textExpanderRef . current . removeEventListener (
'text-expander-value' ,
handleValue ,
) ;
textExpanderRef . current . removeEventListener (
'text-expander-committed' ,
handleCommited ,
) ;
}
} ;
} , [ ] ) ;
2023-11-09 14:11:00 +03:00
useEffect ( ( ) => {
// Resize observer for textarea
const textarea = ref . current ;
if ( ! textarea ) return ;
const resizeObserver = new ResizeObserver ( ( ) => {
// Get height of textarea, set height to textExpander
2023-11-19 07:06:39 +03:00
if ( textExpanderRef . current ) {
const { height } = textarea . getBoundingClientRect ( ) ;
textExpanderRef . current . style . height = height + 'px' ;
}
2023-11-09 14:11:00 +03:00
} ) ;
resizeObserver . observe ( textarea ) ;
} , [ ] ) ;
2023-11-30 18:46:55 +03:00
const slowHighlightPerf = useRef ( 0 ) ; // increment if slow
2023-11-08 18:16:16 +03:00
const composeHighlightRef = useRef ( ) ;
2023-11-09 14:11:00 +03:00
const throttleHighlightText = useThrottledCallback ( ( text ) => {
2023-11-30 18:46:55 +03:00
if ( ! composeHighlightRef . current ) return ;
if ( slowHighlightPerf . current > 3 ) {
// After 3 times of lag, disable highlighting
composeHighlightRef . current . innerHTML = '' ;
composeHighlightRef . current = null ; // Destroy the whole thing
throttleHighlightText ? . cancel ? . ( ) ;
return ;
}
let start ;
let end ;
if ( slowHighlightPerf . current <= 3 ) start = Date . now ( ) ;
2023-11-09 14:11:00 +03:00
composeHighlightRef . current . innerHTML =
highlightText ( text , {
maxCharacters ,
} ) + '\n' ;
2023-11-30 18:46:55 +03:00
if ( slowHighlightPerf . current <= 3 ) end = Date . now ( ) ;
console . debug ( 'HIGHLIGHT PERF' , { start , end , diff : end - start } ) ;
if ( start && end && end - start > 50 ) {
// if slow, increment
slowHighlightPerf . current ++ ;
}
2023-11-09 14:11:00 +03:00
// Newline to prevent multiple line breaks at the end from being collapsed, no idea why
} , 500 ) ;
2023-11-08 18:16:16 +03:00
2024-05-29 10:26:58 +03:00
const debouncedAutoDetectLanguage = useDebouncedCallback ( ( ) => {
// Make use of the highlightRef to get the DOM
// Clone the dom
const dom = composeHighlightRef . current ? . cloneNode ( true ) ;
if ( ! dom ) return ;
// Remove mark
dom . querySelectorAll ( 'mark' ) . forEach ( ( mark ) => {
mark . remove ( ) ;
} ) ;
const text = dom . innerText ? . trim ( ) ;
2024-05-28 12:59:17 +03:00
if ( ! text ) return ;
const langs = detectLangs ( text ) ;
if ( langs ? . length ) {
onTrigger ? . ( {
name : 'auto-detect-language' ,
languages : langs ,
} ) ;
}
2024-05-29 10:26:58 +03:00
} , 2000 ) ;
2024-05-28 12:59:17 +03:00
2023-01-04 14:03:11 +03:00
return (
2023-11-08 18:16:16 +03:00
< text - expander
ref = { textExpanderRef }
keys = "@ # :"
class = "compose-field-container"
>
2023-02-27 11:01:26 +03:00
< textarea
2023-11-04 07:02:41 +03:00
class = "compose-field"
2023-02-27 11:01:26 +03:00
autoCapitalize = "sentences"
autoComplete = "on"
autoCorrect = "on"
spellCheck = "true"
dir = "auto"
rows = "6"
cols = "50"
{ ... textareaProps }
ref = { ref }
name = "status"
value = { text }
2023-09-16 17:57:35 +03:00
onKeyDown = { ( e ) => {
// Get line before cursor position after pressing 'Enter'
const { key , target } = e ;
2024-02-26 09:57:09 +03:00
if ( key === 'Enter' && ! ( e . ctrlKey || e . metaKey ) ) {
2023-09-16 17:57:35 +03:00
try {
const { value , selectionStart } = target ;
const textBeforeCursor = value . slice ( 0 , selectionStart ) ;
const lastLine = textBeforeCursor . split ( '\n' ) . slice ( - 1 ) [ 0 ] ;
if ( lastLine ) {
// If line starts with "- " or "12. "
if ( /^\s*(-|\d+\.)\s/ . test ( lastLine ) ) {
// insert "- " at cursor position
const [ _ , preSpaces , bullet , postSpaces , anything ] =
lastLine . match ( /^(\s*)(-|\d+\.)(\s+)(.+)?/ ) || [ ] ;
if ( anything ) {
e . preventDefault ( ) ;
const [ number ] = bullet . match ( /\d+/ ) || [ ] ;
const newBullet = number ? ` ${ + number + 1 } . ` : '-' ;
const text = ` \ n ${ preSpaces } ${ newBullet } ${ postSpaces } ` ;
target . setRangeText ( text , selectionStart , selectionStart ) ;
const pos = selectionStart + text . length ;
target . setSelectionRange ( pos , pos ) ;
} else {
// trim the line before the cursor, then insert new line
const pos = selectionStart - lastLine . length ;
target . setRangeText ( '' , pos , selectionStart ) ;
}
autoResizeTextarea ( target ) ;
2023-11-26 13:25:29 +03:00
target . dispatchEvent ( new Event ( 'input' ) ) ;
2023-09-16 17:57:35 +03:00
}
}
} catch ( e ) {
// silent fail
console . error ( e ) ;
}
2023-09-10 10:29:25 +03:00
}
2023-11-30 18:46:55 +03:00
if ( composeHighlightRef . current ) {
composeHighlightRef . current . scrollTop = target . scrollTop ;
}
2023-09-16 17:57:35 +03:00
} }
onInput = { ( e ) => {
const { target } = e ;
2024-05-22 14:12:13 +03:00
// Replace zero-width space
const text = target . value . replace ( /\u200b/g , '' ) ;
2023-11-08 18:16:16 +03:00
setText ( text ) ;
2023-09-16 17:57:35 +03:00
autoResizeTextarea ( target ) ;
2023-02-27 11:01:26 +03:00
props . onInput ? . ( e ) ;
2023-11-09 14:11:00 +03:00
throttleHighlightText ( text ) ;
2024-05-29 10:26:58 +03:00
debouncedAutoDetectLanguage ( ) ;
2023-02-27 11:01:26 +03:00
} }
style = { {
width : '100%' ,
height : '4em' ,
2023-10-24 18:23:51 +03:00
// '--text-weight': (1 + charCount / 140).toFixed(1) || 1,
2023-02-27 11:01:26 +03:00
} }
2023-11-08 18:16:16 +03:00
onScroll = { ( e ) => {
2023-11-30 18:46:55 +03:00
if ( composeHighlightRef . current ) {
const { scrollTop } = e . target ;
composeHighlightRef . current . scrollTop = scrollTop ;
}
2023-11-08 18:16:16 +03:00
} }
/ >
< div
ref = { composeHighlightRef }
class = "compose-highlight"
aria - hidden = "true"
2023-02-27 11:01:26 +03:00
/ >
2023-01-04 14:03:11 +03:00
< / t e x t - e x p a n d e r >
) ;
} ) ;
2023-03-23 20:26:49 +03:00
function CharCountMeter ( { maxCharacters = 500 , hidden } ) {
2023-01-04 14:03:11 +03:00
const snapStates = useSnapshot ( states ) ;
const charCount = snapStates . composerCharacterCount ;
const leftChars = maxCharacters - charCount ;
2023-08-13 07:00:33 +03:00
if ( hidden ) {
2024-03-24 11:37:58 +03:00
return < span class = "char-counter" hidden / > ;
2023-01-04 14:03:11 +03:00
}
return (
2024-03-24 11:37:58 +03:00
< span
class = "char-counter"
2023-08-13 07:00:33 +03:00
title = { ` ${ leftChars } / ${ maxCharacters } ` }
2023-01-04 14:03:11 +03:00
style = { {
'--percentage' : ( charCount / maxCharacters ) * 100 ,
} }
2024-03-24 11:37:58 +03:00
>
< meter
class = { ` ${
leftChars <= - 10
? 'explode'
: leftChars <= 0
? 'danger'
: leftChars <= 20
? 'warning'
: ''
} ` }
value = { charCount }
max = { maxCharacters }
/ >
< span class = "counter" > { leftChars } < / span >
< / span >
2023-01-04 14:03:11 +03:00
) ;
}
2024-05-27 14:03:23 +03:00
function scaleDimension ( matrix , matrixLimit , width , height ) {
// matrix = number of pixels
// matrixLimit = max number of pixels
// Calculate new width and height, downsize to within the limit, preserve aspect ratio, no decimals
const scalingFactor = Math . sqrt ( matrixLimit / matrix ) ;
const newWidth = Math . floor ( width * scalingFactor ) ;
const newHeight = Math . floor ( height * scalingFactor ) ;
return { newWidth , newHeight } ;
}
2022-12-12 11:27:44 +03:00
function MediaAttachment ( {
attachment ,
disabled ,
2023-01-25 19:34:52 +03:00
lang ,
2022-12-12 11:27:44 +03:00
onDescriptionChange = ( ) => { } ,
onRemove = ( ) => { } ,
} ) {
2024-08-13 10:26:23 +03:00
const { i18n } = useLingui ( ) ;
2023-12-27 18:33:59 +03:00
const [ uiState , setUIState ] = useState ( 'default' ) ;
2023-02-12 20:21:18 +03:00
const supportsEdit = supports ( '@mastodon/edit-media-attributes' ) ;
2023-10-05 13:01:18 +03:00
const { type , id , file } = attachment ;
2023-10-12 18:19:48 +03:00
const url = useMemo (
( ) => ( file ? URL . createObjectURL ( file ) : attachment . url ) ,
[ file , attachment . url ] ,
) ;
2023-01-05 20:51:39 +03:00
console . log ( { attachment } ) ;
2024-05-27 14:03:23 +03:00
const checkMaxError = ! ! file ? . size ;
const configuration = checkMaxError ? getCurrentInstanceConfiguration ( ) : { } ;
const {
mediaAttachments : {
imageSizeLimit ,
imageMatrixLimit ,
videoSizeLimit ,
videoMatrixLimit ,
videoFrameRateLimit ,
} = { } ,
} = configuration || { } ;
const [ maxError , setMaxError ] = useState ( ( ) => {
if ( ! checkMaxError ) return null ;
if (
type . startsWith ( 'image' ) &&
imageSizeLimit &&
file . size > imageSizeLimit
) {
return {
type : 'imageSizeLimit' ,
details : {
imageSize : file . size ,
imageSizeLimit ,
} ,
} ;
} else if (
type . startsWith ( 'video' ) &&
videoSizeLimit &&
file . size > videoSizeLimit
) {
return {
type : 'videoSizeLimit' ,
details : {
videoSize : file . size ,
videoSizeLimit ,
} ,
} ;
}
return null ;
} ) ;
const [ imageMatrix , setImageMatrix ] = useState ( { } ) ;
useEffect ( ( ) => {
if ( ! checkMaxError || ! imageMatrixLimit ) return ;
if ( imageMatrix ? . matrix > imageMatrixLimit ) {
setMaxError ( {
type : 'imageMatrixLimit' ,
details : {
imageMatrix : imageMatrix ? . matrix ,
imageMatrixLimit ,
width : imageMatrix ? . width ,
height : imageMatrix ? . height ,
} ,
} ) ;
}
} , [ imageMatrix , imageMatrixLimit , checkMaxError ] ) ;
const [ videoMatrix , setVideoMatrix ] = useState ( { } ) ;
useEffect ( ( ) => {
if ( ! checkMaxError || ! videoMatrixLimit ) return ;
if ( videoMatrix ? . matrix > videoMatrixLimit ) {
setMaxError ( {
type : 'videoMatrixLimit' ,
details : {
videoMatrix : videoMatrix ? . matrix ,
videoMatrixLimit ,
width : videoMatrix ? . width ,
height : videoMatrix ? . height ,
} ,
} ) ;
}
} , [ videoMatrix , videoMatrixLimit , checkMaxError ] ) ;
2023-01-05 20:51:39 +03:00
const [ description , setDescription ] = useState ( attachment . description ) ;
2023-12-27 18:33:59 +03:00
const [ suffixType , subtype ] = type . split ( '/' ) ;
2023-01-05 20:51:39 +03:00
const debouncedOnDescriptionChange = useDebouncedCallback (
onDescriptionChange ,
2023-06-14 17:38:38 +03:00
250 ,
2023-01-05 20:51:39 +03:00
) ;
2024-04-02 12:45:14 +03:00
useEffect ( ( ) => {
debouncedOnDescriptionChange ( description ) ;
} , [ description , debouncedOnDescriptionChange ] ) ;
2023-01-05 20:51:39 +03:00
const [ showModal , setShowModal ] = useState ( false ) ;
const textareaRef = useRef ( null ) ;
useEffect ( ( ) => {
let timer ;
if ( showModal && textareaRef . current ) {
timer = setTimeout ( ( ) => {
textareaRef . current . focus ( ) ;
} , 100 ) ;
}
return ( ) => {
clearTimeout ( timer ) ;
} ;
} , [ showModal ] ) ;
const descTextarea = (
< >
2023-02-12 20:21:18 +03:00
{ ! ! id && ! supportsEdit ? (
2022-12-12 11:27:44 +03:00
< div class = "media-desc" >
2024-08-13 10:26:23 +03:00
< span class = "tag" >
< Trans > Uploaded < / Trans >
< / span >
2023-01-05 20:51:39 +03:00
< p title = { description } >
{ attachment . description || < i > No description < / i > }
< / p >
2022-12-12 11:27:44 +03:00
< / div >
) : (
< textarea
2023-01-05 20:51:39 +03:00
ref = { textareaRef }
2022-12-12 16:54:31 +03:00
value = { description || '' }
2023-01-25 19:34:52 +03:00
lang = { lang }
2022-12-12 11:27:44 +03:00
placeholder = {
{
2024-08-13 10:26:23 +03:00
image : t ` Image description ` ,
video : t ` Video description ` ,
audio : t ` Audio description ` ,
2022-12-12 11:27:44 +03:00
} [ suffixType ]
}
autoCapitalize = "sentences"
autoComplete = "on"
autoCorrect = "on"
spellCheck = "true"
dir = "auto"
2023-12-27 18:33:59 +03:00
disabled = { disabled || uiState === 'loading' }
class = { uiState === 'loading' ? 'loading' : '' }
2022-12-12 11:27:44 +03:00
maxlength = "1500" // Not unicode-aware :(
// TODO: Un-hard-code this maxlength, ref: https://github.com/mastodon/mastodon/blob/b59fb28e90bc21d6fd1a6bafd13cfbd81ab5be54/app/models/media_attachment.rb#L39
onInput = { ( e ) => {
const { value } = e . target ;
2023-01-05 20:51:39 +03:00
setDescription ( value ) ;
2024-04-02 12:45:14 +03:00
// debouncedOnDescriptionChange(value);
2022-12-12 11:27:44 +03:00
} }
> < / textarea >
) }
2023-01-05 20:51:39 +03:00
< / >
) ;
2023-12-27 18:33:59 +03:00
const toastRef = useRef ( null ) ;
useEffect ( ( ) => {
return ( ) => {
toastRef . current ? . hideToast ? . ( ) ;
} ;
} , [ ] ) ;
2024-05-27 14:03:23 +03:00
const maxErrorToast = useRef ( null ) ;
const maxErrorText = ( err ) => {
const { type , details } = err ;
switch ( type ) {
case 'imageSizeLimit' : {
const { imageSize , imageSizeLimit } = details ;
2024-08-13 10:26:23 +03:00
return t ` File size too large. Uploading might encounter issues. Try reduce the file size from ${ prettyBytes (
2024-05-27 14:03:23 +03:00
imageSize ,
) } to $ { prettyBytes ( imageSizeLimit ) } or lower . ` ;
}
case 'imageMatrixLimit' : {
const { imageMatrix , imageMatrixLimit , width , height } = details ;
const { newWidth , newHeight } = scaleDimension (
imageMatrix ,
imageMatrixLimit ,
width ,
height ,
) ;
2024-08-13 10:26:23 +03:00
return t ` Dimension too large. Uploading might encounter issues. Try reduce dimension from ${ i18n . number (
width ,
) } × $ { i18n . number ( height ) } px to $ { i18n . number ( newWidth ) } × $ { i18n . number (
newHeight ,
) } px . ` ;
2024-05-27 14:03:23 +03:00
}
case 'videoSizeLimit' : {
const { videoSize , videoSizeLimit } = details ;
2024-08-13 10:26:23 +03:00
return t ` File size too large. Uploading might encounter issues. Try reduce the file size from ${ prettyBytes (
2024-05-27 14:03:23 +03:00
videoSize ,
) } to $ { prettyBytes ( videoSizeLimit ) } or lower . ` ;
}
case 'videoMatrixLimit' : {
const { videoMatrix , videoMatrixLimit , width , height } = details ;
const { newWidth , newHeight } = scaleDimension (
videoMatrix ,
videoMatrixLimit ,
width ,
height ,
) ;
2024-08-13 10:26:23 +03:00
return t ` Dimension too large. Uploading might encounter issues. Try reduce dimension from ${ i18n . number (
width ,
) } × $ { i18n . number ( height ) } px to $ { i18n . number ( newWidth ) } × $ { i18n . number (
newHeight ,
) } px . ` ;
2024-05-27 14:03:23 +03:00
}
case 'videoFrameRateLimit' : {
// Not possible to detect this on client-side for now
2024-08-13 10:26:23 +03:00
return t ` Frame rate too high. Uploading might encounter issues. ` ;
2024-05-27 14:03:23 +03:00
}
}
} ;
2023-01-05 20:51:39 +03:00
return (
< >
< div class = "media-attachment" >
< div
class = "media-preview"
2023-10-05 13:07:36 +03:00
tabIndex = "0"
2023-01-05 20:51:39 +03:00
onClick = { ( ) => {
setShowModal ( true ) ;
} }
2022-12-12 11:27:44 +03:00
>
2023-01-05 20:51:39 +03:00
{ suffixType === 'image' ? (
2024-05-27 14:03:23 +03:00
< img
src = { url }
alt = ""
onLoad = { ( e ) => {
if ( ! checkMaxError ) return ;
const { naturalWidth , naturalHeight } = e . target ;
setImageMatrix ( {
matrix : naturalWidth * naturalHeight ,
width : naturalWidth ,
height : naturalHeight ,
} ) ;
} }
/ >
2023-01-05 20:51:39 +03:00
) : suffixType === 'video' || suffixType === 'gifv' ? (
2024-05-27 14:03:23 +03:00
< video
src = { url + '#t=0.1' } // Make Safari show 1st-frame preview
playsinline
muted
disablePictureInPicture
preload = "metadata"
onLoadedMetadata = { ( e ) => {
if ( ! checkMaxError ) return ;
const { videoWidth , videoHeight } = e . target ;
if ( videoWidth && videoHeight ) {
setVideoMatrix ( {
matrix : videoWidth * videoHeight ,
width : videoWidth ,
height : videoHeight ,
} ) ;
}
} }
/ >
2023-01-05 20:51:39 +03:00
) : suffixType === 'audio' ? (
< audio src = { url } controls / >
) : null }
< / div >
{ descTextarea }
< div class = "media-aside" >
< button
type = "button"
class = "plain close-button"
disabled = { disabled }
onClick = { onRemove }
>
2024-08-13 10:26:23 +03:00
< Icon icon = "x" alt = { t ` Remove ` } / >
2023-01-05 20:51:39 +03:00
< / button >
2024-05-27 14:03:23 +03:00
{ ! ! maxError && (
< button
type = "button"
class = "media-error"
title = { maxErrorText ( maxError ) }
onClick = { ( ) => {
if ( maxErrorToast . current ) {
maxErrorToast . current . hideToast ( ) ;
}
maxErrorToast . current = showToast ( {
text : maxErrorText ( maxError ) ,
duration : 10 _000 ,
} ) ;
} }
>
2024-08-13 10:26:23 +03:00
< Icon icon = "alert" alt = { t ` Error ` } / >
2024-05-27 14:03:23 +03:00
< / button >
) }
2023-01-05 20:51:39 +03:00
< / div >
2022-12-12 11:27:44 +03:00
< / div >
2023-01-05 20:51:39 +03:00
{ showModal && (
< Modal
2024-07-06 04:47:42 +03:00
onClose = { ( ) => {
setShowModal ( false ) ;
2023-01-05 20:51:39 +03:00
} }
>
2023-01-13 12:23:18 +03:00
< div id = "media-sheet" class = "sheet sheet-max" >
2023-04-20 11:10:57 +03:00
< button
type = "button"
class = "sheet-close"
onClick = { ( ) => {
setShowModal ( false ) ;
} }
>
2024-08-13 10:26:23 +03:00
< Icon icon = "x" alt = { t ` Close ` } / >
2023-04-20 11:10:57 +03:00
< / button >
2023-01-05 20:51:39 +03:00
< header >
< h2 >
{
{
2024-08-13 10:26:23 +03:00
image : t ` Edit image description ` ,
video : t ` Edit video description ` ,
audio : t ` Edit audio description ` ,
2023-01-05 20:51:39 +03:00
} [ suffixType ]
}
< / h2 >
< / header >
< main tabIndex = "-1" >
< div class = "media-preview" >
{ suffixType === 'image' ? (
< img src = { url } alt = "" / >
) : suffixType === 'video' || suffixType === 'gifv' ? (
< video src = { url } playsinline controls / >
) : suffixType === 'audio' ? (
< audio src = { url } controls / >
) : null }
< / div >
2023-10-05 13:07:36 +03:00
< div class = "media-form" >
{ descTextarea }
< footer >
2023-12-27 18:33:59 +03:00
{ suffixType === 'image' &&
/^(png|jpe?g|gif|webp)$/i . test ( subtype ) &&
! ! states . settings . mediaAltGenerator &&
! ! IMG _ALT _API _URL && (
< Menu2
portal = { {
target : document . body ,
} }
containerProps = { {
style : {
zIndex : 1001 ,
} ,
} }
align = "center"
position = "anchor"
overflow = "auto"
menuButton = {
2024-08-13 10:26:23 +03:00
< button type = "button" class = "plain" >
< Icon icon = "more" size = "l" alt = { t ` More ` } / >
2023-12-27 18:33:59 +03:00
< / button >
}
>
< MenuItem
disabled = { uiState === 'loading' }
onClick = { ( ) => {
setUIState ( 'loading' ) ;
toastRef . current = showToast ( {
2024-08-13 13:17:00 +03:00
text : t ` Generating description. Please wait… ` ,
2023-12-27 18:33:59 +03:00
duration : - 1 ,
} ) ;
// POST with multipart
( async function ( ) {
try {
const body = new FormData ( ) ;
body . append ( 'image' , file ) ;
const response = await fetch ( IMG _ALT _API _URL , {
method : 'POST' ,
body ,
} ) . then ( ( r ) => r . json ( ) ) ;
2024-01-06 07:23:15 +03:00
if ( response . error ) {
throw new Error ( response . error ) ;
}
2023-12-27 18:33:59 +03:00
setDescription ( response . description ) ;
} catch ( e ) {
console . error ( e ) ;
2024-01-06 07:23:15 +03:00
showToast (
2024-08-13 10:26:23 +03:00
e . message
? t ` Failed to generate description: ${ e . message } `
: t ` Failed to generate description ` ,
2024-01-06 07:23:15 +03:00
) ;
2023-12-27 18:33:59 +03:00
} finally {
setUIState ( 'default' ) ;
toastRef . current ? . hideToast ? . ( ) ;
}
} ) ( ) ;
} }
>
< Icon icon = "sparkles2" / >
2024-05-19 11:27:59 +03:00
{ lang && lang !== 'en' ? (
< small >
2024-08-13 10:26:23 +03:00
< Trans > Generate description … < / Trans >
2024-05-19 11:27:59 +03:00
< br / >
( English )
< / small >
) : (
2024-08-13 10:26:23 +03:00
< span >
< Trans > Generate description … < / Trans >
< / span >
2024-05-19 11:27:59 +03:00
) }
2023-12-27 18:33:59 +03:00
< / MenuItem >
2024-05-19 11:27:59 +03:00
{ ! ! lang && lang !== 'en' && (
< MenuItem
disabled = { uiState === 'loading' }
onClick = { ( ) => {
setUIState ( 'loading' ) ;
toastRef . current = showToast ( {
2024-08-13 13:17:00 +03:00
text : t ` Generating description. Please wait… ` ,
2024-05-19 11:27:59 +03:00
duration : - 1 ,
} ) ;
// POST with multipart
( async function ( ) {
try {
const body = new FormData ( ) ;
body . append ( 'image' , file ) ;
const params = ` ?lang= ${ lang } ` ;
const response = await fetch (
IMG _ALT _API _URL + params ,
{
method : 'POST' ,
body ,
} ,
) . then ( ( r ) => r . json ( ) ) ;
if ( response . error ) {
throw new Error ( response . error ) ;
}
setDescription ( response . description ) ;
} catch ( e ) {
console . error ( e ) ;
showToast (
2024-08-13 10:26:23 +03:00
t ` Failed to generate description ${
2024-05-19 11:27:59 +03:00
e ? . message ? ` : ${ e . message } ` : ''
} ` ,
) ;
} finally {
setUIState ( 'default' ) ;
toastRef . current ? . hideToast ? . ( ) ;
}
} ) ( ) ;
} }
>
< Icon icon = "sparkles2" / >
< small >
2024-08-13 10:26:23 +03:00
< Trans > Generate description … < / Trans >
< br / >
< Trans >
( { localeCode2Text ( lang ) } ) { ' ' }
< span class = "more-insignificant" >
— experimental
< / span >
< / Trans >
2024-05-19 11:27:59 +03:00
< / small >
< / MenuItem >
) }
2023-12-27 18:33:59 +03:00
< / Menu2 >
) }
2023-10-05 13:07:36 +03:00
< button
type = "button"
class = "light block"
onClick = { ( ) => {
setShowModal ( false ) ;
} }
2023-12-27 18:33:59 +03:00
disabled = { uiState === 'loading' }
2023-10-05 13:07:36 +03:00
>
2024-08-13 10:26:23 +03:00
< Trans > Done < / Trans >
2023-10-05 13:07:36 +03:00
< / button >
< / footer >
< / div >
2023-01-05 20:51:39 +03:00
< / main >
< / div >
< / Modal >
) }
< / >
2022-12-12 11:27:44 +03:00
) ;
}
2022-12-13 19:39:35 +03:00
2022-12-14 16:48:17 +03:00
function Poll ( {
2023-01-25 19:34:52 +03:00
lang ,
2022-12-14 16:48:17 +03:00
poll ,
disabled ,
onInput = ( ) => { } ,
maxOptions ,
maxExpiration ,
minExpiration ,
maxCharactersPerOption ,
} ) {
2024-08-13 10:26:23 +03:00
const { _ } = useLingui ( ) ;
2022-12-14 16:48:17 +03:00
const { options , expiresIn , multiple } = poll ;
return (
< div class = { ` poll ${ multiple ? 'multiple' : '' } ` } >
< div class = "poll-choices" >
{ options . map ( ( option , i ) => (
< div class = "poll-choice" key = { i } >
< input
required
type = "text"
value = { option }
disabled = { disabled }
maxlength = { maxCharactersPerOption }
2024-08-13 10:26:23 +03:00
placeholder = { t ` Choice ${ i + 1 } ` }
2023-01-25 19:34:52 +03:00
lang = { lang }
2023-02-11 01:21:23 +03:00
spellCheck = "true"
2023-08-24 04:12:00 +03:00
dir = "auto"
2022-12-14 16:48:17 +03:00
onInput = { ( e ) => {
const { value } = e . target ;
options [ i ] = value ;
onInput ( poll ) ;
} }
/ >
< button
type = "button"
class = "plain2 poll-button"
disabled = { disabled || options . length <= 1 }
onClick = { ( ) => {
options . splice ( i , 1 ) ;
onInput ( poll ) ;
} }
>
2024-08-13 10:26:23 +03:00
< Icon icon = "x" size = "s" alt = { t ` Remove ` } / >
2022-12-14 16:48:17 +03:00
< / button >
< / div >
) ) }
< / div >
< div class = "poll-toolbar" >
< button
type = "button"
class = "plain2 poll-button"
disabled = { disabled || options . length >= maxOptions }
onClick = { ( ) => {
options . push ( '' ) ;
onInput ( poll ) ;
} }
>
+
< / button > { ' ' }
< label class = "multiple-choices" >
< input
type = "checkbox"
checked = { multiple }
disabled = { disabled }
onChange = { ( e ) => {
const { checked } = e . target ;
poll . multiple = checked ;
onInput ( poll ) ;
} }
/ > { ' ' }
2024-08-13 10:26:23 +03:00
< Trans > Multiple choices < / Trans >
2022-12-14 16:48:17 +03:00
< / label >
< label class = "expires-in" >
2024-08-13 10:26:23 +03:00
< Trans > Duration < / Trans > { ' ' }
2022-12-14 16:48:17 +03:00
< select
value = { expiresIn }
disabled = { disabled }
onChange = { ( e ) => {
const { value } = e . target ;
poll . expiresIn = value ;
onInput ( poll ) ;
} }
>
{ Object . entries ( expiryOptions )
2024-08-13 10:26:23 +03:00
. filter ( ( [ value ] ) => {
2022-12-14 16:48:17 +03:00
return value >= minExpiration && value <= maxExpiration ;
} )
2024-08-13 10:26:23 +03:00
. map ( ( [ value , label ] ) => (
2022-12-14 16:48:17 +03:00
< option value = { value } key = { value } >
2024-08-13 10:26:23 +03:00
{ label ( ) }
2022-12-14 16:48:17 +03:00
< / option >
) ) }
< / select >
< / label >
< / div >
< div class = "poll-toolbar" >
< button
type = "button"
class = "plain remove-poll-button"
disabled = { disabled }
onClick = { ( ) => {
onInput ( null ) ;
} }
>
2024-08-13 10:26:23 +03:00
< Trans > Remove poll < / Trans >
2022-12-14 16:48:17 +03:00
< / button >
< / div >
< / div >
) ;
}
2022-12-22 14:24:07 +03:00
function filterShortcodes ( emojis , searchTerm ) {
searchTerm = searchTerm . toLowerCase ( ) ;
// Return an array of shortcodes that start with or contain the search term, sorted by relevance and limited to the first 5
return emojis
. sort ( ( a , b ) => {
let aLower = a . shortcode . toLowerCase ( ) ;
let bLower = b . shortcode . toLowerCase ( ) ;
let aStartsWith = aLower . startsWith ( searchTerm ) ;
let bStartsWith = bLower . startsWith ( searchTerm ) ;
let aContains = aLower . includes ( searchTerm ) ;
let bContains = bLower . includes ( searchTerm ) ;
let bothStartWith = aStartsWith && bStartsWith ;
let bothContain = aContains && bContains ;
return bothStartWith
? a . length - b . length
: aStartsWith
? - 1
: bStartsWith
? 1
: bothContain
? a . length - b . length
: aContains
? - 1
: bContains
? 1
: 0 ;
} )
. slice ( 0 , 5 ) ;
}
function encodeHTML ( str ) {
return str . replace ( /[&<>"']/g , function ( char ) {
return '&#' + char . charCodeAt ( 0 ) + ';' ;
} ) ;
}
2022-12-25 18:28:55 +03:00
function removeNullUndefined ( obj ) {
for ( let key in obj ) {
if ( obj [ key ] === null || obj [ key ] === undefined ) {
delete obj [ key ] ;
}
}
return obj ;
}
2024-05-25 06:06:58 +03:00
function MentionModal ( {
onClose = ( ) => { } ,
onSelect = ( ) => { } ,
defaultSearchTerm ,
} ) {
const { masto } = api ( ) ;
const [ uiState , setUIState ] = useState ( 'default' ) ;
const [ accounts , setAccounts ] = useState ( [ ] ) ;
const [ relationshipsMap , setRelationshipsMap ] = useState ( { } ) ;
const [ selectedIndex , setSelectedIndex ] = useState ( 0 ) ;
const loadRelationships = async ( accounts ) => {
if ( ! accounts ? . length ) return ;
const relationships = await fetchRelationships ( accounts , relationshipsMap ) ;
if ( relationships ) {
setRelationshipsMap ( {
... relationshipsMap ,
... relationships ,
} ) ;
}
} ;
const loadAccounts = ( term ) => {
if ( ! term ) return ;
setUIState ( 'loading' ) ;
( async ( ) => {
try {
const accounts = await masto . v1 . accounts . search . list ( {
q : term ,
limit : 40 ,
resolve : false ,
} ) ;
setAccounts ( accounts ) ;
loadRelationships ( accounts ) ;
setUIState ( 'default' ) ;
} catch ( e ) {
setUIState ( 'error' ) ;
console . error ( e ) ;
}
} ) ( ) ;
} ;
const debouncedLoadAccounts = useDebouncedCallback ( loadAccounts , 1000 ) ;
useEffect ( ( ) => {
loadAccounts ( ) ;
} , [ loadAccounts ] ) ;
const inputRef = useRef ( ) ;
useEffect ( ( ) => {
if ( inputRef . current ) {
inputRef . current . focus ( ) ;
// Put cursor at the end
if ( inputRef . current . value ) {
inputRef . current . selectionStart = inputRef . current . value . length ;
inputRef . current . selectionEnd = inputRef . current . value . length ;
}
}
} , [ ] ) ;
useEffect ( ( ) => {
if ( defaultSearchTerm ) {
loadAccounts ( defaultSearchTerm ) ;
}
} , [ defaultSearchTerm ] ) ;
const selectAccount = ( account ) => {
const socialAddress = account . acct ;
onSelect ( socialAddress ) ;
onClose ( ) ;
} ;
useHotkeys (
'enter' ,
( ) => {
const selectedAccount = accounts [ selectedIndex ] ;
if ( selectedAccount ) {
selectAccount ( selectedAccount ) ;
}
} ,
{
preventDefault : true ,
enableOnFormTags : [ 'input' ] ,
} ,
) ;
const listRef = useRef ( ) ;
useHotkeys (
'down' ,
( ) => {
if ( selectedIndex < accounts . length - 1 ) {
setSelectedIndex ( selectedIndex + 1 ) ;
} else {
setSelectedIndex ( 0 ) ;
}
setTimeout ( ( ) => {
const selectedItem = listRef . current . querySelector ( '.selected' ) ;
if ( selectedItem ) {
selectedItem . scrollIntoView ( {
behavior : 'smooth' ,
block : 'center' ,
inline : 'center' ,
} ) ;
}
} , 1 ) ;
} ,
{
preventDefault : true ,
enableOnFormTags : [ 'input' ] ,
} ,
) ;
useHotkeys (
'up' ,
( ) => {
if ( selectedIndex > 0 ) {
setSelectedIndex ( selectedIndex - 1 ) ;
} else {
setSelectedIndex ( accounts . length - 1 ) ;
}
setTimeout ( ( ) => {
const selectedItem = listRef . current . querySelector ( '.selected' ) ;
if ( selectedItem ) {
selectedItem . scrollIntoView ( {
behavior : 'smooth' ,
block : 'center' ,
inline : 'center' ,
} ) ;
}
} , 1 ) ;
} ,
{
preventDefault : true ,
enableOnFormTags : [ 'input' ] ,
} ,
) ;
return (
< div id = "mention-sheet" class = "sheet" >
{ ! ! onClose && (
< button type = "button" class = "sheet-close" onClick = { onClose } >
2024-08-13 10:26:23 +03:00
< Icon icon = "x" alt = { t ` Close ` } / >
2024-05-25 06:06:58 +03:00
< / button >
) }
< header >
< form
onSubmit = { ( e ) => {
e . preventDefault ( ) ;
debouncedLoadAccounts . flush ? . ( ) ;
// const searchTerm = inputRef.current.value;
// debouncedLoadAccounts(searchTerm);
} }
>
< input
ref = { inputRef }
required
type = "search"
class = "block"
2024-08-13 10:26:23 +03:00
placeholder = { t ` Search accounts ` }
2024-05-25 06:06:58 +03:00
onInput = { ( e ) => {
const { value } = e . target ;
debouncedLoadAccounts ( value ) ;
} }
autocomplete = "off"
autocorrect = "off"
autocapitalize = "off"
spellCheck = "false"
dir = "auto"
defaultValue = { defaultSearchTerm || '' }
/ >
< / form >
< / header >
< main >
{ accounts ? . length > 0 ? (
< ul
ref = { listRef }
class = { ` accounts-list ${ uiState === 'loading' ? 'loading' : '' } ` }
>
{ accounts . map ( ( account , i ) => {
const relationship = relationshipsMap [ account . id ] ;
return (
< li
key = { account . id }
class = { i === selectedIndex ? 'selected' : '' }
>
< AccountBlock
avatarSize = "xxl"
account = { account }
relationship = { relationship }
showStats
showActivity
/ >
< button
type = "button"
class = "plain2"
onClick = { ( ) => {
selectAccount ( account ) ;
} }
>
2024-08-13 10:26:23 +03:00
< Icon icon = "plus" size = "xl" alt = { t ` Add ` } / >
2024-05-25 06:06:58 +03:00
< / button >
< / li >
) ;
} ) }
< / ul >
) : uiState === 'loading' ? (
< div class = "ui-state" >
< Loader abrupt / >
< / div >
) : uiState === 'error' ? (
< div class = "ui-state" >
2024-08-13 10:26:23 +03:00
< p >
< Trans > Error loading accounts < / Trans >
< / p >
2024-05-25 06:06:58 +03:00
< / div >
) : null }
< / main >
< / div >
) ;
}
2023-03-24 17:30:05 +03:00
function CustomEmojisModal ( {
masto ,
instance ,
onClose = ( ) => { } ,
onSelect = ( ) => { } ,
2024-05-22 14:12:13 +03:00
defaultSearchTerm ,
2023-03-24 17:30:05 +03:00
} ) {
const [ uiState , setUIState ] = useState ( 'default' ) ;
const customEmojisList = useRef ( [ ] ) ;
2024-05-01 19:14:25 +03:00
const [ customEmojis , setCustomEmojis ] = useState ( [ ] ) ;
2023-03-24 17:30:05 +03:00
const recentlyUsedCustomEmojis = useMemo (
( ) => store . account . get ( 'recentlyUsedCustomEmojis' ) || [ ] ,
) ;
2024-05-01 19:14:25 +03:00
const searcherRef = useRef ( ) ;
2023-03-24 17:30:05 +03:00
useEffect ( ( ) => {
setUIState ( 'loading' ) ;
( async ( ) => {
try {
2024-05-01 19:14:25 +03:00
const [ emojis , searcher ] = await getCustomEmojis ( instance , masto ) ;
console . log ( 'emojis' , emojis ) ;
searcherRef . current = searcher ;
setCustomEmojis ( emojis ) ;
2023-03-24 17:30:05 +03:00
setUIState ( 'default' ) ;
} catch ( e ) {
setUIState ( 'error' ) ;
console . error ( e ) ;
}
} ) ( ) ;
} , [ ] ) ;
2024-05-01 19:14:25 +03:00
const customEmojisCatList = useMemo ( ( ) => {
// Group emojis by category
const emojisCat = {
'--recent--' : recentlyUsedCustomEmojis . filter ( ( emoji ) =>
customEmojis . find ( ( e ) => e . shortcode === emoji . shortcode ) ,
) ,
} ;
const othersCat = [ ] ;
customEmojis . forEach ( ( emoji ) => {
customEmojisList . current ? . push ? . ( emoji ) ;
if ( ! emoji . category ) {
othersCat . push ( emoji ) ;
return ;
}
if ( ! emojisCat [ emoji . category ] ) {
emojisCat [ emoji . category ] = [ ] ;
}
emojisCat [ emoji . category ] . push ( emoji ) ;
} ) ;
if ( othersCat . length ) {
emojisCat [ '--others--' ] = othersCat ;
}
return emojisCat ;
} , [ customEmojis ] ) ;
const scrollableRef = useRef ( ) ;
const [ matches , setMatches ] = useState ( null ) ;
const onFind = useCallback (
( e ) => {
const { value } = e . target ;
if ( value ) {
const results = searcherRef . current ? . search ( value , {
limit : CUSTOM _EMOJIS _COUNT ,
} ) ;
setMatches ( results . map ( ( r ) => r . item ) ) ;
scrollableRef . current ? . scrollTo ? . ( 0 , 0 ) ;
} else {
setMatches ( null ) ;
}
} ,
[ customEmojis ] ,
) ;
2024-05-22 14:12:13 +03:00
useEffect ( ( ) => {
if ( defaultSearchTerm && customEmojis ? . length ) {
onFind ( { target : { value : defaultSearchTerm } } ) ;
}
} , [ defaultSearchTerm , onFind , customEmojis ] ) ;
2024-05-01 19:14:25 +03:00
const onSelectEmoji = useCallback (
( emoji ) => {
onSelect ? . ( emoji ) ;
onClose ? . ( ) ;
queueMicrotask ( ( ) => {
let recentlyUsedCustomEmojis =
store . account . get ( 'recentlyUsedCustomEmojis' ) || [ ] ;
const recentlyUsedEmojiIndex = recentlyUsedCustomEmojis . findIndex (
( e ) => e . shortcode === emoji . shortcode ,
) ;
if ( recentlyUsedEmojiIndex !== - 1 ) {
// Move emoji to index 0
recentlyUsedCustomEmojis . splice ( recentlyUsedEmojiIndex , 1 ) ;
recentlyUsedCustomEmojis . unshift ( emoji ) ;
} else {
recentlyUsedCustomEmojis . unshift ( emoji ) ;
// Remove unavailable ones
recentlyUsedCustomEmojis = recentlyUsedCustomEmojis . filter ( ( e ) =>
customEmojisList . current ? . find ? . (
( emoji ) => emoji . shortcode === e . shortcode ,
) ,
) ;
// Limit to 10
recentlyUsedCustomEmojis = recentlyUsedCustomEmojis . slice ( 0 , 10 ) ;
}
// Store back
store . account . set ( 'recentlyUsedCustomEmojis' , recentlyUsedCustomEmojis ) ;
} ) ;
} ,
[ onSelect ] ,
) ;
2024-05-22 14:12:13 +03:00
const inputRef = useRef ( ) ;
useEffect ( ( ) => {
if ( inputRef . current ) {
inputRef . current . focus ( ) ;
// Put cursor at the end
if ( inputRef . current . value ) {
inputRef . current . selectionStart = inputRef . current . value . length ;
inputRef . current . selectionEnd = inputRef . current . value . length ;
}
}
} , [ ] ) ;
2023-03-24 17:30:05 +03:00
return (
< div id = "custom-emojis-sheet" class = "sheet" >
2023-04-20 11:10:57 +03:00
{ ! ! onClose && (
< button type = "button" class = "sheet-close" onClick = { onClose } >
2024-08-13 10:26:23 +03:00
< Icon icon = "x" alt = { t ` Close ` } / >
2023-04-20 11:10:57 +03:00
< / button >
) }
2023-03-24 17:30:05 +03:00
< header >
2024-05-01 19:14:25 +03:00
< div >
2024-08-13 10:26:23 +03:00
< b >
< Trans > Custom emojis < / Trans >
< / b > { ' ' }
2024-05-01 19:14:25 +03:00
{ uiState === 'loading' ? (
< Loader / >
) : (
< small class = "insignificant" > • { instance } < / small >
2023-03-24 17:30:05 +03:00
) }
< / div >
2024-05-01 19:14:25 +03:00
< form
onSubmit = { ( e ) => {
e . preventDefault ( ) ;
const emoji = matches [ 0 ] ;
if ( emoji ) {
onSelectEmoji ( ` : ${ emoji . shortcode } : ` ) ;
}
} }
>
< input
2024-05-22 14:12:13 +03:00
ref = { inputRef }
2024-05-01 19:14:25 +03:00
type = "search"
2024-08-13 10:26:23 +03:00
placeholder = { t ` Search emoji ` }
2024-05-01 19:14:25 +03:00
onInput = { onFind }
autocomplete = "off"
autocorrect = "off"
autocapitalize = "off"
spellCheck = "false"
dir = "auto"
2024-05-22 14:12:13 +03:00
defaultValue = { defaultSearchTerm || '' }
2024-05-01 19:14:25 +03:00
/ >
< / form >
< / header >
< main ref = { scrollableRef } >
{ matches !== null ? (
< ul class = "custom-emojis-matches custom-emojis-list" >
{ matches . map ( ( emoji ) => (
< li key = { emoji . shortcode } class = "custom-emojis-match" >
< CustomEmojiButton
emoji = { emoji }
onClick = { ( ) => {
onSelectEmoji ( ` : ${ emoji . shortcode } : ` ) ;
} }
showCode
/ >
< / li >
) ) }
< / ul >
) : (
< div class = "custom-emojis-list" >
{ uiState === 'error' && (
< div class = "ui-state" >
2024-08-13 10:26:23 +03:00
< p >
< Trans > Error loading custom emojis < / Trans >
< / p >
2024-05-01 19:14:25 +03:00
< / div >
) }
{ uiState === 'default' &&
Object . entries ( customEmojisCatList ) . map (
( [ category , emojis ] ) =>
! ! emojis ? . length && (
< >
< div class = "section-header" >
{ {
2024-08-13 10:26:23 +03:00
'--recent--' : t ` Recently used ` ,
'--others--' : t ` Others ` ,
2024-05-01 19:14:25 +03:00
} [ category ] || category }
< / div >
< CustomEmojisList
emojis = { emojis }
onSelect = { onSelectEmoji }
/ >
< / >
) ,
) }
< / div >
) }
2023-03-24 17:30:05 +03:00
< / main >
< / div >
) ;
}
2024-05-01 19:14:25 +03:00
const CustomEmojisList = memo ( ( { emojis , onSelect } ) => {
2024-08-13 10:26:23 +03:00
const { i18n } = useLingui ( ) ;
2024-05-01 19:14:25 +03:00
const [ max , setMax ] = useState ( CUSTOM _EMOJIS _COUNT ) ;
const showMore = emojis . length > max ;
return (
< section >
{ emojis . slice ( 0 , max ) . map ( ( emoji ) => (
< CustomEmojiButton
key = { emoji . shortcode }
emoji = { emoji }
onClick = { ( ) => {
onSelect ( ` : ${ emoji . shortcode } : ` ) ;
} }
/ >
) ) }
{ showMore && (
< button
type = "button"
class = "plain small"
onClick = { ( ) => setMax ( max + CUSTOM _EMOJIS _COUNT ) }
>
2024-08-13 10:26:23 +03:00
< Trans > { i18n . number ( emojis . length - max ) } more … < / Trans >
2024-05-01 19:14:25 +03:00
< / button >
) }
< / section >
) ;
} ) ;
const CustomEmojiButton = memo ( ( { emoji , onClick , showCode } ) => {
const addEdges = ( e ) => {
// Add edge-left or edge-right class based on self position relative to scrollable parent
// If near left edge, add edge-left, if near right edge, add edge-right
const buffer = 88 ;
const parent = e . currentTarget . closest ( 'main' ) ;
if ( parent ) {
const rect = parent . getBoundingClientRect ( ) ;
const selfRect = e . currentTarget . getBoundingClientRect ( ) ;
const targetClassList = e . currentTarget . classList ;
if ( selfRect . left < rect . left + buffer ) {
targetClassList . add ( 'edge-left' ) ;
targetClassList . remove ( 'edge-right' ) ;
} else if ( selfRect . right > rect . right - buffer ) {
targetClassList . add ( 'edge-right' ) ;
targetClassList . remove ( 'edge-left' ) ;
} else {
targetClassList . remove ( 'edge-left' , 'edge-right' ) ;
}
}
} ;
return (
< button
type = "button"
className = "plain4"
onClick = { onClick }
data - title = { showCode ? undefined : emoji . shortcode }
onPointerEnter = { addEdges }
onFocus = { addEdges }
>
< picture >
{ ! ! emoji . staticUrl && (
< source
srcSet = { emoji . staticUrl }
media = "(prefers-reduced-motion: reduce)"
/ >
) }
< img
className = "shortcode-emoji"
src = { emoji . url || emoji . staticUrl }
alt = { emoji . shortcode }
width = "24"
height = "24"
loading = "lazy"
decoding = "async"
/ >
< / picture >
{ showCode && (
< >
{ ' ' }
< code > { emoji . shortcode } < / code >
< / >
) }
< / button >
) ;
} ) ;
2024-04-02 12:51:48 +03:00
const GIFS _PER _PAGE = 20 ;
function GIFPickerModal ( { onClose = ( ) => { } , onSelect = ( ) => { } } ) {
2024-08-13 10:26:23 +03:00
const { i18n } = useLingui ( ) ;
2024-04-02 12:51:48 +03:00
const [ uiState , setUIState ] = useState ( 'default' ) ;
const [ results , setResults ] = useState ( [ ] ) ;
const formRef = useRef ( null ) ;
const qRef = useRef ( null ) ;
const currentOffset = useRef ( 0 ) ;
const scrollableRef = useRef ( null ) ;
function fetchGIFs ( { offset } ) {
console . log ( 'fetchGIFs' , { offset } ) ;
if ( ! qRef . current ? . value ) return ;
setUIState ( 'loading' ) ;
scrollableRef . current ? . scrollTo ? . ( {
top : 0 ,
left : 0 ,
behavior : 'smooth' ,
} ) ;
( async ( ) => {
try {
const query = {
api _key : GIPHY _API _KEY ,
q : qRef . current . value ,
rating : 'g' ,
limit : GIFS _PER _PAGE ,
bundle : 'messaging_non_clips' ,
offset ,
2024-08-13 10:26:23 +03:00
lang : i18n . locale || 'en' ,
2024-04-02 12:51:48 +03:00
} ;
const response = await fetch (
'https://api.giphy.com/v1/gifs/search?' + new URLSearchParams ( query ) ,
{
referrerPolicy : 'no-referrer' ,
} ,
) . then ( ( r ) => r . json ( ) ) ;
currentOffset . current = response . pagination ? . offset || 0 ;
setResults ( response ) ;
setUIState ( 'results' ) ;
} catch ( e ) {
setUIState ( 'error' ) ;
console . error ( e ) ;
}
} ) ( ) ;
}
useEffect ( ( ) => {
qRef . current ? . focus ( ) ;
} , [ ] ) ;
2024-04-17 03:26:35 +03:00
const debouncedOnInput = useDebouncedCallback ( ( ) => {
fetchGIFs ( { offset : 0 } ) ;
} , 1000 ) ;
2024-04-02 12:51:48 +03:00
return (
< div id = "gif-picker-sheet" class = "sheet" >
{ ! ! onClose && (
< button type = "button" class = "sheet-close" onClick = { onClose } >
2024-08-13 10:26:23 +03:00
< Icon icon = "x" alt = { t ` Close ` } / >
2024-04-02 12:51:48 +03:00
< / button >
) }
< header >
< form
ref = { formRef }
onSubmit = { ( e ) => {
e . preventDefault ( ) ;
fetchGIFs ( { offset : 0 } ) ;
} }
>
< input
ref = { qRef }
type = "search"
name = "q"
2024-08-13 10:26:23 +03:00
placeholder = { t ` Search GIFs ` }
2024-04-02 12:51:48 +03:00
required
autocomplete = "off"
autocorrect = "off"
autocapitalize = "off"
spellCheck = "false"
dir = "auto"
2024-04-17 03:26:35 +03:00
onInput = { debouncedOnInput }
2024-04-02 12:51:48 +03:00
/ >
< input
type = "image"
class = "powered-button"
src = { poweredByGiphyURL }
width = "86"
height = "30"
2024-08-13 10:26:23 +03:00
alt = { t ` Powered by GIPHY ` }
2024-04-02 12:51:48 +03:00
/ >
< / form >
< / header >
< main ref = { scrollableRef } class = { uiState === 'loading' ? 'loading' : '' } >
{ uiState === 'default' && (
< div class = "ui-state" >
2024-08-13 10:26:23 +03:00
< p class = "insignificant" >
< Trans > Type to search GIFs < / Trans >
< / p >
2024-04-02 12:51:48 +03:00
< / div >
) }
{ uiState === 'loading' && ! results ? . data ? . length && (
< div class = "ui-state" >
< Loader abrupt / >
< / div >
) }
{ results ? . data ? . length > 0 ? (
< >
< ul >
{ results . data . map ( ( gif ) => {
const { id , images , title , alt _text } = gif ;
const {
fixed _height _small ,
fixed _height _downsampled ,
fixed _height ,
original ,
} = images ;
const theImage = fixed _height _small ? . url
? fixed _height _small
: fixed _height _downsampled ? . url
? fixed _height _downsampled
: fixed _height ;
let { url , webp , width , height } = theImage ;
if ( + height > 100 ) {
width = ( width / height ) * 100 ;
height = 100 ;
}
2024-06-14 03:34:50 +03:00
const urlObj = URL . parse ( url ) ;
2024-04-02 12:51:48 +03:00
const strippedURL = urlObj . origin + urlObj . pathname ;
let strippedWebP ;
if ( webp ) {
2024-06-14 03:34:50 +03:00
const webpObj = URL . parse ( webp ) ;
2024-04-02 12:51:48 +03:00
strippedWebP = webpObj . origin + webpObj . pathname ;
}
return (
< li key = { id } >
< button
type = "button"
onClick = { ( ) => {
const { mp4 , url } = original ;
const theURL = mp4 || url ;
2024-06-14 03:34:50 +03:00
const urlObj = URL . parse ( theURL ) ;
2024-04-02 12:51:48 +03:00
const strippedURL = urlObj . origin + urlObj . pathname ;
onClose ( ) ;
onSelect ( {
url : strippedURL ,
type : mp4 ? 'video/mp4' : 'image/gif' ,
alt _text : alt _text || title ,
} ) ;
} }
>
< figure
style = { {
'--figure-width' : width + 'px' ,
// width: width + 'px'
} }
>
< picture >
{ strippedWebP && (
< source srcset = { strippedWebP } type = "image/webp" / >
) }
< img
src = { strippedURL }
width = { width }
height = { height }
loading = "lazy"
decoding = "async"
alt = { alt _text }
referrerpolicy = "no-referrer"
onLoad = { ( e ) => {
e . target . style . backgroundColor = 'transparent' ;
} }
/ >
< / picture >
< figcaption > { alt _text || title } < / figcaption >
< / figure >
< / button >
< / li >
) ;
} ) }
< / ul >
< p class = "pagination" >
{ results . pagination ? . offset > 0 && (
< button
type = "button"
class = "light small"
disabled = { uiState === 'loading' }
onClick = { ( ) => {
fetchGIFs ( {
offset : results . pagination ? . offset - GIFS _PER _PAGE ,
} ) ;
} }
>
< Icon icon = "chevron-left" / >
2024-08-13 10:26:23 +03:00
< span >
< Trans > Previous < / Trans >
< / span >
2024-04-02 12:51:48 +03:00
< / button >
) }
< span / >
{ results . pagination ? . offset + results . pagination ? . count <
results . pagination ? . total _count && (
< button
type = "button"
class = "light small"
disabled = { uiState === 'loading' }
onClick = { ( ) => {
fetchGIFs ( {
offset : results . pagination ? . offset + GIFS _PER _PAGE ,
} ) ;
} }
>
2024-08-13 10:26:23 +03:00
< span >
< Trans > Next < / Trans >
< / span > { ' ' }
< Icon icon = "chevron-right" / >
2024-04-02 12:51:48 +03:00
< / button >
) }
< / p >
< / >
) : (
uiState === 'results' && (
< div class = "ui-state" >
< p > No results < / p >
< / div >
)
) }
{ uiState === 'error' && (
< div class = "ui-state" >
2024-08-13 10:26:23 +03:00
< p >
< Trans > Error loading GIFs < / Trans >
< / p >
2024-04-02 12:51:48 +03:00
< / div >
) }
< / main >
< / div >
) ;
}
2022-12-13 19:39:35 +03:00
export default Compose ;