2022-12-21 13:02:13 +03:00
import debounce from 'just-debounce-it' ;
2022-12-10 12:14:48 +03:00
import { Link } from 'preact-router/match' ;
import {
useEffect ,
useLayoutEffect ,
useMemo ,
useRef ,
useState ,
} from 'preact/hooks' ;
import { useSnapshot } from 'valtio' ;
import Icon from '../components/icon' ;
import Loader from '../components/loader' ;
import Status from '../components/status' ;
2022-12-22 19:30:55 +03:00
import htmlContentLength from '../utils/html-content-length' ;
2022-12-19 12:38:20 +03:00
import shortenNumber from '../utils/shorten-number' ;
2022-12-10 12:14:48 +03:00
import states from '../utils/states' ;
2022-12-20 20:02:48 +03:00
import store from '../utils/store' ;
2022-12-10 12:14:48 +03:00
import useTitle from '../utils/useTitle' ;
2022-12-22 19:30:55 +03:00
const LIMIT = 40 ;
2022-12-16 08:27:04 +03:00
function StatusPage ( { id } ) {
2022-12-10 12:14:48 +03:00
const snapStates = useSnapshot ( states ) ;
2022-12-21 13:02:13 +03:00
const [ statuses , setStatuses ] = useState ( [ ] ) ;
2022-12-10 12:14:48 +03:00
const [ uiState , setUIState ] = useState ( 'default' ) ;
2022-12-21 13:02:13 +03:00
const userInitiated = useRef ( true ) ; // Initial open is user-initiated
2022-12-10 12:14:48 +03:00
const heroStatusRef = useRef ( ) ;
2022-12-21 13:02:13 +03:00
const scrollableRef = useRef ( ) ;
2022-12-20 20:02:48 +03:00
useEffect ( ( ) => {
2022-12-21 13:02:13 +03:00
const onScroll = debounce ( ( ) => {
// console.log('onScroll');
const { scrollTop } = scrollableRef . current ;
states . scrollPositions . set ( id , scrollTop ) ;
} , 100 ) ;
scrollableRef . current . addEventListener ( 'scroll' , onScroll , {
passive : true ,
} ) ;
onScroll ( ) ;
return ( ) => {
scrollableRef . current ? . removeEventListener ( 'scroll' , onScroll ) ;
} ;
} , [ id ] ) ;
useEffect ( ( ) => {
setUIState ( 'loading' ) ;
2022-12-18 19:19:19 +03:00
const containsStatus = statuses . find ( ( s ) => s . id === id ) ;
2022-12-20 10:32:31 +03:00
if ( ! containsStatus ) {
2022-12-21 13:02:13 +03:00
// Case 1: On first load, or when navigating to a status that's not cached at all
2022-12-10 12:14:48 +03:00
setStatuses ( [ { id } ] ) ;
2022-12-20 20:02:48 +03:00
} else {
const cachedStatuses = store . session . getJSON ( 'statuses-' + id ) ;
if ( cachedStatuses ) {
2022-12-21 13:02:13 +03:00
// Case 2: Looks like we've cached this status before, let's restore them to make it snappy
const reallyCachedStatuses = cachedStatuses . filter (
( s ) => snapStates . statuses . has ( s . id ) ,
// Some are not cached in the global state, so we need to filter them out
) ;
setStatuses ( reallyCachedStatuses ) ;
} else {
// Case 3: Unknown state, could be a sub-comment. Let's slice off all descendant statuses after the hero status to be safe because they are custom-rendered with sub-comments etc
const heroIndex = statuses . findIndex ( ( s ) => s . id === id ) ;
const slicedStatuses = statuses . slice ( 0 , heroIndex + 1 ) ;
setStatuses ( slicedStatuses ) ;
2022-12-20 20:02:48 +03:00
}
2022-12-10 12:14:48 +03:00
}
2022-12-21 13:02:13 +03:00
( async ( ) => {
const hasStatus = snapStates . statuses . has ( id ) ;
let heroStatus = snapStates . statuses . get ( id ) ;
try {
heroStatus = await masto . statuses . fetch ( id ) ;
states . statuses . set ( id , heroStatus ) ;
} catch ( e ) {
// Silent fail if status is cached
if ( ! hasStatus ) {
setUIState ( 'error' ) ;
alert ( 'Error fetching status' ) ;
}
return ;
2022-12-10 12:14:48 +03:00
}
2022-12-21 13:02:13 +03:00
try {
const context = await masto . statuses . fetchContext ( id ) ;
const { ancestors , descendants } = context ;
2022-12-10 12:14:48 +03:00
2022-12-21 13:02:13 +03:00
ancestors . forEach ( ( status ) => {
states . statuses . set ( status . id , status ) ;
} ) ;
const nestedDescendants = [ ] ;
descendants . forEach ( ( status ) => {
states . statuses . set ( status . id , status ) ;
if ( status . inReplyToAccountId === status . account . id ) {
// If replying to self, it's part of the thread, level 1
nestedDescendants . push ( status ) ;
} else if ( status . inReplyToId === heroStatus . id ) {
// If replying to the hero status, it's a reply, level 1
nestedDescendants . push ( status ) ;
2022-12-18 15:46:13 +03:00
} else {
2022-12-21 13:02:13 +03:00
// If replying to someone else, it's a reply to a reply, level 2
const parent = descendants . find ( ( s ) => s . id === status . inReplyToId ) ;
if ( parent ) {
if ( ! parent . _ _replies ) {
parent . _ _replies = [ ] ;
}
parent . _ _replies . push ( status ) ;
} else {
// If no parent, it's probably a reply to a reply to a reply, level 3
console . warn ( '[LEVEL 3] No parent found for' , status ) ;
}
2022-12-18 15:46:13 +03:00
}
2022-12-21 13:02:13 +03:00
} ) ;
2022-12-18 15:46:13 +03:00
2022-12-21 13:02:13 +03:00
console . log ( { ancestors , descendants , nestedDescendants } ) ;
2022-12-10 12:14:48 +03:00
2022-12-21 13:02:13 +03:00
const allStatuses = [
... ancestors . map ( ( s ) => ( {
id : s . id ,
ancestor : true ,
accountID : s . account . id ,
} ) ) ,
{ id , accountID : heroStatus . account . id } ,
... nestedDescendants . map ( ( s ) => ( {
id : s . id ,
accountID : s . account . id ,
descendant : true ,
thread : s . account . id === heroStatus . account . id ,
replies : s . _ _replies ? . map ( ( r ) => r . id ) ,
} ) ) ,
] ;
2022-12-10 12:14:48 +03:00
2022-12-21 13:02:13 +03:00
setUIState ( 'default' ) ;
console . log ( { allStatuses } ) ;
setStatuses ( allStatuses ) ;
store . session . setJSON ( 'statuses-' + id , allStatuses ) ;
} catch ( e ) {
console . error ( e ) ;
setUIState ( 'error' ) ;
}
} ) ( ) ;
2022-12-10 12:14:48 +03:00
} , [ id , snapStates . reloadStatusPage ] ) ;
useLayoutEffect ( ( ) => {
2022-12-21 13:02:13 +03:00
if ( ! statuses . length ) return ;
const isLoading = uiState === 'loading' ;
if ( userInitiated . current ) {
const hasAncestors = statuses . findIndex ( ( s ) => s . id === id ) > 0 ; // Cannot use `ancestor` key because the hero state is dynamic
if ( ! isLoading && hasAncestors ) {
// Case 1: User initiated, has ancestors, after statuses are loaded, SNAP to hero status
console . log ( 'Case 1' ) ;
heroStatusRef . current ? . scrollIntoView ( ) ;
} else if ( isLoading && statuses . length > 1 ) {
// Case 2: User initiated, while statuses are loading, SMOOTH-SCROLL to hero status
console . log ( 'Case 2' ) ;
heroStatusRef . current ? . scrollIntoView ( {
behavior : 'smooth' ,
block : 'start' ,
} ) ;
}
} else {
const scrollPosition = states . scrollPositions . get ( id ) ;
if ( scrollPosition && scrollableRef . current ) {
// Case 3: Not user initiated (e.g. back/forward button), restore to saved scroll position
console . log ( 'Case 3' ) ;
scrollableRef . current . scrollTop = scrollPosition ;
}
2022-12-10 12:14:48 +03:00
}
2022-12-21 13:02:13 +03:00
console . log ( 'No case' , {
isLoading ,
userInitiated : userInitiated . current ,
statusesLength : statuses . length ,
// scrollPosition,
} ) ;
2022-12-10 12:14:48 +03:00
2022-12-21 13:02:13 +03:00
if ( ! isLoading ) {
// Reset user initiated flag after statuses are loaded
userInitiated . current = false ;
2022-12-18 08:43:34 +03:00
}
2022-12-21 13:02:13 +03:00
} , [ statuses , uiState ] ) ;
2022-12-10 12:14:48 +03:00
2022-12-18 15:46:13 +03:00
const heroStatus = snapStates . statuses . get ( id ) ;
2022-12-10 12:14:48 +03:00
const heroDisplayName = useMemo ( ( ) => {
// Remove shortcodes from display name
if ( ! heroStatus ) return '' ;
const { account } = heroStatus ;
const div = document . createElement ( 'div' ) ;
div . innerHTML = account . displayName ;
return div . innerText . trim ( ) ;
} , [ heroStatus ] ) ;
const heroContentText = useMemo ( ( ) => {
if ( ! heroStatus ) return '' ;
2022-12-15 12:14:33 +03:00
const { spoilerText , content } = heroStatus ;
let text ;
if ( spoilerText ) {
text = spoilerText ;
} else {
const div = document . createElement ( 'div' ) ;
div . innerHTML = content ;
text = div . innerText . trim ( ) ;
}
2022-12-10 12:14:48 +03:00
if ( text . length > 64 ) {
2022-12-15 12:14:33 +03:00
// "The title should ideally be less than 64 characters in length"
// https://www.w3.org/Provider/Style/TITLE.html
2022-12-10 12:14:48 +03:00
text = text . slice ( 0 , 64 ) + '…' ;
}
return text ;
} , [ heroStatus ] ) ;
useTitle (
heroDisplayName && heroContentText
2022-12-15 12:14:33 +03:00
? ` ${ heroDisplayName } : " ${ heroContentText } " `
2022-12-10 12:14:48 +03:00
: 'Status' ,
) ;
const prevRoute = states . history . findLast ( ( h ) => {
return h === '/' || /notifications/i . test ( h ) ;
} ) ;
const closeLink = ` # ${ prevRoute || '/' } ` ;
2022-12-22 19:30:55 +03:00
const [ limit , setLimit ] = useState ( LIMIT ) ;
2022-12-18 15:46:13 +03:00
const showMore = useMemo ( ( ) => {
// return number of statuses to show
return statuses . length - limit ;
} , [ statuses . length , limit ] ) ;
2022-12-22 19:30:55 +03:00
const hasManyStatuses = statuses . length > LIMIT ;
2022-12-21 13:02:13 +03:00
const hasDescendants = statuses . some ( ( s ) => s . descendant ) ;
2022-12-19 05:05:27 +03:00
2022-12-10 12:14:48 +03:00
return (
< div class = "deck-backdrop" >
< Link href = { closeLink } > < / Link >
< div
2022-12-21 13:02:13 +03:00
ref = { scrollableRef }
2022-12-10 12:14:48 +03:00
class = { ` status-deck deck contained ${
statuses . length > 1 ? 'padded-bottom' : ''
} ` }
>
< header >
2022-12-19 11:25:57 +03:00
{ / * < d i v >
2022-12-18 15:53:32 +03:00
< Link class = "button plain deck-close" href = { closeLink } >
< Icon icon = "chevron-left" size = "xl" / >
< / Link >
2022-12-19 11:25:57 +03:00
< / div > * / }
2022-12-10 12:14:48 +03:00
< h1 > Status < / h1 >
< div class = "header-side" >
< Loader hidden = { uiState !== 'loading' } / >
2022-12-19 11:25:57 +03:00
< Link class = "button plain deck-close" href = { closeLink } >
< Icon icon = "x" size = "xl" / >
< / Link >
2022-12-10 12:14:48 +03:00
< / div >
< / header >
2022-12-20 10:32:31 +03:00
< ul
class = { ` timeline flat contextual ${
uiState === 'loading' ? 'loading' : ''
} ` }
>
2022-12-18 15:46:13 +03:00
{ statuses . slice ( 0 , limit ) . map ( ( status ) => {
const {
id : statusID ,
ancestor ,
descendant ,
thread ,
replies ,
} = status ;
2022-12-10 12:14:48 +03:00
const isHero = statusID === id ;
return (
< li
key = { statusID }
ref = { isHero ? heroStatusRef : null }
2022-12-17 20:14:44 +03:00
class = { ` ${ ancestor ? 'ancestor' : '' } ${
descendant ? 'descendant' : ''
2022-12-20 10:32:31 +03:00
} $ { thread ? 'thread' : '' } $ { isHero ? 'hero' : '' } ` }
2022-12-10 12:14:48 +03:00
>
{ isHero ? (
< Status statusID = { statusID } withinContext size = "l" / >
) : (
< Link
class = "
status - link
"
href = { ` #/s/ ${ statusID } ` }
2022-12-21 13:02:13 +03:00
onClick = { ( ) => {
userInitiated . current = true ;
} }
2022-12-10 12:14:48 +03:00
>
2022-12-18 15:46:13 +03:00
< Status
statusID = { statusID }
withinContext
size = { thread || ancestor ? 'm' : 's' }
/ >
2022-12-22 19:30:55 +03:00
{ replies ? . length > LIMIT && (
< div class = "replies-link" >
< Icon icon = "comment" / > { ' ' }
< span title = { replies . length } >
{ shortenNumber ( replies . length ) }
< / span >
< / div >
) }
2022-12-10 12:14:48 +03:00
< / Link >
) }
2022-12-22 19:30:55 +03:00
{ descendant &&
replies ? . length > 0 &&
replies ? . length <= LIMIT && (
< SubComments
hasManyStatuses = { hasManyStatuses }
replies = { replies }
/ >
) }
2022-12-10 12:14:48 +03:00
{ uiState === 'loading' &&
isHero &&
2022-12-10 16:19:38 +03:00
! ! heroStatus ? . repliesCount &&
2022-12-21 13:02:13 +03:00
! hasDescendants && (
2022-12-10 12:14:48 +03:00
< div class = "status-loading" >
2022-12-10 16:19:38 +03:00
< Loader / >
2022-12-10 12:14:48 +03:00
< / div >
) }
< / li >
) ;
} ) }
2022-12-19 05:04:50 +03:00
{ showMore > 0 && (
< li >
< button
type = "button"
class = "plain block"
disabled = { uiState === 'loading' }
2022-12-22 19:30:55 +03:00
onClick = { ( ) => setLimit ( ( l ) => l + LIMIT ) }
2022-12-19 05:04:50 +03:00
style = { { marginBlockEnd : '6em' } }
>
Show more & hellip ; { ' ' }
2022-12-22 19:30:55 +03:00
< span class = "tag" >
{ showMore > LIMIT ? ` ${ LIMIT } + ` : showMore }
< / span >
2022-12-19 05:04:50 +03:00
< / button >
< / li >
) }
2022-12-10 12:14:48 +03:00
< / ul >
< / div >
< / div >
) ;
2022-12-16 08:27:04 +03:00
}
2022-12-22 19:30:55 +03:00
function SubComments ( { hasManyStatuses , replies } ) {
// If less than or 2 replies and total number of characters of content from replies is less than 500
let isBrief = false ;
if ( replies . length <= 2 ) {
let totalLength = replies . reduce ( ( acc , reply ) => {
const { content } = reply ;
const length = htmlContentLength ( content ) ;
return acc + length ;
} , 0 ) ;
isBrief = totalLength < 500 ;
}
const open = isBrief || ! hasManyStatuses ;
return (
< details class = "replies" open = { open } >
< summary hidden = { open } >
< span title = { replies . length } > { shortenNumber ( replies . length ) } < / span > repl
{ replies . length === 1 ? 'y' : 'ies' }
< / summary >
< ul >
{ replies . map ( ( replyID ) => (
< li key = { replyID } >
< Link
class = "status-link"
href = { ` #/s/ ${ replyID } ` }
onClick = { ( ) => {
userInitiated . current = true ;
} }
>
< Status statusID = { replyID } withinContext size = "s" / >
< / Link >
< / li >
) ) }
< / ul >
< / details >
) ;
}
2022-12-16 08:27:04 +03:00
export default StatusPage ;