diff options
Diffstat (limited to 'settings/accounts/ui/fediverse.qml')
| -rw-r--r-- | settings/accounts/ui/fediverse.qml | 679 |
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 {} + } + } + } +} |
