2022-12-10 12:14:48 +03:00
import './compose.css' ;
import '@github/text-expander-element' ;
2023-01-13 10:30:09 +03:00
import equal from 'fast-deep-equal' ;
2023-01-04 14:03:11 +03:00
import { forwardRef } from 'preact/compat' ;
2023-03-24 17:30:05 +03:00
import { useEffect , useMemo , useRef , useState } from 'preact/hooks' ;
2023-01-01 14:41:42 +03:00
import { useHotkeys } from 'react-hotkeys-hook' ;
2022-12-10 15:46:56 +03:00
import stringLength from 'string-length' ;
2023-01-11 09:44:20 +03:00
import { uid } from 'uid/single' ;
2023-01-30 15:51:06 +03:00
import { useDebouncedCallback } from 'use-debounce' ;
2023-01-04 14:03:11 +03:00
import { useSnapshot } from 'valtio' ;
2022-12-10 12:14:48 +03:00
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' ;
2023-05-20 09:14:35 +03:00
import localeMatch from '../utils/locale-match' ;
2022-12-13 16:54:16 +03:00
import openCompose from '../utils/open-compose' ;
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 ,
} from '../utils/store-utils' ;
import supports from '../utils/supports' ;
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' ;
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 = {
'5 minutes' : 5 * 60 ,
'30 minutes' : 30 * 60 ,
'1 hour' : 60 * 60 ,
'6 hours' : 6 * 60 * 60 ,
2023-01-22 15:50:11 +03:00
'12 hours' : 12 * 60 * 60 ,
2022-12-14 16:48:17 +03:00
'1 day' : 24 * 60 * 60 ,
'3 days' : 3 * 24 * 60 * 60 ,
'7 days' : 7 * 24 * 60 * 60 ,
} ;
const expirySeconds = Object . values ( expiryOptions ) ;
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 ) {
menu . style . left = innerWidth - width - windowMargin + 'px' ;
}
}
} ) ;
} ) ;
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' ) ;
}
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
} ) {
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-02-24 07:20:31 +03:00
const { configuration } = getCurrentInstance ( ) ;
2023-02-12 20:21:18 +03:00
console . log ( '⚙️ Configuration' , configuration ) ;
2022-12-10 12:14:48 +03:00
const {
statuses : { maxCharacters , maxMediaAttachments , charactersReservedPerUrl } ,
mediaAttachments : {
supportedMimeTypes ,
imageSizeLimit ,
imageMatrixLimit ,
videoSizeLimit ,
videoMatrixLimit ,
videoFrameRateLimit ,
} ,
polls : { maxOptions , maxCharactersPerOption , maxExpiration , minExpiration } ,
} = configuration ;
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 ( ( ) => {
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' ]
? prefs [ 'posting:default:visibility' ]
: visibility ,
) ;
2023-02-09 18:59:57 +03:00
setLanguage ( language || prefs . postingDefaultLanguage || DEFAULT _LANG ) ;
2022-12-13 15:42:09 +03:00
setSensitive ( sensitive ) ;
} 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 {
2022-12-25 18:28:55 +03:00
const statusSource = await masto . v1 . statuses . fetchSource (
editStatus . id ,
) ;
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 ) ;
2023-02-09 18:59:57 +03:00
setLanguage ( language || presf . postingDefaultLanguage || DEFAULT _LANG ) ;
2022-12-12 16:54:31 +03:00
setSensitive ( sensitive ) ;
2022-12-14 16:48:17 +03:00
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' ] ) {
setVisibility ( prefs [ 'posting:default:visibility' ] ) ;
2023-02-09 18:59:57 +03:00
}
2023-06-23 16:20:11 +03:00
if ( prefs [ 'posting:default:language' ] ) {
setLanguage ( prefs [ 'posting:default:language' ] ) ;
2023-02-09 18:59:57 +03:00
}
2023-06-23 16:20:11 +03:00
if ( prefs [ 'posting:default:sensitive' ] ) {
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 ) ;
2023-02-19 19:46:21 +03:00
setLanguage ( language || prefs . postingDefaultLanguage || 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 ( ) ;
2023-02-16 14:10:26 +03:00
const beforeUnloadCopy = '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
const hasMediaAttachments = mediaAttachments . length > 0 ;
if ( ! value && ! 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-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 🤞
} ,
{
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
} ,
{
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-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 ;
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 ,
} ,
} ;
if ( ! equal ( backgroundDraft , prevBackgroundDraft . current ) && ! canClose ( ) ) {
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 ) {
alert ( ` You can only attach up to ${ maxMediaAttachments } files. ` ) ;
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 ) {
alert ( ` You can only attach up to ${ maxMediaAttachments } files. ` ) ;
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 ] ) ;
2023-03-24 17:30:05 +03:00
const [ showEmoji2Picker , setShowEmoji2Picker ] = useState ( false ) ;
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-03-23 11:13:22 +03:00
/ >
) }
{ ! standalone ? (
< span >
< 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;
// }
// }
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 ) {
alert ( 'Looks like your browser is blocking popups.' ) ;
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
} }
>
< Icon icon = "popout" alt = "Pop out" / >
< / button > { ' ' }
< button
type = "button"
class = "light close-button"
disabled = { uiState === 'loading' }
onClick = { ( ) => {
if ( confirmClose ( ) ) {
onClose ( ) ;
}
} }
>
< Icon icon = "x" / >
< / 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 ) {
alert ( 'Looks like you closed the parent window.' ) ;
return ;
}
2022-12-13 15:42:09 +03:00
2023-03-23 11:13:22 +03:00
if ( window . opener . _ _STATES _ _ . showCompose ) {
const yes = confirm (
'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?' ,
) ;
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
window . opener . _ _STATES _ _ . showCompose = true ;
} ,
} ) ;
} }
>
< Icon icon = "popin" alt = "Pop in" / >
< / button >
)
) }
< / div >
{ ! ! replyToStatus && (
< div class = "status-preview" >
< Status status = { replyToStatus } size = "s" previewMode / >
< div class = "status-preview-legend reply-to" >
Replying to @
{ replyToStatus . account . acct || replyToStatus . account . username }
2023-04-29 17:22:07 +03:00
& rsquo ; s post
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 / >
2023-04-29 17:22:07 +03:00
< div class = "status-preview-legend" > Editing source post < / 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 ) {
alert ( 'Poll must have at least 2 options' ) ;
return ;
}
if ( poll . options . some ( ( option ) => option === '' ) ) {
alert ( 'Some poll choices are empty' ) ;
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 (
'Some media have no descriptions. Continue?' ,
) ;
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
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-03-23 11:13:22 +03:00
return masto . v2 . mediaAttachments
. 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 ;
} )
) {
setUIState ( 'error' ) ;
// Alert all the reasons
results . forEach ( ( result ) => {
if ( result . status === 'rejected' ) {
console . error ( result ) ;
alert ( result . reason || ` Attachment # ${ i } failed ` ) ;
}
} ) ;
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-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 ) {
newStatus = await masto . v1 . statuses . update (
editStatus . id ,
params ,
) ;
saveStatus ( newStatus , instance , {
skipThreading : true ,
} ) ;
} else {
2023-05-23 14:16:24 +03:00
try {
newStatus = await masto . v1 . statuses . create ( params , {
idempotencyKey : UID . current ,
} ) ;
} catch ( _ ) {
// If idempotency key fails, try again without it
newStatus = await masto . v1 . statuses . create ( params ) ;
}
2023-03-23 11:13:22 +03:00
}
setUIState ( 'default' ) ;
// Close
onClose ( {
newStatus ,
instance ,
2023-01-11 09:44:20 +03:00
} ) ;
2023-03-23 11:13:22 +03:00
} catch ( e ) {
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"
placeholder = "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' : '' } ` }
title = "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' : '' } ` }
title = { ` Visibility: ${ visibility } ` }
>
< Icon icon = { visibilityIconsMap [ visibility ] } alt = { visibility } / >
< select
name = "visibility"
value = { visibility }
onChange = { ( e ) => {
setVisibility ( e . target . value ) ;
} }
disabled = { uiState === 'loading' || ! ! editStatus }
>
< option value = "public" >
Public < Icon icon = "earth" / >
< / option >
< option value = "unlisted" > Unlisted < / option >
< option value = "private" > Followers only < / option >
2023-04-06 13:21:56 +03:00
< option value = "direct" > Private mention < / option >
2023-03-23 11:13:22 +03:00
< / select >
< / label > { ' ' }
2022-12-10 12:14:48 +03:00
< / div >
2023-03-23 11:13:22 +03:00
< Textarea
ref = { textareaRef }
placeholder = {
replyToStatus
? 'Post your reply'
: editStatus
2023-04-29 17:22:07 +03:00
? 'Edit your post'
2023-03-23 11:13:22 +03:00
: 'What are you doing?'
}
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' ) {
return masto . v1 . accounts . search ( {
q ,
limit ,
resolve : false ,
} ) ;
}
2023-03-23 11:13:22 +03:00
return masto . v2 . search ( params ) ;
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 ) ;
} }
/ > { ' ' }
< span > Mark media as sensitive < / span > { ' ' }
< 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 (
` You can only attach up to ${ maxMediaAttachments } files. ` ,
) ;
} else {
setMediaAttachments ( ( attachments ) => {
return attachments . concat ( mediaFiles ) ;
} ) ;
}
// Reset
e . target . value = '' ;
} }
/ >
< Icon icon = "attachment" / >
< / label > { ' ' }
< button
type = "button"
class = "toolbar-button"
2023-03-23 11:13:22 +03:00
disabled = {
2023-03-24 17:30:05 +03:00
uiState === 'loading' || ! ! poll || ! ! mediaAttachments . length
2023-03-23 11:13:22 +03:00
}
2023-03-24 17:30:05 +03:00
onClick = { ( ) => {
setPoll ( {
options : [ '' , '' ] ,
expiresIn : 24 * 60 * 60 , // 1 day
multiple : false ,
} ) ;
2023-03-23 11:13:22 +03:00
} }
2023-03-24 17:30:05 +03:00
>
< Icon icon = "poll" alt = "Add poll" / >
< / button > { ' ' }
< button
type = "button"
class = "toolbar-button"
disabled = { uiState === 'loading' }
onClick = { ( ) => {
setShowEmoji2Picker ( true ) ;
} }
>
< Icon icon = "emoji2" / >
< / button >
< / 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 ${
language !== prevLanguage . current ? 'highlight' : ''
} ` }
>
< 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' }
>
{ supportedLanguages
2023-04-17 15:47:34 +03:00
. sort ( ( [ codeA , commonA ] , [ codeB , commonB ] ) => {
const { contentTranslationHideLanguages = [ ] } =
states . settings ;
// Sort codes that same as language, prevLanguage, DEFAULT_LANGUAGE and all the ones in states.settings.contentTranslationHideLanguages, to the top
if (
codeA === language ||
codeA === prevLanguage ||
codeA === DEFAULT _LANG ||
contentTranslationHideLanguages ? . includes ( codeA )
)
return - 1 ;
if (
codeB === language ||
codeB === prevLanguage ||
codeB === DEFAULT _LANG ||
contentTranslationHideLanguages ? . includes ( codeB )
)
return 1 ;
2023-03-23 11:13:22 +03:00
return commonA . localeCompare ( commonB ) ;
} )
. map ( ( [ code , common , native ] ) => (
< option value = { code } >
{ common } ( { native } )
< / option >
) ) }
< / select >
< / label > { ' ' }
< button
type = "submit"
class = "large"
2022-12-27 13:09:23 +03:00
disabled = { uiState === 'loading' }
>
2023-03-23 11:13:22 +03:00
{ replyToStatus ? 'Reply' : editStatus ? 'Update' : 'Post' }
< / button >
< / div >
< / form >
< / div >
2023-03-24 17:30:05 +03:00
{ showEmoji2Picker && (
< Modal
class = "light"
onClick = { ( e ) => {
if ( e . target === e . currentTarget ) {
setShowEmoji2Picker ( false ) ;
}
} }
>
< CustomEmojisModal
masto = { masto }
instance = { instance }
onClose = { ( ) => {
setShowEmoji2Picker ( false ) ;
} }
onSelect = { ( emoji ) => {
const emojiWithSpace = ` ${ emoji } ` ;
const textarea = textareaRef . current ;
if ( ! textarea ) return ;
const { selectionStart , selectionEnd } = textarea ;
const text = textarea . value ;
const newText =
text . slice ( 0 , selectionStart ) +
emojiWithSpace +
text . slice ( selectionEnd ) ;
textarea . value = newText ;
textarea . selectionStart = textarea . selectionEnd =
selectionEnd + emojiWithSpace . length ;
textarea . focus ( ) ;
textarea . dispatchEvent ( new Event ( 'input' ) ) ;
} }
/ >
< / 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 ;
textarea . style . height = value ? scrollHeight + offset + 'px' : null ;
}
}
2023-01-04 14:03:11 +03:00
const Textarea = forwardRef ( ( props , ref ) => {
2023-02-24 07:25:05 +03:00
const { masto } = api ( ) ;
2023-01-04 14:03:11 +03:00
const [ text , setText ] = useState ( ref . current ? . value || '' ) ;
2023-02-05 19:17:19 +03:00
const { maxCharacters , performSearch = ( ) => { } , ... textareaProps } = props ;
2023-01-04 14:03:11 +03:00
const snapStates = useSnapshot ( states ) ;
const charCount = snapStates . composerCharacterCount ;
2023-02-23 06:36:07 +03:00
const customEmojis = useRef ( ) ;
useEffect ( ( ) => {
( async ( ) => {
try {
const emojis = await masto . v1 . customEmojis . list ( ) ;
console . log ( { emojis } ) ;
customEmojis . current = emojis ;
} catch ( e ) {
// silent fail
console . error ( e ) ;
}
} ) ( ) ;
} , [ ] ) ;
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),
// );
const emojis = filterShortcodes ( customEmojis . current , text ) ;
let html = '' ;
emojis . forEach ( ( emoji ) => {
const { shortcode , url } = emoji ;
html += `
< li role = "option" data - value = "${encodeHTML(shortcode)}" >
< img src = " $ { encodeHTML (
url ,
) } " width=" 16 " height=" 16 " alt=" " loading=" lazy " / >
: $ { encodeHTML ( shortcode ) } :
< / li > ` ;
} ) ;
// console.log({ emojis, html });
menu . innerHTML = html ;
provide (
Promise . resolve ( {
matched : emojis . length > 0 ,
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 ,
} = 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 >
< br > @ $ { encodeHTML ( acct ) }
< / span >
< / li >
` ;
} else {
html += `
< li role = "option" data - value = "${encodeHTML(name)}" >
< span > # < b > $ { encodeHTML ( name ) } < / b > < / span >
< / li >
` ;
}
menu . innerHTML = html ;
} ) ;
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 ;
if ( key === ':' ) {
e . detail . value = ` : ${ item . dataset . value } : ` ;
} else {
e . detail . value = ` ${ key } ${ item . dataset . value } ` ;
}
} ;
textExpanderRef . current . addEventListener (
'text-expander-value' ,
handleValue ,
) ;
handleCommited = ( e ) => {
const { input } = e . detail ;
setText ( input . value ) ;
} ;
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 ,
) ;
}
} ;
} , [ ] ) ;
return (
< text - expander ref = { textExpanderRef } keys = "@ # :" >
2023-02-27 11:01:26 +03:00
< textarea
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 ;
if ( key === 'Enter' ) {
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 ) ;
}
}
} catch ( e ) {
// silent fail
console . error ( e ) ;
}
2023-09-10 10:29:25 +03:00
}
2023-09-16 17:57:35 +03:00
} }
onInput = { ( e ) => {
const { target } = e ;
setText ( target . value ) ;
autoResizeTextarea ( target ) ;
2023-02-27 11:01:26 +03:00
props . onInput ? . ( e ) ;
} }
style = { {
width : '100%' ,
height : '4em' ,
'--text-weight' : ( 1 + charCount / 140 ) . toFixed ( 1 ) || 1 ,
} }
/ >
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 ) {
2023-03-23 20:26:49 +03:00
return < meter class = "donut" hidden / > ;
2023-01-04 14:03:11 +03:00
}
return (
< meter
class = { ` donut ${
leftChars <= - 10
? 'explode'
: leftChars <= 0
? 'danger'
: leftChars <= 20
? 'warning'
: ''
} ` }
value = { charCount }
max = { maxCharacters }
data - left = { leftChars }
2023-08-13 07:00:33 +03:00
title = { ` ${ leftChars } / ${ maxCharacters } ` }
2023-01-04 14:03:11 +03:00
style = { {
'--percentage' : ( charCount / maxCharacters ) * 100 ,
} }
/ >
) ;
}
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 = ( ) => { } ,
} ) {
2023-02-12 20:21:18 +03:00
const supportsEdit = supports ( '@mastodon/edit-media-attributes' ) ;
2023-01-05 20:51:39 +03:00
const { url , type , id } = attachment ;
console . log ( { attachment } ) ;
const [ description , setDescription ] = useState ( attachment . description ) ;
2022-12-12 11:27:44 +03:00
const suffixType = type . split ( '/' ) [ 0 ] ;
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
) ;
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" >
< span class = "tag" > Uploaded < / 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 = {
{
image : 'Image description' ,
video : 'Video description' ,
audio : 'Audio description' ,
} [ suffixType ]
}
autoCapitalize = "sentences"
autoComplete = "on"
autoCorrect = "on"
spellCheck = "true"
dir = "auto"
disabled = { disabled }
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 ) ;
debouncedOnDescriptionChange ( value ) ;
2022-12-12 11:27:44 +03:00
} }
> < / textarea >
) }
2023-01-05 20:51:39 +03:00
< / >
) ;
return (
< >
< div class = "media-attachment" >
< div
class = "media-preview"
onClick = { ( ) => {
setShowModal ( true ) ;
} }
2022-12-12 11:27:44 +03:00
>
2023-01-05 20:51:39 +03:00
{ suffixType === 'image' ? (
< img src = { url } alt = "" / >
) : suffixType === 'video' || suffixType === 'gifv' ? (
< video src = { url } playsinline muted / >
) : suffixType === 'audio' ? (
< audio src = { url } controls / >
) : null }
< / div >
{ descTextarea }
< div class = "media-aside" >
< button
type = "button"
class = "plain close-button"
disabled = { disabled }
onClick = { onRemove }
>
< Icon icon = "x" / >
< / button >
< / div >
2022-12-12 11:27:44 +03:00
< / div >
2023-01-05 20:51:39 +03:00
{ showModal && (
< Modal
onClick = { ( e ) => {
if ( e . target === e . currentTarget ) {
setShowModal ( false ) ;
}
} }
>
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 ) ;
} }
>
< Icon icon = "x" / >
< / button >
2023-01-05 20:51:39 +03:00
< header >
< h2 >
{
{
image : 'Edit image description' ,
video : 'Edit video description' ,
audio : 'Edit audio description' ,
} [ 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 >
{ descTextarea }
< / 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 ,
} ) {
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 }
placeholder = { ` 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 ) ;
} }
>
< Icon icon = "x" size = "s" / >
< / 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 ) ;
} }
/ > { ' ' }
Multiple choices
< / label >
< label class = "expires-in" >
Duration { ' ' }
< select
value = { expiresIn }
disabled = { disabled }
onChange = { ( e ) => {
const { value } = e . target ;
poll . expiresIn = value ;
onInput ( poll ) ;
} }
>
{ Object . entries ( expiryOptions )
. filter ( ( [ label , value ] ) => {
return value >= minExpiration && value <= maxExpiration ;
} )
. map ( ( [ label , value ] ) => (
< option value = { value } key = { value } >
{ label }
< / option >
) ) }
< / select >
< / label >
< / div >
< div class = "poll-toolbar" >
< button
type = "button"
class = "plain remove-poll-button"
disabled = { disabled }
onClick = { ( ) => {
onInput ( null ) ;
} }
>
Remove poll
< / 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 ;
}
2023-03-24 17:30:05 +03:00
function CustomEmojisModal ( {
masto ,
instance ,
onClose = ( ) => { } ,
onSelect = ( ) => { } ,
} ) {
const [ uiState , setUIState ] = useState ( 'default' ) ;
const customEmojisList = useRef ( [ ] ) ;
const [ customEmojis , setCustomEmojis ] = useState ( { } ) ;
const recentlyUsedCustomEmojis = useMemo (
( ) => store . account . get ( 'recentlyUsedCustomEmojis' ) || [ ] ,
) ;
useEffect ( ( ) => {
setUIState ( 'loading' ) ;
( async ( ) => {
try {
const emojis = await masto . v1 . customEmojis . list ( ) ;
// Group emojis by category
const emojisCat = {
'--recent--' : recentlyUsedCustomEmojis . filter ( ( emoji ) =>
emojis . find ( ( e ) => e . shortcode === emoji . shortcode ) ,
) ,
} ;
const othersCat = [ ] ;
emojis . forEach ( ( emoji ) => {
if ( ! emoji . visibleInPicker ) return ;
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 ;
}
setCustomEmojis ( emojisCat ) ;
setUIState ( 'default' ) ;
} catch ( e ) {
setUIState ( 'error' ) ;
console . error ( e ) ;
}
} ) ( ) ;
} , [ ] ) ;
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 } >
< Icon icon = "x" / >
< / button >
) }
2023-03-24 17:30:05 +03:00
< header >
< b > Custom emojis < / b > { ' ' }
{ uiState === 'loading' ? (
< Loader / >
) : (
< small class = "insignificant" > • { instance } < / small >
) }
< / header >
< main >
< div class = "custom-emojis-list" >
{ uiState === 'error' && (
< div class = "ui-state" >
< p > Error loading custom emojis < / p >
< / div >
) }
{ uiState === 'default' &&
Object . entries ( customEmojis ) . map (
( [ category , emojis ] ) =>
! ! emojis ? . length && (
< >
< div class = "section-header" >
{ {
'--recent--' : 'Recently used' ,
'--others--' : 'Others' ,
} [ category ] || category }
< / div >
< section >
{ emojis . map ( ( emoji ) => (
< button
key = { emoji }
type = "button"
class = "plain4"
onClick = { ( ) => {
onClose ( ) ;
requestAnimationFrame ( ( ) => {
onSelect ( ` : ${ emoji . shortcode } : ` ) ;
} ) ;
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 ,
) ;
} }
title = { ` : ${ emoji . shortcode } : ` }
>
2023-09-24 10:45:01 +03:00
< picture >
{ ! ! emoji . staticUrl && (
< source
srcset = { emoji . staticUrl }
media = "(prefers-reduced-motion: reduce)"
/ >
) }
< img
class = "shortcode-emoji"
src = { emoji . url || emoji . staticUrl }
alt = { emoji . shortcode }
width = "16"
height = "16"
loading = "lazy"
decoding = "async"
/ >
< / picture >
2023-03-24 17:30:05 +03:00
< / button >
) ) }
< / section >
< / >
) ,
) }
< / div >
< / main >
< / div >
) ;
}
2022-12-13 19:39:35 +03:00
export default Compose ;