summaryrefslogtreecommitdiff
path: root/settings/accounts/ui/fediverse.qml
diff options
context:
space:
mode:
Diffstat (limited to 'settings/accounts/ui/fediverse.qml')
-rw-r--r--settings/accounts/ui/fediverse.qml679
1 files changed, 679 insertions, 0 deletions
diff --git a/settings/accounts/ui/fediverse.qml b/settings/accounts/ui/fediverse.qml
new file mode 100644
index 0000000..3f5968a
--- /dev/null
+++ b/settings/accounts/ui/fediverse.qml
@@ -0,0 +1,679 @@
+/*
+ * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+
+import QtQuick 2.0
+import Sailfish.Silica 1.0
+import Sailfish.Accounts 1.0
+import com.jolla.settings.accounts 1.0
+import com.jolla.settings.accounts.fediverse 1.0
+
+AccountCreationAgent {
+ id: root
+
+ property Item _oauthPage
+ property Item _settingsDialog
+ property QtObject _accountSetup
+
+ property string _pendingApiHost
+ property var _pendingInstanceContext: ({})
+ property bool _registering
+
+ readonly property string callbackUri: "http://ipv4.jolla.com/online/status.html"
+ readonly property string defaultServerHost: "mastodon.social"
+ readonly property string defaultApiHost: normalizeApiHost(defaultServerHost)
+ readonly property string genericIconPath: "image://theme/icon-l-fediverse"
+
+ 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 _trimmedString(value) {
+ return value ? value.toString().trim() : ""
+ }
+
+ function _normalizeResourceUrl(rawUrl, apiHost) {
+ var value = _trimmedString(rawUrl)
+ if (value.length === 0) {
+ return ""
+ }
+ if (/^https?:\/\//i.test(value)) {
+ return value
+ }
+ if (value.indexOf("//") === 0) {
+ return "https:" + value
+ }
+ if (value.charAt(0) === "/") {
+ return apiHost + value
+ }
+ return apiHost + "/" + value.replace(/^\/+/, "")
+ }
+
+ function _displayName(apiHost) {
+ return oauthHost(apiHost)
+ }
+
+ function _fallbackDisplayName(apiHost) {
+ var display = _displayName(apiHost)
+ if (display.length > 0) {
+ return display
+ }
+ return _displayName(defaultApiHost)
+ }
+
+ function _instanceContext(apiHost) {
+ var normalizedApiHost = normalizeApiHost(apiHost)
+ var title = _fallbackDisplayName(normalizedApiHost)
+ return {
+ "apiHost": normalizedApiHost,
+ "oauthHost": oauthHost(normalizedApiHost),
+ "instanceTitle": title.length > 0 ? title : qsTrId("settings-accounts-fediverse-la-provider_name"),
+ "instanceDescription": "",
+ "instanceIconUrl": "",
+ "instanceIconPath": instanceIconCache.cachedIconPath(normalizedApiHost)
+ }
+ }
+
+ function _extractInstanceTitle(responseData, apiHost) {
+ var candidates = [
+ responseData ? responseData.title : "",
+ responseData ? responseData.name : "",
+ responseData ? responseData.uri : ""
+ ]
+ for (var i = 0; i < candidates.length; ++i) {
+ var value = _trimmedString(candidates[i])
+ if (value.length > 0) {
+ return value
+ }
+ }
+ return _fallbackDisplayName(apiHost)
+ }
+
+ function _extractInstanceDescription(responseData) {
+ var candidates = [
+ responseData ? responseData.short_description : "",
+ responseData ? responseData.description : "",
+ responseData ? responseData.shortDescription : ""
+ ]
+ for (var i = 0; i < candidates.length; ++i) {
+ var value = _trimmedString(candidates[i])
+ if (value.length > 0) {
+ return value
+ }
+ }
+ return ""
+ }
+
+ function _extractInstanceIconUrl(responseData, apiHost) {
+ if (!responseData) {
+ return ""
+ }
+
+ if (responseData.thumbnail) {
+ if (typeof responseData.thumbnail === "string") {
+ return _normalizeResourceUrl(responseData.thumbnail, apiHost)
+ }
+ if (responseData.thumbnail.url) {
+ return _normalizeResourceUrl(responseData.thumbnail.url, apiHost)
+ }
+ if (responseData.thumbnail.static_url) {
+ return _normalizeResourceUrl(responseData.thumbnail.static_url, apiHost)
+ }
+ }
+
+ if (responseData.icon) {
+ if (typeof responseData.icon === "string") {
+ return _normalizeResourceUrl(responseData.icon, apiHost)
+ }
+ if (responseData.icon.url) {
+ return _normalizeResourceUrl(responseData.icon.url, apiHost)
+ }
+ }
+
+ return ""
+ }
+
+ 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(context, busyPage) {
+ if (_registering) {
+ return
+ }
+ _registering = true
+
+ if (context.instanceIconUrl.length > 0 && context.instanceIconPath.length === 0) {
+ instanceIconCache.cacheIcon(context.apiHost, context.instanceIconUrl)
+ }
+
+ var xhr = new XMLHttpRequest()
+ xhr.onreadystatechange = function() {
+ if (xhr.readyState !== XMLHttpRequest.DONE) {
+ return
+ }
+
+ if (xhr.status < 200 || xhr.status >= 300) {
+ _showRegistrationError(qsTrId("settings-accounts-fediverse-la-register_app_failed").arg(context.apiHost), busyPage)
+ return
+ }
+
+ var response
+ try {
+ response = JSON.parse(xhr.responseText)
+ } catch (err) {
+ _showRegistrationError(qsTrId("settings-accounts-fediverse-la-invalid_app_registration_response"), busyPage)
+ return
+ }
+
+ if (!response.client_id || !response.client_secret) {
+ _showRegistrationError(qsTrId("settings-accounts-fediverse-la-app_registration_missing_credentials"), busyPage)
+ return
+ }
+
+ context.clientId = response.client_id
+ context.clientSecret = response.client_secret
+ _showOAuthPage(context)
+ }
+
+ var postData = []
+ postData.push("client_name=" + encodeURIComponent(qsTrId("settings-accounts-fediverse-la-client_name")))
+ postData.push("redirect_uris=" + encodeURIComponent(callbackUri))
+ postData.push("scopes=" + encodeURIComponent("read write"))
+ postData.push("website=" + encodeURIComponent("https://sailfishos.org"))
+
+ xhr.open("POST", context.apiHost + "/api/v1/apps")
+ xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
+ xhr.send(postData.join("&"))
+ }
+
+ function _discoverInstanceContext(apiHost, busyPage) {
+ var context = _instanceContext(apiHost)
+ _pendingInstanceContext = context
+
+ function continueWithContext() {
+ _pendingInstanceContext = context
+ _registerClientApplication(context, busyPage)
+ }
+
+ function loadEndpoint(paths, index) {
+ if (index >= paths.length) {
+ continueWithContext()
+ return
+ }
+
+ var xhr = new XMLHttpRequest()
+ xhr.onreadystatechange = function() {
+ if (xhr.readyState !== XMLHttpRequest.DONE) {
+ return
+ }
+
+ if (xhr.status >= 200 && xhr.status < 300) {
+ try {
+ var response = JSON.parse(xhr.responseText)
+ context.instanceTitle = _extractInstanceTitle(response, context.apiHost)
+ context.instanceDescription = _extractInstanceDescription(response)
+ context.instanceIconUrl = _extractInstanceIconUrl(response, context.apiHost)
+ if (context.instanceIconUrl.length > 0) {
+ var cachedPath = instanceIconCache.cachedIconPath(context.apiHost)
+ if (cachedPath.length > 0) {
+ context.instanceIconPath = cachedPath
+ }
+ }
+ } catch (err) {
+ }
+ continueWithContext()
+ return
+ }
+
+ loadEndpoint(paths, index + 1)
+ }
+
+ xhr.open("GET", context.apiHost + paths[index])
+ xhr.send()
+ }
+
+ loadEndpoint(["/api/v2/instance", "/api/v1/instance"], 0)
+ }
+
+ function _extractAccountName(responseData) {
+ if (!responseData) {
+ return ""
+ }
+
+ var candidates = [
+ "AccountUsername",
+ "UserName",
+ "user_name",
+ "acct",
+ "username",
+ "preferred_username",
+ "login",
+ "ScreenName"
+ ]
+ for (var i = 0; i < candidates.length; ++i) {
+ var value = responseData[candidates[i]]
+ if (value) {
+ var userName = value.toString().trim()
+ if (userName.length > 0) {
+ return userName
+ }
+ }
+ }
+
+ return ""
+ }
+
+ function _formatFediverseAccountId(accountName, apiHost) {
+ var value = accountName ? accountName.toString().trim() : ""
+ if (value.length === 0) {
+ return ""
+ }
+
+ value = value.replace(/^@+/, "")
+ if (value.indexOf("@") !== -1) {
+ return "@" + value
+ }
+
+ var host = oauthHost(apiHost)
+ if (host.length === 0) {
+ return ""
+ }
+
+ return "@" + value + "@" + host
+ }
+
+ function _isFediverseAccountId(value) {
+ var text = value ? value.toString().trim() : ""
+ return /^@[^@]+@[^@]+$/.test(text)
+ }
+
+ function _extractAccessToken(responseData) {
+ if (!responseData) {
+ return ""
+ }
+
+ var token = responseData["AccessToken"]
+ if (!token || token.toString().trim().length === 0) {
+ token = responseData["access_token"]
+ }
+ return token ? token.toString().trim() : ""
+ }
+
+ function _handleAccountCreated(accountId, context, responseData) {
+ var props = {
+ "accountId": accountId,
+ "apiHost": context.apiHost,
+ "oauthHost": context.oauthHost,
+ "clientId": context.clientId,
+ "clientSecret": context.clientSecret,
+ "accessToken": _extractAccessToken(responseData),
+ "accountDescription": _formatFediverseAccountId(_extractAccountName(responseData), context.apiHost),
+ "instanceTitle": context.instanceTitle,
+ "instanceDescription": context.instanceDescription,
+ "instanceIconUrl": context.instanceIconUrl,
+ "instanceIconPath": context.instanceIconPath
+ }
+ _accountSetup = accountSetupComponent.createObject(root, props)
+ _accountSetup.done.connect(function() {
+ accountCreated(accountId)
+ _goToSettings(accountId)
+ })
+ _accountSetup.error.connect(function() {
+ accountCreationError(qsTrId("settings-accounts-fediverse-la-account_setup_failed"))
+ })
+ }
+
+ function _goToSettings(accountId) {
+ if (_settingsDialog != null) {
+ _settingsDialog.destroy()
+ }
+ _settingsDialog = settingsComponent.createObject(root, {"accountId": accountId})
+ pageStack.replace(_settingsDialog)
+ }
+
+ FediverseInstanceIconCache {
+ id: instanceIconCache
+
+ onIconReady: {
+ var normalizedHost = root.normalizeApiHost(apiHost)
+ if (root._pendingInstanceContext.apiHost === normalizedHost) {
+ root._pendingInstanceContext.instanceIconPath = iconPath
+ }
+ if (root._accountSetup && root._accountSetup.apiHost === normalizedHost) {
+ root._accountSetup.updateInstanceIcon(iconPath)
+ }
+ }
+ }
+
+ initialPage: Dialog {
+ id: setupDialog
+
+ property string normalizedHost: root.normalizeApiHost(instanceField.text)
+
+ canAccept: !root._registering
+ acceptDestinationAction: PageStackAction.Push
+ acceptDestination: busyComponent
+
+ onAccepted: {
+ root._pendingApiHost = normalizedHost.length > 0 ? normalizedHost : root.defaultApiHost
+ }
+
+ DialogHeader {
+ id: header
+ acceptText: qsTrId("settings-accounts-common-bt-sign_in")
+ }
+
+ Column {
+ anchors.top: header.bottom
+ anchors.topMargin: Theme.paddingLarge
+ spacing: Theme.paddingLarge
+ width: parent.width
+
+ Row {
+ x: Theme.horizontalPageMargin
+ width: parent.width - x * 2
+ spacing: Theme.paddingMedium
+
+ Image {
+ id: promptIcon
+ width: Theme.iconSizeMedium
+ height: Theme.iconSizeMedium
+ source: root.genericIconPath
+ fillMode: Image.PreserveAspectFit
+ sourceSize.width: width
+ sourceSize.height: height
+ }
+
+ Label {
+ width: parent.width - promptIcon.width - parent.spacing
+ wrapMode: Text.Wrap
+ color: Theme.highlightColor
+ text: qsTrId("settings-accounts-fediverse-la-enter_server_then_sign_in")
+ }
+ }
+
+ TextField {
+ id: instanceField
+ x: Theme.horizontalPageMargin
+ width: parent.width - x * 2
+ label: qsTrId("settings-accounts-fediverse-la-server")
+ placeholderText: root.defaultServerHost
+ 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: qsTrId("settings-accounts-fediverse-la-preparing_sign_in")
+ onStatusChanged: {
+ if (status === PageStatus.Active && root._pendingApiHost.length > 0) {
+ root._discoverInstanceContext(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, "fediverse-microblog", sessionData)
+ }
+
+ onAccountCreated: {
+ root._handleAccountCreated(accountId, context, responseData)
+ }
+
+ 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 string accessToken
+ property string accountDescription
+ property string instanceTitle
+ property string instanceDescription
+ property string instanceIconUrl
+ property string instanceIconPath
+ 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 _serviceNames() {
+ return ["fediverse-microblog", "fediverse-notifications", "fediverse-sharing"]
+ }
+
+ function _effectiveInstanceTitle() {
+ var title = root._trimmedString(instanceTitle)
+ return title.length > 0 ? title : qsTrId("settings-accounts-fediverse-la-provider_name")
+ }
+
+ function _effectiveIconPath() {
+ return root._trimmedString(instanceIconPath).length > 0 ? instanceIconPath : root.genericIconPath
+ }
+
+ function _applyIconPath(iconPath) {
+ instanceIconPath = iconPath
+ newAccount.setConfigurationValue("", "iconPath", _effectiveIconPath())
+ var services = _serviceNames()
+ for (var i = 0; i < services.length; ++i) {
+ newAccount.setConfigurationValue(services[i], "iconPath", _effectiveIconPath())
+ }
+ if (hasConfigured) {
+ newAccount.sync()
+ }
+ }
+
+ function updateInstanceIcon(iconPath) {
+ if (root._trimmedString(iconPath).length === 0) {
+ return
+ }
+ _applyIconPath(iconPath)
+ }
+
+ function configure() {
+ hasConfigured = true
+
+ var services = _serviceNames()
+ var providerDisplayName = _effectiveInstanceTitle()
+ newAccount.displayName = providerDisplayName
+
+ newAccount.setConfigurationValue("", "api/Host", apiHost)
+ newAccount.setConfigurationValue("", "FeedViewAutoSync", true)
+ newAccount.setConfigurationValue("", "instance/Title", providerDisplayName)
+ newAccount.setConfigurationValue("", "instance/Description", root._trimmedString(instanceDescription))
+ newAccount.setConfigurationValue("", "instance/IconUrl", root._trimmedString(instanceIconUrl))
+ _applyIconPath(_effectiveIconPath())
+
+ if (accountDescription.length > 0) {
+ newAccount.setConfigurationValue("", "description", accountDescription)
+ if (root._isFediverseAccountId(accountDescription)) {
+ newAccount.setConfigurationValue("", "default_credentials_username", accountDescription)
+ }
+ }
+
+ 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])
+ }
+
+ if (instanceIconUrl.length > 0 && instanceIconPath.length === 0) {
+ instanceIconCache.cacheIcon(apiHost, instanceIconUrl)
+ }
+
+ if (accountDescription.length > 0 || accessToken.length === 0) {
+ newAccount.sync()
+ return
+ }
+
+ var xhr = new XMLHttpRequest()
+ xhr.onreadystatechange = function() {
+ if (xhr.readyState !== XMLHttpRequest.DONE) {
+ return
+ }
+
+ if (xhr.status >= 200 && xhr.status < 300) {
+ try {
+ var response = JSON.parse(xhr.responseText)
+ var fetchedDescription = root._formatFediverseAccountId(root._extractAccountName(response), apiHost)
+ if (fetchedDescription.length > 0) {
+ accountDescription = fetchedDescription
+ newAccount.setConfigurationValue("", "description", fetchedDescription)
+ if (root._isFediverseAccountId(fetchedDescription)) {
+ newAccount.setConfigurationValue("", "default_credentials_username", fetchedDescription)
+ }
+ }
+ } catch (err) {
+ }
+ }
+
+ newAccount.sync()
+ }
+
+ xhr.open("GET", apiHost + "/api/v1/accounts/verify_credentials")
+ xhr.setRequestHeader("Authorization", "Bearer " + accessToken)
+ xhr.send()
+ }
+ }
+ }
+
+ 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
+ }
+
+ FediverseSettingsDisplay {
+ id: settingsDisplay
+ anchors.top: header.bottom
+ accountManager: root.accountManager
+ accountProvider: root.accountProvider
+ autoEnableAccount: true
+
+ onAccountSaveCompleted: {
+ root.delayDeletion = false
+ }
+ }
+
+ VerticalScrollDecorator {}
+ }
+ }
+ }
+}