summaryrefslogtreecommitdiff
path: root/settings/accounts
diff options
context:
space:
mode:
Diffstat (limited to 'settings/accounts')
-rw-r--r--settings/accounts/accounts.pro27
-rw-r--r--settings/accounts/providers/mastodon.provider32
-rw-r--r--settings/accounts/services/mastodon-microblog.service29
-rw-r--r--settings/accounts/services/mastodon-sharing.service28
-rw-r--r--settings/accounts/ui/MastodonSettingsDisplay.qml92
-rw-r--r--settings/accounts/ui/mastodon-settings.qml66
-rw-r--r--settings/accounts/ui/mastodon-update.qml99
-rw-r--r--settings/accounts/ui/mastodon.qml337
8 files changed, 710 insertions, 0 deletions
diff --git a/settings/accounts/accounts.pro b/settings/accounts/accounts.pro
new file mode 100644
index 0000000..7adc97e
--- /dev/null
+++ b/settings/accounts/accounts.pro
@@ -0,0 +1,27 @@
+TEMPLATE = aux
+
+OTHER_FILES += \
+ $$PWD/providers/mastodon.provider \
+ $$PWD/services/mastodon-microblog.service \
+ $$PWD/services/mastodon-sharing.service \
+ $$PWD/ui/MastodonSettingsDisplay.qml \
+ $$PWD/ui/mastodon.qml \
+ $$PWD/ui/mastodon-settings.qml \
+ $$PWD/ui/mastodon-update.qml
+
+provider.files += $$PWD/providers/mastodon.provider
+provider.path = /usr/share/accounts/providers/
+
+services.files += \
+ $$PWD/services/mastodon-microblog.service \
+ $$PWD/services/mastodon-sharing.service
+services.path = /usr/share/accounts/services/
+
+ui.files += \
+ $$PWD/ui/MastodonSettingsDisplay.qml \
+ $$PWD/ui/mastodon.qml \
+ $$PWD/ui/mastodon-settings.qml \
+ $$PWD/ui/mastodon-update.qml
+ui.path = /usr/share/accounts/ui/
+
+INSTALLS += provider services ui
diff --git a/settings/accounts/providers/mastodon.provider b/settings/accounts/providers/mastodon.provider
new file mode 100644
index 0000000..acd9d91
--- /dev/null
+++ b/settings/accounts/providers/mastodon.provider
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE provider>
+<provider version="1.0" id="mastodon">
+ <name>Mastodon</name>
+ <description>Mastodon social network</description>
+ <icon>image://theme/graphic-service-mastodon</icon>
+
+ <template>
+ <group name="auth">
+ <setting name="method">oauth2</setting>
+ <setting name="mechanism">web_server</setting>
+ <group name="oauth2">
+ <group name="web_server">
+ <setting name="Host">mastodon.social</setting>
+ <setting name="AllowedSchemes" type="as">["https"]</setting>
+ <setting name="AuthPath">oauth/authorize</setting>
+ <setting name="TokenPath">oauth/token</setting>
+ <setting name="ResponseType">code</setting>
+ <setting name="Scope" type="as">["read","write"]</setting>
+ <setting name="RedirectUri">http://ipv4.jolla.com/online/status.html</setting>
+ </group>
+ </group>
+ </group>
+ <group name="api">
+ <setting name="Host">https://mastodon.social</setting>
+ </group>
+ </template>
+
+ <tags>
+ <tag>user-group:account-mastodon</tag>
+ </tags>
+</provider>
diff --git a/settings/accounts/services/mastodon-microblog.service b/settings/accounts/services/mastodon-microblog.service
new file mode 100644
index 0000000..550333e
--- /dev/null
+++ b/settings/accounts/services/mastodon-microblog.service
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<service id="mastodon-microblog">
+ <type>microblogging</type>
+ <name>Posts</name>
+ <icon>image://theme/icon-m-events</icon>
+ <provider>mastodon</provider>
+
+ <template>
+ <setting name="sync_profile_templates" type="as">["mastodon.Posts"]</setting>
+ <group name="auth">
+ <setting name="method">oauth2</setting>
+ <setting name="mechanism">web_server</setting>
+ <group name="oauth2">
+ <group name="web_server">
+ <setting name="Host">mastodon.social</setting>
+ <setting name="AllowedSchemes" type="as">["https"]</setting>
+ <setting name="AuthPath">oauth/authorize</setting>
+ <setting name="TokenPath">oauth/token</setting>
+ <setting name="ResponseType">code</setting>
+ <setting name="Scope" type="as">["read","write"]</setting>
+ <setting name="RedirectUri">http://ipv4.jolla.com/online/status.html</setting>
+ </group>
+ </group>
+ </group>
+ <group name="api">
+ <setting name="Host">https://mastodon.social</setting>
+ </group>
+ </template>
+</service>
diff --git a/settings/accounts/services/mastodon-sharing.service b/settings/accounts/services/mastodon-sharing.service
new file mode 100644
index 0000000..c3ecf37
--- /dev/null
+++ b/settings/accounts/services/mastodon-sharing.service
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<service id="mastodon-sharing">
+ <type>sharing</type>
+ <name>Sharing</name>
+ <icon>image://theme/icon-m-share</icon>
+ <provider>mastodon</provider>
+
+ <template>
+ <group name="auth">
+ <setting name="method">oauth2</setting>
+ <setting name="mechanism">web_server</setting>
+ <group name="oauth2">
+ <group name="web_server">
+ <setting name="Host">mastodon.social</setting>
+ <setting name="AllowedSchemes" type="as">["https"]</setting>
+ <setting name="AuthPath">oauth/authorize</setting>
+ <setting name="TokenPath">oauth/token</setting>
+ <setting name="ResponseType">code</setting>
+ <setting name="Scope" type="as">["read","write"]</setting>
+ <setting name="RedirectUri">http://ipv4.jolla.com/online/status.html</setting>
+ </group>
+ </group>
+ </group>
+ <group name="api">
+ <setting name="Host">https://mastodon.social</setting>
+ </group>
+ </template>
+</service>
diff --git a/settings/accounts/ui/MastodonSettingsDisplay.qml b/settings/accounts/ui/MastodonSettingsDisplay.qml
new file mode 100644
index 0000000..be79e3a
--- /dev/null
+++ b/settings/accounts/ui/MastodonSettingsDisplay.qml
@@ -0,0 +1,92 @@
+import QtQuick 2.0
+import Sailfish.Silica 1.0
+import Sailfish.Accounts 1.0
+import com.jolla.settings.accounts 1.0
+import org.nemomobile.configuration 1.0
+
+StandardAccountSettingsDisplay {
+ id: root
+
+ settingsModified: true
+
+ onAboutToSaveAccount: {
+ settingsLoader.updateAllSyncProfiles()
+
+ if (eventsSyncSwitch.checked !== root.account.configurationValues("")["FeedViewAutoSync"]) {
+ root.account.setConfigurationValue("", "FeedViewAutoSync", eventsSyncSwitch.checked)
+ }
+ }
+
+ StandardAccountSettingsLoader {
+ id: settingsLoader
+ account: root.account
+ accountProvider: root.accountProvider
+ accountManager: root.accountManager
+ autoEnableServices: root.autoEnableAccount
+
+ onSettingsLoaded: {
+ syncServicesRepeater.model = syncServices
+ otherServicesDisplay.serviceModel = otherServices
+
+ var autoSync = root.account.configurationValues("")["FeedViewAutoSync"]
+ var isNewAccount = root.autoEnableAccount
+ eventsSyncSwitch.checked = (isNewAccount || autoSync === true)
+ }
+ }
+
+ Column {
+ id: syncServicesDisplay
+ width: parent.width
+
+ SectionHeader {
+ //: Options for data to be downloaded from a remote server
+ //% "Download"
+ text: qsTrId("settings-accounts-la-download_options")
+ }
+
+ Repeater {
+ id: syncServicesRepeater
+ TextSwitch {
+ checked: model.enabled
+ text: model.displayName
+ visible: text.length > 0
+ onCheckedChanged: {
+ if (checked) {
+ root.account.enableWithService(model.serviceName)
+ } else {
+ root.account.disableWithService(model.serviceName)
+ }
+ }
+ }
+ }
+
+ TextSwitch {
+ id: eventsSyncSwitch
+
+ text: "Sync Mastodon feed automatically"
+ description: "Fetch new posts periodically when browsing Events Mastodon feed."
+
+ onCheckedChanged: {
+ autoSyncConf.value = checked
+ }
+ }
+ }
+
+ ConfigurationValue {
+ id: autoSyncConf
+ key: "/desktop/lipstick-jolla-home/events/auto_sync_feeds/" + root.account.identifier
+ }
+
+ AccountServiceSettingsDisplay {
+ id: otherServicesDisplay
+ enabled: root.accountEnabled
+
+ onUpdateServiceEnabledStatus: {
+ if (enabled) {
+ root.account.enableWithService(serviceName)
+ } else {
+ root.account.disableWithService(serviceName)
+ }
+ }
+ }
+}
diff --git a/settings/accounts/ui/mastodon-settings.qml b/settings/accounts/ui/mastodon-settings.qml
new file mode 100644
index 0000000..ae79ce4
--- /dev/null
+++ b/settings/accounts/ui/mastodon-settings.qml
@@ -0,0 +1,66 @@
+import QtQuick 2.0
+import Sailfish.Silica 1.0
+import Sailfish.Accounts 1.0
+import com.jolla.settings.accounts 1.0
+
+AccountSettingsAgent {
+ id: root
+
+ initialPage: Page {
+ onPageContainerChanged: {
+ if (pageContainer == null && !credentialsUpdater.running) {
+ root.delayDeletion = true
+ settingsDisplay.saveAccount()
+ }
+ }
+
+ Component.onDestruction: {
+ if (status == PageStatus.Active) {
+ settingsDisplay.saveAccount(true)
+ }
+ }
+
+ AccountCredentialsUpdater {
+ id: credentialsUpdater
+ }
+
+ SilicaFlickable {
+ anchors.fill: parent
+ contentHeight: header.height + settingsDisplay.height + Theme.paddingLarge
+
+ StandardAccountSettingsPullDownMenu {
+ visible: settingsDisplay.accountValid
+ allowSync: true
+ onCredentialsUpdateRequested: {
+ credentialsUpdater.replaceWithCredentialsUpdatePage(root.accountId)
+ }
+ onAccountDeletionRequested: {
+ root.accountDeletionRequested()
+ pageStack.pop()
+ }
+ onSyncRequested: {
+ settingsDisplay.saveAccountAndSync()
+ }
+ }
+
+ PageHeader {
+ id: header
+ title: root.accountsHeaderText
+ }
+
+ MastodonSettingsDisplay {
+ id: settingsDisplay
+ anchors.top: header.bottom
+ accountManager: root.accountManager
+ accountProvider: root.accountProvider
+ accountId: root.accountId
+
+ onAccountSaveCompleted: {
+ root.delayDeletion = false
+ }
+ }
+
+ VerticalScrollDecorator {}
+ }
+ }
+}
diff --git a/settings/accounts/ui/mastodon-update.qml b/settings/accounts/ui/mastodon-update.qml
new file mode 100644
index 0000000..3a6c25c
--- /dev/null
+++ b/settings/accounts/ui/mastodon-update.qml
@@ -0,0 +1,99 @@
+import QtQuick 2.0
+import Sailfish.Silica 1.0
+import Sailfish.Accounts 1.0
+import com.jolla.settings.accounts 1.0
+
+AccountCredentialsAgent {
+ id: root
+
+ property bool _started
+
+ readonly property string callbackUri: "http://ipv4.jolla.com/online/status.html"
+
+ function normalizeApiHost(rawHost) {
+ var host = rawHost ? rawHost.trim() : ""
+ if (host.length === 0) {
+ host = "https://mastodon.social"
+ }
+
+ host = host.replace(/^https?:\/\//i, "")
+ var pathSeparator = host.indexOf("/")
+ if (pathSeparator !== -1) {
+ host = host.substring(0, pathSeparator)
+ }
+ host = host.replace(/\/+$/, "")
+
+ if (host.length === 0) {
+ host = "mastodon.social"
+ }
+ return "https://" + host.toLowerCase()
+ }
+
+ function _valueFromServiceConfig(config, key) {
+ return config && config[key] ? config[key].toString() : ""
+ }
+
+ function _startUpdate() {
+ if (_started || initialPage.status !== PageStatus.Active || account.status !== Account.Initialized) {
+ return
+ }
+
+ var config = account.configurationValues("mastodon-microblog")
+ var apiHost = normalizeApiHost(_valueFromServiceConfig(config, "api/Host"))
+ var oauthHost = _valueFromServiceConfig(config, "auth/oauth2/web_server/Host")
+ if (oauthHost.length === 0) {
+ oauthHost = apiHost.replace(/^https?:\/\//i, "")
+ }
+
+ var clientId = _valueFromServiceConfig(config, "auth/oauth2/web_server/ClientId")
+ var clientSecret = _valueFromServiceConfig(config, "auth/oauth2/web_server/ClientSecret")
+ if (clientId.length === 0 || clientSecret.length === 0) {
+ credentialsUpdateError("Missing Mastodon OAuth client credentials")
+ return
+ }
+
+ _started = true
+
+ var sessionData = {
+ "ClientId": clientId,
+ "ClientSecret": clientSecret,
+ "Host": oauthHost,
+ "AuthPath": "oauth/authorize",
+ "TokenPath": "oauth/token",
+ "ResponseType": "code",
+ "Scope": ["read", "write"],
+ "RedirectUri": callbackUri
+ }
+ initialPage.prepareAccountCredentialsUpdate(account, root.accountProvider, "mastodon-microblog", sessionData)
+ }
+
+ Account {
+ id: account
+ identifier: root.accountId
+
+ onStatusChanged: {
+ root._startUpdate()
+ }
+ }
+
+ initialPage: OAuthAccountSetupPage {
+ onStatusChanged: {
+ root._startUpdate()
+ }
+
+ onAccountCredentialsUpdated: {
+ root.credentialsUpdated(root.accountId)
+ root.goToEndDestination()
+ }
+
+ onAccountCredentialsUpdateError: {
+ root.credentialsUpdateError(errorMessage)
+ }
+
+ onPageContainerChanged: {
+ if (pageContainer == null) {
+ cancelSignIn()
+ }
+ }
+ }
+}
diff --git a/settings/accounts/ui/mastodon.qml b/settings/accounts/ui/mastodon.qml
new file mode 100644
index 0000000..a789459
--- /dev/null
+++ b/settings/accounts/ui/mastodon.qml
@@ -0,0 +1,337 @@
+import QtQuick 2.0
+import Sailfish.Silica 1.0
+import Sailfish.Accounts 1.0
+import com.jolla.settings.accounts 1.0
+
+AccountCreationAgent {
+ id: root
+
+ property Item _oauthPage
+ property Item _settingsDialog
+ property QtObject _accountSetup
+
+ property string _pendingApiHost
+ property bool _registering
+
+ readonly property string callbackUri: "http://ipv4.jolla.com/online/status.html"
+
+ function normalizeApiHost(rawHost) {
+ var host = rawHost ? rawHost.trim() : ""
+ if (host.length === 0) {
+ return ""
+ }
+
+ host = host.replace(/^https?:\/\//i, "")
+ var pathSeparator = host.indexOf("/")
+ if (pathSeparator !== -1) {
+ host = host.substring(0, pathSeparator)
+ }
+ host = host.replace(/\/+$/, "")
+
+ if (host.length === 0) {
+ return ""
+ }
+ return "https://" + host.toLowerCase()
+ }
+
+ function oauthHost(apiHost) {
+ return apiHost.replace(/^https?:\/\//i, "")
+ }
+
+ function _displayName(apiHost) {
+ return oauthHost(apiHost)
+ }
+
+ function _showRegistrationError(message, busyPage) {
+ _registering = false
+ accountCreationError(message)
+ if (busyPage) {
+ busyPage.state = "info"
+ busyPage.infoDescription = message
+ busyPage.infoExtraDescription = ""
+ busyPage.infoButtonText = ""
+ }
+ }
+
+ function _showOAuthPage(context) {
+ _registering = false
+ if (_oauthPage != null) {
+ _oauthPage.cancelSignIn()
+ _oauthPage.destroy()
+ }
+ _oauthPage = oAuthComponent.createObject(root, { "context": context })
+ pageStack.replace(_oauthPage)
+ }
+
+ function _registerClientApplication(apiHost, busyPage) {
+ if (_registering) {
+ return
+ }
+ _registering = true
+
+ var xhr = new XMLHttpRequest()
+ xhr.onreadystatechange = function() {
+ if (xhr.readyState !== XMLHttpRequest.DONE) {
+ return
+ }
+
+ if (xhr.status < 200 || xhr.status >= 300) {
+ _showRegistrationError("Failed to register Mastodon app for " + apiHost, busyPage)
+ return
+ }
+
+ var response
+ try {
+ response = JSON.parse(xhr.responseText)
+ } catch (err) {
+ _showRegistrationError("Invalid Mastodon app registration response", busyPage)
+ return
+ }
+
+ if (!response.client_id || !response.client_secret) {
+ _showRegistrationError("Mastodon app registration did not return credentials", busyPage)
+ return
+ }
+
+ _showOAuthPage({
+ "apiHost": apiHost,
+ "oauthHost": oauthHost(apiHost),
+ "clientId": response.client_id,
+ "clientSecret": response.client_secret
+ })
+ }
+
+ var postData = []
+ postData.push("client_name=" + encodeURIComponent("Sailfish Mastodon"))
+ postData.push("redirect_uris=" + encodeURIComponent(callbackUri))
+ postData.push("scopes=" + encodeURIComponent("read write"))
+ postData.push("website=" + encodeURIComponent("https://sailfishos.org"))
+
+ xhr.open("POST", apiHost + "/api/v1/apps")
+ xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
+ xhr.send(postData.join("&"))
+ }
+
+ function _handleAccountCreated(accountId, context) {
+ var props = {
+ "accountId": accountId,
+ "apiHost": context.apiHost,
+ "oauthHost": context.oauthHost,
+ "clientId": context.clientId,
+ "clientSecret": context.clientSecret
+ }
+ _accountSetup = accountSetupComponent.createObject(root, props)
+ _accountSetup.done.connect(function() {
+ accountCreated(accountId)
+ _goToSettings(accountId)
+ })
+ _accountSetup.error.connect(function() {
+ accountCreationError("Failed to finish Mastodon account setup")
+ })
+ }
+
+ function _goToSettings(accountId) {
+ if (_settingsDialog != null) {
+ _settingsDialog.destroy()
+ }
+ _settingsDialog = settingsComponent.createObject(root, {"accountId": accountId})
+ pageStack.replace(_settingsDialog)
+ }
+
+ initialPage: Dialog {
+ id: setupDialog
+
+ property string normalizedHost: root.normalizeApiHost(instanceField.text)
+
+ canAccept: !root._registering && normalizedHost.length > 0
+ acceptDestinationAction: PageStackAction.Push
+ acceptDestination: busyComponent
+
+ onAccepted: {
+ root._pendingApiHost = normalizedHost
+ }
+
+ DialogHeader {
+ id: header
+ //% "Sign in"
+ acceptText: qsTrId("settings_accounts-common-bt-sign_in")
+ }
+
+ Column {
+ anchors.top: header.bottom
+ anchors.topMargin: Theme.paddingLarge
+ spacing: Theme.paddingLarge
+ width: parent.width
+
+ Label {
+ x: Theme.horizontalPageMargin
+ width: parent.width - x * 2
+ wrapMode: Text.Wrap
+ color: Theme.highlightColor
+ text: "Enter your Mastodon server, then sign in."
+ }
+
+ TextField {
+ id: instanceField
+ x: Theme.horizontalPageMargin
+ width: parent.width - x * 2
+ label: "Server"
+ placeholderText: "mastodon.social"
+ inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhUrlCharactersOnly
+ EnterKey.iconSource: "image://theme/icon-m-enter-next"
+ EnterKey.onClicked: {
+ if (setupDialog.canAccept) {
+ setupDialog.accept()
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: busyComponent
+ AccountBusyPage {
+ busyDescription: "Preparing Mastodon sign-in..."
+ onStatusChanged: {
+ if (status === PageStatus.Active && root._pendingApiHost.length > 0) {
+ root._registerClientApplication(root._pendingApiHost, this)
+ }
+ }
+ }
+ }
+
+ Component {
+ id: oAuthComponent
+ OAuthAccountSetupPage {
+ property var context
+
+ Component.onCompleted: {
+ var sessionData = {
+ "ClientId": context.clientId,
+ "ClientSecret": context.clientSecret,
+ "Host": context.oauthHost,
+ "AuthPath": "oauth/authorize",
+ "TokenPath": "oauth/token",
+ "ResponseType": "code",
+ "Scope": ["read", "write"],
+ "RedirectUri": root.callbackUri
+ }
+ prepareAccountCreation(root.accountProvider, "mastodon-microblog", sessionData)
+ }
+
+ onAccountCreated: {
+ root._handleAccountCreated(accountId, context)
+ }
+
+ onAccountCreationError: {
+ root.accountCreationError(errorMessage)
+ }
+ }
+ }
+
+ Component {
+ id: accountSetupComponent
+ QtObject {
+ id: accountSetup
+
+ property int accountId
+ property string apiHost
+ property string oauthHost
+ property string clientId
+ property string clientSecret
+ property bool hasConfigured
+
+ signal done()
+ signal error()
+
+ property Account newAccount: Account {
+ identifier: accountSetup.accountId
+
+ onStatusChanged: {
+ if (status === Account.Initialized || status === Account.Synced) {
+ if (!accountSetup.hasConfigured) {
+ accountSetup.configure()
+ } else {
+ accountSetup.done()
+ }
+ } else if (status === Account.Invalid && accountSetup.hasConfigured) {
+ accountSetup.error()
+ }
+ }
+ }
+
+ function configure() {
+ hasConfigured = true
+
+ var services = ["mastodon-microblog", "mastodon-sharing"]
+ var providerDisplayName = root._displayName(apiHost)
+ if (providerDisplayName.length > 0) {
+ newAccount.displayName = providerDisplayName
+ }
+
+ newAccount.setConfigurationValue("", "api/Host", apiHost)
+ newAccount.setConfigurationValue("", "FeedViewAutoSync", true)
+ for (var i = 0; i < services.length; ++i) {
+ var service = services[i]
+ newAccount.setConfigurationValue(service, "api/Host", apiHost)
+ newAccount.setConfigurationValue(service, "auth/oauth2/web_server/Host", oauthHost)
+ newAccount.setConfigurationValue(service, "auth/oauth2/web_server/AuthPath", "oauth/authorize")
+ newAccount.setConfigurationValue(service, "auth/oauth2/web_server/TokenPath", "oauth/token")
+ newAccount.setConfigurationValue(service, "auth/oauth2/web_server/ResponseType", "code")
+ newAccount.setConfigurationValue(service, "auth/oauth2/web_server/RedirectUri", root.callbackUri)
+ newAccount.setConfigurationValue(service, "auth/oauth2/web_server/Scope", ["read", "write"])
+ newAccount.setConfigurationValue(service, "auth/oauth2/web_server/ClientId", clientId)
+ newAccount.setConfigurationValue(service, "auth/oauth2/web_server/ClientSecret", clientSecret)
+ }
+
+ for (var j = 0; j < services.length; ++j) {
+ newAccount.enableWithService(services[j])
+ }
+
+ newAccount.sync()
+ }
+ }
+ }
+
+ Component {
+ id: settingsComponent
+ Dialog {
+ property alias accountId: settingsDisplay.accountId
+
+ acceptDestination: root.endDestination
+ acceptDestinationAction: root.endDestinationAction
+ acceptDestinationProperties: root.endDestinationProperties
+ acceptDestinationReplaceTarget: root.endDestinationReplaceTarget
+ backNavigation: false
+
+ onAccepted: {
+ root.delayDeletion = true
+ settingsDisplay.saveAccountAndSync()
+ }
+
+ SilicaFlickable {
+ anchors.fill: parent
+ contentHeight: header.height + settingsDisplay.height
+
+ DialogHeader {
+ id: header
+ }
+
+ MastodonSettingsDisplay {
+ id: settingsDisplay
+ anchors.top: header.bottom
+ accountManager: root.accountManager
+ accountProvider: root.accountProvider
+ autoEnableAccount: true
+
+ onAccountSaveCompleted: {
+ root.delayDeletion = false
+ }
+ }
+
+ VerticalScrollDecorator {}
+ }
+ }
+ }
+
+}