2017-05-22 11:34:27 +00:00
/ *
2017-06-28 11:23:33 +00:00
Copyright 2017 Vector Creations Ltd
2017-05-22 11:34:27 +00:00
Licensed under the Apache License , Version 2.0 ( the "License" ) ;
you may not use this file except in compliance with the License .
You may obtain a copy of the License at
http : //www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing , software
distributed under the License is distributed on an "AS IS" BASIS ,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND , either express or implied .
See the License for the specific language governing permissions and
limitations under the License .
* /
'use strict' ;
2017-07-12 13:16:47 +00:00
import url from 'url' ;
2017-11-10 11:42:56 +00:00
import qs from 'querystring' ;
2017-06-28 14:53:18 +00:00
import React from 'react' ;
import MatrixClientPeg from '../../../MatrixClientPeg' ;
2017-09-04 07:31:25 +00:00
import PlatformPeg from '../../../PlatformPeg' ;
2017-07-06 08:28:48 +00:00
import ScalarAuthClient from '../../../ScalarAuthClient' ;
2017-11-29 22:16:22 +00:00
import WidgetMessaging from '../../../WidgetMessaging' ;
2017-11-15 13:24:38 +00:00
import TintableSvgButton from './TintableSvgButton' ;
2017-07-06 08:28:48 +00:00
import SdkConfig from '../../../SdkConfig' ;
2017-07-14 10:17:59 +00:00
import Modal from '../../../Modal' ;
2017-09-22 19:43:27 +00:00
import { _t , _td } from '../../../languageHandler' ;
2017-07-14 10:17:59 +00:00
import sdk from '../../../index' ;
2017-07-26 10:28:43 +00:00
import AppPermission from './AppPermission' ;
2017-08-01 16:29:29 +00:00
import AppWarning from './AppWarning' ;
2017-07-27 15:41:52 +00:00
import MessageSpinner from './MessageSpinner' ;
2017-07-27 22:38:26 +00:00
import WidgetUtils from '../../../WidgetUtils' ;
2017-08-16 17:19:12 +00:00
import dis from '../../../dispatcher' ;
2017-05-22 11:34:27 +00:00
2017-07-12 13:16:47 +00:00
const ALLOWED _APP _URL _SCHEMES = [ 'https:' , 'http:' ] ;
2017-07-06 08:28:48 +00:00
2017-05-22 11:34:27 +00:00
export default React . createClass ( {
displayName : 'AppTile' ,
propTypes : {
id : React . PropTypes . string . isRequired ,
url : React . PropTypes . string . isRequired ,
name : React . PropTypes . string . isRequired ,
2017-06-13 13:33:17 +00:00
room : React . PropTypes . object . isRequired ,
2017-07-14 10:17:59 +00:00
type : React . PropTypes . string . isRequired ,
2017-07-28 15:46:21 +00:00
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
2017-07-27 15:41:52 +00:00
fullWidth : React . PropTypes . bool ,
2017-08-18 10:41:27 +00:00
// UserId of the current user
userId : React . PropTypes . string . isRequired ,
// UserId of the entity that added / modified the widget
creatorUserId : React . PropTypes . string ,
2017-05-22 11:34:27 +00:00
} ,
2017-10-27 16:49:14 +00:00
getDefaultProps ( ) {
2017-05-22 11:34:27 +00:00
return {
url : "" ,
} ;
} ,
2017-10-31 16:31:46 +00:00
/ * *
2017-11-10 11:50:14 +00:00
* Set initial component state when the App wUrl ( widget URL ) is being updated .
* Component props * must * be passed ( rather than relying on this . props ) .
2017-11-08 17:44:54 +00:00
* @ param { Object } newProps The new properties of the component
2017-10-31 16:31:46 +00:00
* @ return { Object } Updated component state to be set with setState
* /
2017-11-10 10:17:55 +00:00
_getNewState ( newProps ) {
2017-11-08 17:44:54 +00:00
const widgetPermissionId = [ newProps . room . roomId , encodeURIComponent ( newProps . url ) ] . join ( '_' ) ;
2017-11-02 18:33:11 +00:00
const hasPermissionToLoad = localStorage . getItem ( widgetPermissionId ) ;
return {
initialising : true , // True while we are mangling the widget URL
loading : true , // True while the iframe content is loading
2017-11-29 22:16:22 +00:00
widgetUrl : this . _addWurlParams ( newProps . url ) ,
2017-11-02 18:33:11 +00:00
widgetPermissionId : widgetPermissionId ,
2017-11-10 11:50:14 +00:00
// Assume that widget has permission to load if we are the user who
// added it to the room, or if explicitly granted by the user
2017-11-08 17:44:54 +00:00
hasPermissionToLoad : hasPermissionToLoad === 'true' || newProps . userId === newProps . creatorUserId ,
2017-11-02 18:33:11 +00:00
error : null ,
deleting : false ,
2017-11-29 22:16:22 +00:00
widgetPageTitle : null ,
2017-11-02 18:33:11 +00:00
} ;
2017-10-27 16:49:14 +00:00
} ,
2017-11-29 22:16:22 +00:00
/ * *
* Add widget instance specific parameters to pass in wUrl
* Properties passed to widget instance :
* - widgetId
* - origin / parent URL
* @ param { string } urlString Url string to modify
* @ return { string }
* Url string with parameters appended .
* If url can not be parsed , it is returned unmodified .
* /
_addWurlParams ( urlString ) {
const u = url . parse ( urlString ) ;
if ( ! u ) {
console . error ( "_addWurlParams" , "Invalid URL" , urlString ) ;
return url ;
}
const params = qs . parse ( u . query ) ;
// Append widget ID to query parameters
params . widgetId = this . props . id ;
// Append current / parent URL
params . parentUrl = window . location . href ;
u . search = undefined ;
u . query = params ;
return u . format ( ) ;
} ,
2017-10-27 16:49:14 +00:00
getInitialState ( ) {
2017-11-10 10:17:55 +00:00
return this . _getNewState ( this . props ) ;
2017-07-06 08:28:48 +00:00
} ,
2017-11-02 17:27:59 +00:00
/ * *
* Returns true if specified url is a scalar URL , typically https : //scalar.vector.im/api
* @ param { [ type ] } url URL to check
* @ return { Boolean } True if specified URL is a scalar URL
* /
isScalarUrl ( url ) {
if ( ! url ) {
2017-11-09 14:07:29 +00:00
console . error ( 'Scalar URL check failed. No URL specified' ) ;
2017-11-02 17:27:59 +00:00
return false ;
}
2017-08-30 09:36:22 +00:00
let scalarUrls = SdkConfig . get ( ) . integrations _widgets _urls ;
if ( ! scalarUrls || scalarUrls . length == 0 ) {
scalarUrls = [ SdkConfig . get ( ) . integrations _rest _url ] ;
}
for ( let i = 0 ; i < scalarUrls . length ; i ++ ) {
2017-11-02 17:27:59 +00:00
if ( url . startsWith ( scalarUrls [ i ] ) ) {
2017-08-30 09:36:22 +00:00
return true ;
}
}
return false ;
2017-07-06 08:28:48 +00:00
} ,
2017-10-27 16:49:14 +00:00
isMixedContent ( ) {
2017-08-01 16:29:29 +00:00
const parentContentProtocol = window . location . protocol ;
const u = url . parse ( this . props . url ) ;
const childContentProtocol = u . protocol ;
2017-08-01 16:49:41 +00:00
if ( parentContentProtocol === 'https:' && childContentProtocol !== 'https:' ) {
2017-08-02 16:05:46 +00:00
console . warn ( "Refusing to load mixed-content app:" ,
parentContentProtocol , childContentProtocol , window . location , this . props . url ) ;
2017-08-01 16:29:29 +00:00
return true ;
}
return false ;
} ,
2017-10-27 16:49:14 +00:00
componentWillMount ( ) {
2017-11-30 10:20:29 +00:00
WidgetMessaging . startListening ( ) ;
2017-10-27 12:47:51 +00:00
window . addEventListener ( 'message' , this . _onMessage , false ) ;
this . setScalarToken ( ) ;
} ,
2017-11-02 18:33:11 +00:00
/ * *
* Adds a scalar token to the widget URL , if required
* Component initialisation is only complete when this function has resolved
* /
2017-10-27 12:47:51 +00:00
setScalarToken ( ) {
2017-11-10 09:44:58 +00:00
this . setState ( { initialising : true } ) ;
2017-11-02 17:27:59 +00:00
if ( ! this . isScalarUrl ( this . props . url ) ) {
2017-11-09 14:07:29 +00:00
console . warn ( 'Non-scalar widget, not setting scalar token!' , url ) ;
2017-11-02 18:33:11 +00:00
this . setState ( {
error : null ,
2017-11-29 22:16:22 +00:00
widgetUrl : this . _addWurlParams ( this . props . url ) ,
2017-11-02 18:33:11 +00:00
initialising : false ,
} ) ;
2017-07-06 08:28:48 +00:00
return ;
}
2017-11-02 18:33:11 +00:00
// Fetch the token before loading the iframe as we need it to mangle the URL
2017-10-31 10:04:37 +00:00
if ( ! this . _scalarClient ) {
2017-10-27 12:47:51 +00:00
this . _scalarClient = new ScalarAuthClient ( ) ;
}
2017-07-06 08:28:48 +00:00
this . _scalarClient . getScalarToken ( ) . done ( ( token ) => {
2017-10-27 16:49:14 +00:00
// Append scalar_token as a query param if not already present
2017-08-01 14:53:42 +00:00
this . _scalarClient . scalarToken = token ;
2017-11-29 22:16:22 +00:00
const u = url . parse ( this . _addWurlParams ( this . props . url ) ) ;
2017-11-07 12:33:38 +00:00
const params = qs . parse ( u . query ) ;
2017-10-31 10:37:40 +00:00
if ( ! params . scalar _token ) {
params . scalar _token = encodeURIComponent ( token ) ;
2017-11-09 14:07:29 +00:00
// u.search must be set to undefined, so that u.format() uses query paramerters - https://nodejs.org/docs/latest/api/url.html#url_url_format_url_options
u . search = undefined ;
u . query = params ;
2017-07-06 08:28:48 +00:00
}
this . setState ( {
error : null ,
widgetUrl : u . format ( ) ,
2017-11-02 18:33:11 +00:00
initialising : false ,
2017-07-06 08:28:48 +00:00
} ) ;
} , ( err ) => {
2017-11-09 14:07:29 +00:00
console . error ( "Failed to get scalar_token" , err ) ;
2017-07-06 08:28:48 +00:00
this . setState ( {
error : err . message ,
2017-11-02 18:33:11 +00:00
initialising : false ,
2017-07-06 08:28:48 +00:00
} ) ;
} ) ;
2017-09-04 07:31:25 +00:00
} ,
componentWillUnmount ( ) {
2017-11-30 10:20:29 +00:00
WidgetMessaging . stopListening ( ) ;
2017-09-04 07:31:25 +00:00
window . removeEventListener ( 'message' , this . _onMessage ) ;
} ,
2017-10-31 16:31:46 +00:00
componentWillReceiveProps ( nextProps ) {
if ( nextProps . url !== this . props . url ) {
2017-11-10 10:17:55 +00:00
this . _getNewState ( nextProps ) ;
2017-11-02 18:33:11 +00:00
this . setScalarToken ( ) ;
2017-11-09 14:28:24 +00:00
} else if ( nextProps . show && ! this . props . show ) {
this . setState ( {
loading : true ,
} ) ;
2017-10-27 16:49:14 +00:00
}
2017-10-27 12:47:51 +00:00
} ,
2017-09-04 07:31:25 +00:00
_onMessage ( event ) {
2017-09-25 15:13:18 +00:00
if ( this . props . type !== 'jitsi' ) {
2017-09-04 07:31:25 +00:00
return ;
}
if ( ! event . origin ) {
event . origin = event . originalEvent . origin ;
}
2017-09-25 15:14:25 +00:00
if ( ! this . state . widgetUrl . startsWith ( event . origin ) ) {
2017-09-04 07:31:25 +00:00
return ;
}
if ( event . data . widgetAction === 'jitsi_iframe_loaded' ) {
const iframe = this . refs . appFrame . contentWindow
. document . querySelector ( 'iframe[id^="jitsiConferenceFrame"]' ) ;
PlatformPeg . get ( ) . setupScreenSharingForIframe ( iframe ) ;
}
2017-07-06 08:28:48 +00:00
} ,
2017-10-27 16:49:14 +00:00
_canUserModify ( ) {
2017-08-01 10:39:17 +00:00
return WidgetUtils . canUserModifyWidgets ( this . props . room . roomId ) ;
2017-08-01 10:42:50 +00:00
} ,
2017-07-27 17:10:28 +00:00
2017-10-27 16:49:14 +00:00
_onEditClick ( e ) {
2017-07-14 10:17:59 +00:00
console . log ( "Edit widget ID " , this . props . id ) ;
const IntegrationsManager = sdk . getComponent ( "views.settings.IntegrationsManager" ) ;
2017-08-22 09:04:57 +00:00
const src = this . _scalarClient . getScalarInterfaceUrlForRoom (
this . props . room . roomId , 'type_' + this . props . type , this . props . id ) ;
2017-07-27 16:19:18 +00:00
Modal . createTrackedDialog ( 'Integrations Manager' , '' , IntegrationsManager , {
2017-07-14 10:17:59 +00:00
src : src ,
} , "mx_IntegrationsManager" ) ;
2017-05-22 17:00:17 +00:00
} ,
2017-11-10 11:50:14 +00:00
/ * I f u s e r h a s p e r m i s s i o n t o m o d i f y w i d g e t s , d e l e t e t h e w i d g e t ,
* otherwise revoke access for the widget to load in the user ' s browser
2017-07-27 17:10:28 +00:00
* /
2017-10-27 16:49:14 +00:00
_onDeleteClick ( ) {
2017-07-27 17:10:28 +00:00
if ( this . _canUserModify ( ) ) {
2017-10-23 16:05:44 +00:00
// Show delete confirmation dialog
const QuestionDialog = sdk . getComponent ( "dialogs.QuestionDialog" ) ;
Modal . createTrackedDialog ( 'Delete Widget' , '' , QuestionDialog , {
title : _t ( "Delete Widget" ) ,
2017-10-25 09:45:17 +00:00
description : _t (
"Deleting a widget removes it for all users in this room." +
" Are you sure you want to delete this widget?" ) ,
2017-10-23 17:42:43 +00:00
button : _t ( "Delete widget" ) ,
2017-10-23 16:05:44 +00:00
onFinished : ( confirmed ) => {
2017-10-23 22:47:37 +00:00
if ( ! confirmed ) {
return ;
2017-10-23 16:05:44 +00:00
}
2017-10-23 22:47:37 +00:00
this . setState ( { deleting : true } ) ;
MatrixClientPeg . get ( ) . sendStateEvent (
this . props . room . roomId ,
'im.vector.modular.widgets' ,
{ } , // empty content
this . props . id ,
) . catch ( ( e ) => {
console . error ( 'Failed to delete widget' , e ) ;
this . setState ( { deleting : false } ) ;
} ) ;
2017-10-23 16:05:44 +00:00
} ,
2017-07-27 17:10:28 +00:00
} ) ;
} else {
console . log ( "Revoke widget permissions - %s" , this . props . id ) ;
this . _revokeWidgetPermission ( ) ;
}
} ,
2017-11-29 22:16:22 +00:00
/ * *
* Called when widget iframe has finished loading
* /
2017-10-27 16:49:14 +00:00
_onLoaded ( ) {
this . setState ( { loading : false } ) ;
2017-11-29 22:16:22 +00:00
} ,
/ * *
2017-11-30 14:50:30 +00:00
* Set remote content title on AppTile
* @ param { string } title Title string to set on the AppTile
2017-11-29 22:16:22 +00:00
* /
2017-11-30 14:50:30 +00:00
_updateWidgetTitle ( title ) {
if ( title ) {
this . setState ( { widgetPageTitle : null } ) ;
2017-11-29 22:16:22 +00:00
}
2017-10-27 16:49:14 +00:00
} ,
2017-07-28 15:48:13 +00:00
// Widget labels to render, depending upon user permissions
// These strings are translated at the point that they are inserted in to the DOM, in the render method
2017-07-27 17:10:28 +00:00
_deleteWidgetLabel ( ) {
if ( this . _canUserModify ( ) ) {
2017-09-22 19:43:27 +00:00
return _td ( 'Delete widget' ) ;
2017-07-27 17:10:28 +00:00
}
2017-09-22 19:43:27 +00:00
return _td ( 'Revoke widget access' ) ;
2017-05-22 17:00:17 +00:00
} ,
2017-07-28 17:21:23 +00:00
/* TODO -- Store permission in account data so that it is persisted across multiple devices */
2017-07-26 10:28:43 +00:00
_grantWidgetPermission ( ) {
console . warn ( 'Granting permission to load widget - ' , this . state . widgetUrl ) ;
localStorage . setItem ( this . state . widgetPermissionId , true ) ;
2017-07-27 15:41:52 +00:00
this . setState ( { hasPermissionToLoad : true } ) ;
2017-07-26 10:28:43 +00:00
} ,
2017-07-27 17:10:28 +00:00
_revokeWidgetPermission ( ) {
console . warn ( 'Revoking permission to load widget - ' , this . state . widgetUrl ) ;
localStorage . removeItem ( this . state . widgetPermissionId ) ;
this . setState ( { hasPermissionToLoad : false } ) ;
2017-05-22 17:00:17 +00:00
} ,
2017-10-27 16:49:14 +00:00
formatAppTileName ( ) {
2017-06-28 09:27:06 +00:00
let appTileName = "No name" ;
2017-11-16 13:19:36 +00:00
if ( this . props . name && this . props . name . trim ( ) ) {
2017-06-28 09:27:06 +00:00
appTileName = this . props . name . trim ( ) ;
}
return appTileName ;
} ,
2017-10-27 16:49:14 +00:00
onClickMenuBar ( ev ) {
2017-08-16 17:19:12 +00:00
ev . preventDefault ( ) ;
// Ignore clicks on menu bar children
if ( ev . target !== this . refs . menu _bar ) {
return ;
}
// Toggle the view state of the apps drawer
dis . dispatch ( {
action : 'appsDrawer' ,
show : ! this . props . show ,
} ) ;
} ,
2017-11-29 22:16:22 +00:00
_getSafeUrl ( ) {
const parsedWidgetUrl = url . parse ( this . state . widgetUrl ) ;
let safeWidgetUrl = '' ;
if ( ALLOWED _APP _URL _SCHEMES . indexOf ( parsedWidgetUrl . protocol ) !== - 1 ) {
safeWidgetUrl = url . format ( parsedWidgetUrl ) ;
}
return safeWidgetUrl ;
} ,
2017-10-27 16:49:14 +00:00
render ( ) {
2017-07-06 08:28:48 +00:00
let appTileBody ;
2017-07-12 23:27:03 +00:00
// Don't render widget if it is in the process of being deleted
if ( this . state . deleting ) {
return < div > < / d i v > ;
}
2017-07-26 10:28:43 +00:00
// Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin
// because that would allow the iframe to prgramatically remove the sandbox attribute, but
// this would only be for content hosted on the same origin as the riot client: anything
// hosted on the same origin as the client will get the same access as if you clicked
// a link to it.
const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox " +
2017-07-28 10:14:04 +00:00
"allow-same-origin allow-scripts allow-presentation" ;
2017-07-26 10:28:43 +00:00
2017-08-18 17:33:56 +00:00
if ( this . props . show ) {
2017-11-08 20:38:31 +00:00
const loadingElement = (
< div className = 'mx_AppTileBody mx_AppLoading' >
< MessageSpinner msg = 'Loading...' / >
< / d i v >
) ;
2017-11-02 18:33:11 +00:00
if ( this . state . initialising ) {
2017-11-08 20:38:31 +00:00
appTileBody = loadingElement ;
2017-08-18 17:33:56 +00:00
} else if ( this . state . hasPermissionToLoad == true ) {
if ( this . isMixedContent ( ) ) {
2017-11-08 20:38:31 +00:00
appTileBody = (
< div className = "mx_AppTileBody" >
< AppWarning errorMsg = "Error - Mixed content" / >
< / d i v >
) ;
2017-08-18 17:33:56 +00:00
} else {
appTileBody = (
2017-11-08 20:38:31 +00:00
< div className = { this . state . loading ? 'mx_AppTileBody mx_AppLoading' : 'mx_AppTileBody' } >
2017-11-08 20:38:54 +00:00
{ this . state . loading && loadingElement }
2017-08-18 17:33:56 +00:00
< iframe
ref = "appFrame"
2017-11-29 22:16:22 +00:00
src = { this . _getSafeUrl ( ) }
2017-08-18 17:33:56 +00:00
allowFullScreen = "true"
sandbox = { sandboxFlags }
2017-10-31 10:43:17 +00:00
onLoad = { this . _onLoaded }
2017-08-18 17:33:56 +00:00
> < / i f r a m e >
< / d i v >
) ;
}
} else {
2017-08-21 08:30:38 +00:00
const isRoomEncrypted = MatrixClientPeg . get ( ) . isRoomEncrypted ( this . props . room . roomId ) ;
2017-08-01 16:48:02 +00:00
appTileBody = (
< div className = "mx_AppTileBody" >
2017-08-18 17:33:56 +00:00
< AppPermission
2017-08-21 08:30:38 +00:00
isRoomEncrypted = { isRoomEncrypted }
2017-08-18 17:33:56 +00:00
url = { this . state . widgetUrl }
onPermissionGranted = { this . _grantWidgetPermission }
/ >
2017-08-01 16:48:02 +00:00
< / d i v >
) ;
2017-07-12 13:16:47 +00:00
}
2017-07-06 08:28:48 +00:00
}
2017-07-14 10:17:59 +00:00
// editing is done in scalar
2017-07-27 19:18:31 +00:00
const showEditButton = Boolean ( this . _scalarClient && this . _canUserModify ( ) ) ;
2017-07-27 17:10:28 +00:00
const deleteWidgetLabel = this . _deleteWidgetLabel ( ) ;
2017-11-15 13:24:38 +00:00
let deleteIcon = 'img/cancel_green.svg' ;
let deleteClasses = 'mx_AppTileMenuBarWidget' ;
2017-11-16 13:19:36 +00:00
if ( this . _canUserModify ( ) ) {
2017-10-23 16:05:44 +00:00
deleteIcon = 'img/icon-delete-pink.svg' ;
2017-07-27 17:10:28 +00:00
deleteClasses += ' mx_AppTileMenuBarWidgetDelete' ;
}
2017-07-14 10:17:59 +00:00
2017-05-22 11:34:27 +00:00
return (
2017-06-13 13:32:40 +00:00
< div className = { this . props . fullWidth ? "mx_AppTileFullWidth" : "mx_AppTile" } id = { this . props . id } >
2017-08-16 17:19:12 +00:00
< div ref = "menu_bar" className = "mx_AppTileMenuBar" onClick = { this . onClickMenuBar } >
2017-11-14 19:53:32 +00:00
< b > { this . formatAppTileName ( ) } < / b >
2017-11-29 22:16:22 +00:00
{ this . state . widgetPageTitle && (
< span > & nbsp ; - & nbsp ; { this . state . widgetPageTitle } < / s p a n >
) }
2017-05-22 11:34:27 +00:00
< span className = "mx_AppTileMenuBarWidgets" >
2017-09-28 10:21:06 +00:00
{ /* Edit widget */ }
2017-11-15 13:24:38 +00:00
{ showEditButton && < TintableSvgButton
2017-11-14 19:53:59 +00:00
src = "img/edit_green.svg"
className = "mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
2017-07-27 19:18:31 +00:00
title = { _t ( 'Edit' ) }
2017-05-22 17:00:17 +00:00
onClick = { this . _onEditClick }
2017-11-15 15:17:21 +00:00
width = "10"
height = "10"
2017-09-28 10:21:06 +00:00
/ > }
2017-05-22 17:00:17 +00:00
2017-09-28 10:21:06 +00:00
{ /* Delete widget */ }
2017-11-15 13:24:38 +00:00
< TintableSvgButton
src = { deleteIcon }
className = { deleteClasses }
title = { _t ( deleteWidgetLabel ) }
onClick = { this . _onDeleteClick }
2017-11-15 15:17:21 +00:00
width = "10"
height = "10"
2017-05-22 17:00:17 +00:00
/ >
2017-05-22 11:34:27 +00:00
< / s p a n >
< / d i v >
2017-09-28 10:21:06 +00:00
{ appTileBody }
2017-05-22 11:34:27 +00:00
< / d i v >
) ;
} ,
} ) ;