/* * 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 {} } } } }