1import * as React from 'react' 2import * as Kb from '../../../../../common-adapters' 3import * as Styles from '../../../../../styles' 4import {resolveRootAsURL} from '../../../../../desktop/app/resolve-root.desktop' 5import {urlsToImgSet} from '../../../../../common-adapters/icon.desktop' 6import {Props} from '.' 7import SharedTimer, {SharedTimerID} from '../../../../../util/shared-timers' 8 9const copyChildren = (children: React.ReactNode): React.ReactNode => 10 // @ts-ignore 11 React.Children.map(children, child => (child ? React.cloneElement(child) : child)) 12 13export const animationDuration = 2000 14 15const retainedHeights = new Set<string>() 16 17type State = { 18 animating: boolean 19 children?: React.ReactNode 20 height: number 21} 22 23class ExplodingHeightRetainer extends React.PureComponent<Props, State> { 24 _boxRef = React.createRef<HTMLDivElement>() 25 state = { 26 animating: false, 27 children: this.props.retainHeight ? null : copyChildren(this.props.children), // no children if we already exploded 28 height: 17, 29 } 30 timerID?: SharedTimerID 31 32 static getDerivedStateFromProps(nextProps: Props, _: State) { 33 return nextProps.retainHeight ? null : {children: copyChildren(nextProps.children)} 34 } 35 36 componentDidMount() { 37 // remeasure if we are already exploded 38 if (this.props.retainHeight && retainedHeights.has(this.props.messageKey) && this.props.measure) { 39 retainedHeights.delete(this.props.messageKey) 40 this.props.measure() 41 } 42 } 43 44 componentDidUpdate(prevProps: Props) { 45 if (this.props.retainHeight) { 46 if (!prevProps.retainHeight) { 47 // destroy local copy of children when animation finishes 48 this.setState({animating: true}, () => { 49 this.timerID && SharedTimer.removeObserver(this.props.messageKey, this.timerID) 50 this.timerID = SharedTimer.addObserver(() => this.setState({animating: false, children: null}), { 51 key: this.props.messageKey, 52 ms: animationDuration, 53 }) 54 }) 55 } 56 return 57 } 58 59 this.setHeight() 60 } 61 62 setHeight() { 63 const node = this._boxRef.current 64 if (node instanceof HTMLElement) { 65 const height = node.clientHeight 66 if (height && height !== this.state.height) { 67 retainedHeights.add(this.props.messageKey) 68 this.setState({height}) 69 } 70 } 71 } 72 73 componentWillUnmount() { 74 this.timerID && SharedTimer.removeObserver(this.props.messageKey, this.timerID) 75 } 76 77 render() { 78 return ( 79 <Kb.Box 80 style={Styles.collapseStyles([ 81 styles.container, 82 this.props.style, 83 // paddingRight is to compensate for the message menu 84 // to make sure we don't rewrap text when showing the animation 85 this.props.retainHeight && { 86 height: this.state.height, 87 paddingRight: 28, 88 position: 'relative', 89 }, 90 ])} 91 forwardedRef={this._boxRef} 92 > 93 {this.state.children} 94 <Ashes 95 doneExploding={!this.state.animating} 96 exploded={this.props.retainHeight} 97 explodedBy={this.props.explodedBy} 98 height={this.state.height} 99 /> 100 </Kb.Box> 101 ) 102 } 103} 104 105const Ashes = (props: {doneExploding: boolean; exploded: boolean; explodedBy?: string; height: number}) => { 106 let explodedTag: React.ReactNode = null 107 if (props.doneExploding) { 108 explodedTag = props.explodedBy ? ( 109 <Kb.Text type="BodyTiny" style={styles.exploded}> 110 EXPLODED BY{' '} 111 <Kb.ConnectedUsernames 112 type="BodySmallBold" 113 onUsernameClicked="profile" 114 usernames={props.explodedBy} 115 inline={true} 116 colorFollowing={true} 117 colorYou={true} 118 underline={true} 119 /> 120 </Kb.Text> 121 ) : ( 122 <Kb.Text type="BodyTiny" style={styles.exploded}> 123 EXPLODED 124 </Kb.Text> 125 ) 126 } 127 return ( 128 <AshBox className={Styles.classNames({'full-width': props.exploded})}> 129 {props.exploded && explodedTag} 130 <FlameFront height={props.height} stop={props.doneExploding} /> 131 </AshBox> 132 ) 133} 134 135const FlameFront = (props: {height: number; stop: boolean}) => { 136 if (props.stop) { 137 return null 138 } 139 const numBoxes = Math.max(Math.ceil(props.height / 17) - 1, 1) 140 const children: Array<React.ReactNode> = [] 141 for (let i = 0; i < numBoxes; i++) { 142 children.push( 143 <Kb.Box style={styles.flame}> 144 <Kb.Animation 145 animationType={Styles.isDarkMode() ? 'darkExploding' : 'exploding'} 146 width={64} 147 height={64} 148 /> 149 </Kb.Box> 150 ) 151 } 152 return ( 153 <Kb.Box className="flame-container" style={styles.flameContainer}> 154 {children} 155 </Kb.Box> 156 ) 157} 158 159const explodedIllustrationUrl = (): string => 160 Styles.isDarkMode() 161 ? urlsToImgSet({'68': resolveRootAsURL('../images/icons/dark-pattern-ashes-desktop-400-68.png')}, 68) 162 : urlsToImgSet({'68': resolveRootAsURL('../images/icons/pattern-ashes-desktop-400-68.png')}, 68) 163 164const styles = Styles.styleSheetCreate( 165 () => 166 ({ 167 ashBox: Styles.platformStyles({ 168 isElectron: { 169 backgroundColor: Styles.globalColors.white, // exploded messages don't have hover effects and we need to cover the message 170 backgroundImage: explodedIllustrationUrl(), 171 backgroundRepeat: 'repeat', 172 backgroundSize: '400px 68px', 173 bottom: 0, 174 left: 0, 175 overflow: 'hidden', 176 position: 'absolute', 177 top: 0, 178 transition: `width 0s`, 179 width: 0, 180 }, 181 }), 182 container: {...Styles.globalStyles.flexBoxColumn, flex: 1}, 183 exploded: Styles.platformStyles({ 184 isElectron: { 185 backgroundColor: Styles.globalColors.white, 186 bottom: 0, 187 color: Styles.globalColors.black_20_on_white, 188 padding: 2, 189 paddingLeft: Styles.globalMargins.tiny, 190 paddingTop: 0, 191 position: 'absolute', 192 right: 0, 193 whiteSpace: 'nowrap', 194 }, 195 }), 196 flame: { 197 height: 17, 198 }, 199 flameContainer: { 200 position: 'absolute', 201 right: -32, 202 top: -22, 203 width: 64, 204 }, 205 } as const) 206) 207 208const AshBox = Styles.styled.div( 209 { 210 '&.full-width': { 211 overflow: 'visible', 212 transition: `width ${animationDuration}ms linear`, 213 width: '100%', 214 }, 215 }, 216 () => styles.ashBox 217) 218 219export default ExplodingHeightRetainer 220