2022-10-07 19:14:31 +11:00
import PropTypes from 'prop-types' ;
2023-07-28 00:11:17 +10:00
import React from 'react' ;
2023-05-24 01:15:17 +10:00
2023-07-28 00:11:17 +10:00
import { FormattedMessage , defineMessages , injectIntl } from 'react-intl' ;
2023-05-24 01:15:17 +10:00
2022-10-07 19:14:31 +11:00
import classNames from 'classnames' ;
2023-05-24 01:15:17 +10:00
import { connect } from 'react-redux' ;
2023-07-28 00:11:17 +10:00
import { throttle , escapeRegExp } from 'lodash' ;
2024-01-16 21:27:26 +11:00
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react' ;
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react' ;
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react' ;
import StarIcon from '@/material-icons/400-24px/star.svg?react' ;
2022-10-27 04:35:55 +11:00
import { openModal , closeModal } from 'mastodon/actions/modal' ;
2023-07-28 00:11:17 +10:00
import api from 'mastodon/api' ;
2023-10-23 18:43:00 +11:00
import { Button } from 'mastodon/components/button' ;
2023-05-24 01:15:17 +10:00
import { Icon } from 'mastodon/components/icon' ;
2023-08-04 00:43:15 +10:00
import { registrationsOpen , sso _redirect } from 'mastodon/initial_state' ;
2022-10-07 19:14:31 +11:00
2023-07-28 00:11:17 +10:00
const messages = defineMessages ( {
loginPrompt : { id : 'interaction_modal.login.prompt' , defaultMessage : 'Domain of your home server, e.g. mastodon.social' } ,
} ) ;
2022-10-07 19:14:31 +11:00
const mapStateToProps = ( state , { accountId } ) => ( {
displayNameHtml : state . getIn ( [ 'accounts' , accountId , 'display_name_html' ] ) ,
2023-08-14 20:04:04 +10:00
signupUrl : state . getIn ( [ 'server' , 'server' , 'registrations' , 'url' ] , null ) || '/auth/sign_up' ,
2022-10-07 19:14:31 +11:00
} ) ;
2022-10-27 04:35:55 +11:00
const mapDispatchToProps = ( dispatch ) => ( {
onSignupClick ( ) {
2023-08-14 20:04:04 +10:00
dispatch ( closeModal ( {
2023-10-09 22:38:29 +11:00
modalType : undefined ,
ignoreFocus : false ,
} ) ) ;
2023-08-14 20:04:04 +10:00
dispatch ( openModal ( { modalType : 'CLOSED_REGISTRATIONS' } ) ) ;
2022-10-27 04:35:55 +11:00
} ,
} ) ;
2023-07-28 00:11:17 +10:00
const PERSISTENCE _KEY = 'mastodon_home' ;
const isValidDomain = value => {
const url = new URL ( 'https:///path' ) ;
url . hostname = value ;
return url . hostname === value ;
} ;
const valueToDomain = value => {
// If the user starts typing an URL
if ( /^https?:\/\// . test ( value ) ) {
try {
const url = new URL ( value ) ;
// Consider that if there is a path, the URL is more meaningful than a bare domain
if ( url . pathname . length > 1 ) {
return '' ;
}
return url . host ;
} catch {
return undefined ;
}
// If the user writes their full handle including username
} else if ( value . includes ( '@' ) ) {
if ( value . replace ( /^@/ , '' ) . split ( '@' ) . length > 2 ) {
return undefined ;
}
return '' ;
}
return value ;
} ;
const addInputToOptions = ( value , options ) => {
value = value . trim ( ) ;
if ( value . includes ( '.' ) && isValidDomain ( value ) ) {
return [ value ] . concat ( options . filter ( ( x ) => x !== value ) ) ;
}
return options ;
} ;
class LoginForm extends React . PureComponent {
2022-10-07 19:14:31 +11:00
static propTypes = {
2023-07-28 00:11:17 +10:00
resourceUrl : PropTypes . string ,
intl : PropTypes . object . isRequired ,
2022-10-07 19:14:31 +11:00
} ;
state = {
2023-07-28 00:11:17 +10:00
value : localStorage ? ( localStorage . getItem ( PERSISTENCE _KEY ) || '' ) : '' ,
expanded : false ,
selectedOption : - 1 ,
isLoading : false ,
isSubmitting : false ,
error : false ,
options : [ ] ,
networkOptions : [ ] ,
2022-10-07 19:14:31 +11:00
} ;
setRef = c => {
this . input = c ;
2023-01-30 11:45:35 +11:00
} ;
2022-10-07 19:14:31 +11:00
2023-09-06 07:49:48 +10:00
isValueValid = ( value ) => {
let likelyAcct = false ;
let url = null ;
if ( value . startsWith ( '/' ) ) {
return false ;
}
if ( value . startsWith ( '@' ) ) {
value = value . slice ( 1 ) ;
likelyAcct = true ;
}
// The user is in the middle of typing something, do not error out
if ( value === '' ) {
return true ;
}
if ( /^https?:\/\// . test ( value ) && ! likelyAcct ) {
url = value ;
} else {
url = ` https:// ${ value } ` ;
}
try {
new URL ( url ) ;
return true ;
2024-09-11 23:59:46 +10:00
} catch {
2023-09-06 07:49:48 +10:00
return false ;
}
} ;
2023-07-28 00:11:17 +10:00
handleChange = ( { target } ) => {
2023-09-06 07:49:48 +10:00
const error = ! this . isValueValid ( target . value ) ;
this . setState ( state => ( { error , value : target . value , isLoading : true , options : addInputToOptions ( target . value , state . networkOptions ) } ) , ( ) => this . _loadOptions ( ) ) ;
2023-01-30 11:45:35 +11:00
} ;
2022-10-07 19:14:31 +11:00
2023-07-28 00:11:17 +10:00
handleMessage = ( event ) => {
const { resourceUrl } = this . props ;
if ( event . origin !== window . origin || event . source !== this . iframeRef . contentWindow ) {
return ;
}
if ( event . data ? . type === 'fetchInteractionURL-failure' ) {
this . setState ( { isSubmitting : false , error : true } ) ;
} else if ( event . data ? . type === 'fetchInteractionURL-success' ) {
if ( /^https?:\/\// . test ( event . data . template ) ) {
2023-09-06 07:49:48 +10:00
try {
const url = new URL ( event . data . template . replace ( '{uri}' , encodeURIComponent ( resourceUrl ) ) ) ;
2023-07-28 00:11:17 +10:00
2023-09-06 07:49:48 +10:00
if ( localStorage ) {
localStorage . setItem ( PERSISTENCE _KEY , event . data . uri _or _domain ) ;
}
window . location . href = url ;
} catch ( e ) {
console . error ( e ) ;
this . setState ( { isSubmitting : false , error : true } ) ;
}
2023-07-28 00:11:17 +10:00
} else {
this . setState ( { isSubmitting : false , error : true } ) ;
}
}
2023-01-30 11:45:35 +11:00
} ;
2022-10-07 19:14:31 +11:00
2023-07-28 00:11:17 +10:00
componentDidMount ( ) {
window . addEventListener ( 'message' , this . handleMessage ) ;
}
2022-10-07 19:14:31 +11:00
componentWillUnmount ( ) {
2023-07-28 00:11:17 +10:00
window . removeEventListener ( 'message' , this . handleMessage ) ;
2022-10-07 19:14:31 +11:00
}
2023-07-28 00:11:17 +10:00
handleSubmit = ( ) => {
const { value } = this . state ;
this . setState ( { isSubmitting : true } ) ;
this . iframeRef . contentWindow . postMessage ( {
type : 'fetchInteractionURL' ,
uri _or _domain : value . trim ( ) ,
} , window . origin ) ;
} ;
setIFrameRef = ( iframe ) => {
this . iframeRef = iframe ;
2023-10-09 22:38:29 +11:00
} ;
2023-07-28 00:11:17 +10:00
handleFocus = ( ) => {
this . setState ( { expanded : true } ) ;
} ;
handleBlur = ( ) => {
this . setState ( { expanded : false } ) ;
} ;
handleKeyDown = ( e ) => {
const { options , selectedOption } = this . state ;
switch ( e . key ) {
case 'ArrowDown' :
e . preventDefault ( ) ;
if ( options . length > 0 ) {
this . setState ( { selectedOption : Math . min ( selectedOption + 1 , options . length - 1 ) } ) ;
}
break ;
case 'ArrowUp' :
e . preventDefault ( ) ;
if ( options . length > 0 ) {
this . setState ( { selectedOption : Math . max ( selectedOption - 1 , - 1 ) } ) ;
}
break ;
case 'Enter' :
e . preventDefault ( ) ;
if ( selectedOption === - 1 ) {
this . handleSubmit ( ) ;
} else if ( options . length > 0 ) {
this . setState ( { value : options [ selectedOption ] , error : false } , ( ) => this . handleSubmit ( ) ) ;
}
break ;
}
} ;
handleOptionClick = e => {
const index = Number ( e . currentTarget . getAttribute ( 'data-index' ) ) ;
const option = this . state . options [ index ] ;
e . preventDefault ( ) ;
this . setState ( { selectedOption : index , value : option , error : false } , ( ) => this . handleSubmit ( ) ) ;
} ;
_loadOptions = throttle ( ( ) => {
const { value } = this . state ;
const domain = valueToDomain ( value . trim ( ) ) ;
if ( typeof domain === 'undefined' ) {
this . setState ( { options : [ ] , networkOptions : [ ] , isLoading : false , error : true } ) ;
return ;
}
if ( domain . length === 0 ) {
this . setState ( { options : [ ] , networkOptions : [ ] , isLoading : false } ) ;
return ;
}
api ( ) . get ( '/api/v1/peers/search' , { params : { q : domain } } ) . then ( ( { data } ) => {
if ( ! data ) {
data = [ ] ;
}
this . setState ( ( state ) => ( { networkOptions : data , options : addInputToOptions ( state . value , data ) , isLoading : false } ) ) ;
} ) . catch ( ( ) => {
this . setState ( { isLoading : false } ) ;
} ) ;
} , 200 , { leading : true , trailing : true } ) ;
2022-10-07 19:14:31 +11:00
render ( ) {
2023-07-28 00:11:17 +10:00
const { intl } = this . props ;
const { value , expanded , options , selectedOption , error , isSubmitting } = this . state ;
const domain = ( valueToDomain ( value ) || '' ) . trim ( ) ;
const domainRegExp = new RegExp ( ` ( ${ escapeRegExp ( domain ) } ) ` , 'gi' ) ;
const hasPopOut = domain . length > 0 && options . length > 0 ;
2022-10-07 19:14:31 +11:00
return (
2023-07-28 00:11:17 +10:00
< div className = { classNames ( 'interaction-modal__login' , { focused : expanded , expanded : hasPopOut , invalid : error } ) } >
< iframe
ref = { this . setIFrameRef }
style = { { display : 'none' } }
src = '/remote_interaction_helper'
sandbox = 'allow-scripts allow-same-origin'
title = 'remote interaction helper'
2022-10-07 19:14:31 +11:00
/ >
2023-07-28 00:11:17 +10:00
< div className = 'interaction-modal__login__input' >
< input
ref = { this . setRef }
type = 'text'
value = { value }
placeholder = { intl . formatMessage ( messages . loginPrompt ) }
aria - label = { intl . formatMessage ( messages . loginPrompt ) }
autoFocus
onChange = { this . handleChange }
onFocus = { this . handleFocus }
onBlur = { this . handleBlur }
onKeyDown = { this . handleKeyDown }
2023-12-07 00:42:12 +11:00
autoComplete = 'off'
autoCapitalize = 'off'
spellCheck = 'false'
2023-07-28 00:11:17 +10:00
/ >
2023-09-06 07:49:48 +10:00
< Button onClick = { this . handleSubmit } disabled = { isSubmitting || error } > < FormattedMessage id = 'interaction_modal.login.action' defaultMessage = 'Take me home' / > < / Button >
2023-07-28 00:11:17 +10:00
< / div >
{ hasPopOut && (
< div className = 'search__popout' >
< div className = 'search__popout__menu' >
{ options . map ( ( option , i ) => (
< button key = { option } onMouseDown = { this . handleOptionClick } data - index = { i } className = { classNames ( 'search__popout__menu__item' , { selected : selectedOption === i } ) } >
{ option . split ( domainRegExp ) . map ( ( part , i ) => (
part . toLowerCase ( ) === domain . toLowerCase ( ) ? (
< mark key = { i } >
{ part }
< / mark >
) : (
< span key = { i } >
{ part }
< / span >
)
) ) }
< / button >
) ) }
< / div >
< / div >
) }
2022-10-07 19:14:31 +11:00
< / div >
) ;
}
}
2023-07-28 00:11:17 +10:00
const IntlLoginForm = injectIntl ( LoginForm ) ;
class InteractionModal extends React . PureComponent {
2022-10-07 19:14:31 +11:00
static propTypes = {
displayNameHtml : PropTypes . string ,
url : PropTypes . string ,
type : PropTypes . oneOf ( [ 'reply' , 'reblog' , 'favourite' , 'follow' ] ) ,
2022-10-27 04:35:55 +11:00
onSignupClick : PropTypes . func . isRequired ,
2023-08-14 20:04:04 +10:00
signupUrl : PropTypes . string . isRequired ,
2022-10-07 19:14:31 +11:00
} ;
2022-10-27 04:35:55 +11:00
handleSignupClick = ( ) => {
this . props . onSignupClick ( ) ;
2023-01-30 11:45:35 +11:00
} ;
2022-10-27 04:35:55 +11:00
2022-10-07 19:14:31 +11:00
render ( ) {
2023-08-14 20:04:04 +10:00
const { url , type , displayNameHtml , signupUrl } = this . props ;
2022-10-07 19:14:31 +11:00
const name = < bdi dangerouslySetInnerHTML = { { _ _html : displayNameHtml } } / > ;
let title , actionDescription , icon ;
switch ( type ) {
case 'reply' :
2023-10-25 04:45:08 +11:00
icon = < Icon id = 'reply' icon = { ReplyIcon } / > ;
2022-10-07 19:14:31 +11:00
title = < FormattedMessage id = 'interaction_modal.title.reply' defaultMessage = "Reply to {name}'s post" values = { { name } } / > ;
actionDescription = < FormattedMessage id = 'interaction_modal.description.reply' defaultMessage = 'With an account on Mastodon, you can respond to this post.' / > ;
break ;
case 'reblog' :
2023-10-25 04:45:08 +11:00
icon = < Icon id = 'retweet' icon = { RepeatIcon } / > ;
2022-10-07 19:14:31 +11:00
title = < FormattedMessage id = 'interaction_modal.title.reblog' defaultMessage = "Boost {name}'s post" values = { { name } } / > ;
actionDescription = < FormattedMessage id = 'interaction_modal.description.reblog' defaultMessage = 'With an account on Mastodon, you can boost this post to share it with your own followers.' / > ;
break ;
case 'favourite' :
2023-10-25 04:45:08 +11:00
icon = < Icon id = 'star' icon = { StarIcon } / > ;
2023-07-22 03:09:13 +10:00
title = < FormattedMessage id = 'interaction_modal.title.favourite' defaultMessage = "Favorite {name}'s post" values = { { name } } / > ;
actionDescription = < FormattedMessage id = 'interaction_modal.description.favourite' defaultMessage = 'With an account on Mastodon, you can favorite this post to let the author know you appreciate it and save it for later.' / > ;
2022-10-07 19:14:31 +11:00
break ;
case 'follow' :
2023-10-25 04:45:08 +11:00
icon = < Icon id = 'user-plus' icon = { PersonAddIcon } / > ;
2022-10-07 19:14:31 +11:00
title = < FormattedMessage id = 'interaction_modal.title.follow' defaultMessage = 'Follow {name}' values = { { name } } / > ;
actionDescription = < FormattedMessage id = 'interaction_modal.description.follow' defaultMessage = 'With an account on Mastodon, you can follow {name} to receive their posts in your home feed.' values = { { name } } / > ;
break ;
}
2022-10-27 04:35:55 +11:00
let signupButton ;
2023-08-04 00:43:15 +10:00
if ( sso _redirect ) {
2023-08-08 01:58:29 +10:00
signupButton = (
< a href = { sso _redirect } data - method = 'post' className = 'link-button' >
< FormattedMessage id = 'sign_in_banner.create_account' defaultMessage = 'Create account' / >
2022-10-27 04:35:55 +11:00
< / a >
2023-08-08 01:58:29 +10:00
) ;
} else if ( registrationsOpen ) {
signupButton = (
2023-08-14 20:04:04 +10:00
< a href = { signupUrl } className = 'link-button' >
2023-08-08 01:58:29 +10:00
< FormattedMessage id = 'sign_in_banner.create_account' defaultMessage = 'Create account' / >
< / a >
) ;
2022-10-27 04:35:55 +11:00
} else {
2023-08-08 01:58:29 +10:00
signupButton = (
< button className = 'link-button' onClick = { this . handleSignupClick } >
< FormattedMessage id = 'sign_in_banner.create_account' defaultMessage = 'Create account' / >
< / button >
2022-10-27 04:35:55 +11:00
) ;
}
2022-10-07 19:14:31 +11:00
return (
< div className = 'modal-root__modal interaction-modal' >
< div className = 'interaction-modal__lead' >
< h3 > < span className = 'interaction-modal__icon' > { icon } < / span > { title } < / h3 >
2023-07-28 00:11:17 +10:00
< p > { actionDescription } < strong > < FormattedMessage id = 'interaction_modal.sign_in' defaultMessage = 'You are not logged in to this server. Where is your account hosted?' / > < / strong > < / p >
2022-10-07 19:14:31 +11:00
< / div >
2023-07-28 00:11:17 +10:00
< IntlLoginForm resourceUrl = { url } / >
2022-10-07 19:14:31 +11:00
2023-07-28 00:11:17 +10:00
< p className = 'hint' > < FormattedMessage id = 'interaction_modal.sign_in_hint' defaultMessage = "Tip: That's the website where you signed up. If you don't remember, look for the welcome e-mail in your inbox. You can also enter your full username! (e.g. @Mastodon@mastodon.social)" / > < / p >
< p > < FormattedMessage id = 'interaction_modal.no_account_yet' defaultMessage = 'Not on Mastodon?' / > { signupButton } < / p >
2022-10-07 19:14:31 +11:00
< / div >
) ;
}
}
2023-03-24 13:17:53 +11:00
export default connect ( mapStateToProps , mapDispatchToProps ) ( InteractionModal ) ;