Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: 3rd-Party Server URLs #7

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Revolt.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@
1773C03D2C07DD1F007B8867 /* MessageableChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1773C03C2C07DD1F007B8867 /* MessageableChannel.swift */; };
17772C182C30AF83000D1EDA /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17772C172C30AF83000D1EDA /* AppDelegate.swift */; };
1777DD892ADC3C31003D6C72 /* Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1777DD882ADC3C31003D6C72 /* Markdown.swift */; };
177DA1172CEADBB1000FC7EA /* AnyCodable in Frameworks */ = {isa = PBXBuildFile; productRef = 177DA1162CEADBB1000FC7EA /* AnyCodable */; };
1781011E2C8CBC2900AC2756 /* SubviewAttachingTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 1781011D2C8CBC2900AC2756 /* SubviewAttachingTextView */; };
1782F5E62B08F60B00759D40 /* Discovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1782F5E52B08F60B00759D40 /* Discovery.swift */; };
17863A592C8094840051A52C /* Tile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17863A582C8094840051A52C /* Tile.swift */; };
Expand Down Expand Up @@ -141,6 +140,8 @@
17F8B7092C7983730065F1DE /* CreateServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17F8B7082C7983730065F1DE /* CreateServer.swift */; };
17F9D7632C9208B500D0BB6F /* MessageReactionsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17F9D7622C9208B500D0BB6F /* MessageReactionsSheet.swift */; };
36D461CF77D84B97B94929A9 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 03268FCCFC7D4D1F8B6E9F6F /* Sentry */; };
8FCDB25C2D067E7C009C82DB /* AnyCodable in Frameworks */ = {isa = PBXBuildFile; productRef = 177DA1162CEADBB1000FC7EA /* AnyCodable */; };
8FCDB25E2D067E8B009C82DB /* ServerUrlSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FCDB25D2D067E8B009C82DB /* ServerUrlSelector.swift */; };
D49B705329C4D3FE009494A5 /* RevoltApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D49B705229C4D3FE009494A5 /* RevoltApp.swift */; };
D49B705729C4D3FE009494A5 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D49B705629C4D3FE009494A5 /* Assets.xcassets */; };
D49B705A29C4D3FE009494A5 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D49B705929C4D3FE009494A5 /* Preview Assets.xcassets */; };
Expand Down Expand Up @@ -326,6 +327,7 @@
17F555262AFC229900958F2F /* ServerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSettings.swift; sourceTree = "<group>"; };
17F8B7082C7983730065F1DE /* CreateServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateServer.swift; sourceTree = "<group>"; };
17F9D7622C9208B500D0BB6F /* MessageReactionsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReactionsSheet.swift; sourceTree = "<group>"; };
8FCDB25D2D067E8B009C82DB /* ServerUrlSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUrlSelector.swift; sourceTree = "<group>"; };
D49B704F29C4D3FE009494A5 /* Revolt.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Revolt.app; sourceTree = BUILT_PRODUCTS_DIR; };
D49B705229C4D3FE009494A5 /* RevoltApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevoltApp.swift; sourceTree = "<group>"; };
D49B705629C4D3FE009494A5 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
Expand All @@ -352,6 +354,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
8FCDB25C2D067E7C009C82DB /* AnyCodable in Frameworks */,
DAAA4BF429F2274A00F41E52 /* Collections in Frameworks */,
175997192B2FB90700C39CF6 /* Flow in Frameworks */,
1746CF5A2B83C6750051FD47 /* CodableWrapper in Frameworks */,
Expand Down Expand Up @@ -590,6 +593,7 @@
children = (
176485742CA3947B00AF8141 /* PermissionToggle.swift */,
170410962CA478D5002C1445 /* AllPermissionsSettings.swift */,
8FCDB25D2D067E8B009C82DB /* ServerUrlSelector.swift */,
);
path = Settings;
sourceTree = "<group>";
Expand Down Expand Up @@ -1112,6 +1116,7 @@
17D5C9442B14DF500060C035 /* DMScrollView.swift in Sources */,
1746A4B62CAF57C300095CF3 /* GroupDMChannelPermissionsSettings.swift in Sources */,
17D8BACF2B211DEE005F5447 /* ChannelInfo.swift in Sources */,
8FCDB25E2D067E8B009C82DB /* ServerUrlSelector.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
24 changes: 19 additions & 5 deletions Revolt/Api/UserSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ class PersistentUserSettingsStore: Codable {

var notifications: NotificationOptionsData

var serverUrl: String {
didSet {
keyWasSet()
}
}

var lastOpenChannels: [String: String] {
didSet {
keyWasSet()
Expand All @@ -177,8 +183,9 @@ class PersistentUserSettingsStore: Codable {

var experiments: ExperimentOptionsData

init(keyWasSet: @escaping () -> Void, notifications: NotificationOptionsData, lastOpenChannels: [String: String], closedCategories: [String: Set<String>], experiments: ExperimentOptionsData) {
init(keyWasSet: @escaping () -> Void, notifications: NotificationOptionsData, serverUrl: String, lastOpenChannels: [String: String], closedCategories: [String: Set<String>], experiments: ExperimentOptionsData) {
self.notifications = notifications
self.serverUrl = serverUrl
self.lastOpenChannels = lastOpenChannels
self.closedCategories = closedCategories
self.experiments = experiments
Expand All @@ -188,6 +195,7 @@ class PersistentUserSettingsStore: Codable {

init() {
self.notifications = NotificationOptionsData()
self.serverUrl = ""
self.lastOpenChannels = [:]
self.closedCategories = [:]
self.experiments = ExperimentOptionsData()
Expand All @@ -201,6 +209,7 @@ class PersistentUserSettingsStore: Codable {

enum CodingKeys: String, CodingKey {
case _notifications = "notifications"
case _serverUrl = "serverUrl"
case _lastOpenChannels = "lastOpenChannels"
case _closedCategories = "closedCategories"
case _experiments = "experiments"
Expand Down Expand Up @@ -338,9 +347,14 @@ class UserSettingsData {
mfaStatus: try await state.http.fetchMFAStatus().get()
)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change was added due to an infinite loop in waiting for notifications. When logging into the official server or a 3rd party, the request would make it to the API, but the user would never get the "Accept Notifications" prompt.

let settingsValues = try await state.http.fetchSettings(keys: ["notifications"]).get()
let notificationValue = try! settingsValues["notifications"].unwrapped().b.replacingOccurrences(of: #"\""#, with: #"""#)
self.cache.notificationSettings = try! JSONDecoder().decode(UserSettingsNotificationsData.self, from: try! notificationValue.data(using: .utf8).unwrapped())
if let settingsValues = try await state.http.fetchSettings(keys: ["notifications"]).get()["notifications"] {
let notificationString = String(describing: settingsValues)
let cleanValue = notificationString.replacingOccurrences(of: #"\""#, with: #"""#)
if let data = cleanValue.data(using: String.Encoding.utf8),
let notificationSettings = try? JSONDecoder().decode(UserSettingsNotificationsData.self, from: data) {
self.cache.notificationSettings = notificationSettings
}
}

self.cacheState = .cached
writeCacheToFile()
Expand Down Expand Up @@ -427,4 +441,4 @@ class UserSettingsData {
self.store = .init()
self.store.updateDecodeWithCallback(keyWasSet: storeKeyWasSet)
}
}
}
211 changes: 211 additions & 0 deletions Revolt/Components/Settings/ServerUrlSelector.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
//
// ServerUrlSelector.swift
// Revolt
//
// Created by pythcon on 12/7/24.
//

import SwiftUI
import Types

struct ServerUrlSelector: View {
@EnvironmentObject var viewState: ViewState
@Environment(\.colorScheme) var colorScheme

private let officialServer = "https://api.revolt.chat"
@State private var showCustomServer = false
@State private var customDomain: String = ""
@State private var isValidating = false
@State private var validationError: String? = nil
@State private var validationSuccess: String? = nil
@State private var connectionStatus: ConnectionStatus = .untested
@FocusState private var isTextFieldFocused: Bool

enum ConnectionStatus {
case untested
case testing
case success
case failed
}

private func validateAndUpdateApiInfo(_ domain: String) {
if domain.isEmpty { return }

isValidating = true
connectionStatus = .testing
validationError = nil
validationSuccess = nil

let baseUrl: String
if domain == officialServer {
baseUrl = officialServer
} else {
// First try the domain as-is
let cleanDomain = domain.hasSuffix("/") ? String(domain.dropLast()) : domain
baseUrl = (domain.starts(with: "http://") || domain.starts(with: "https://")) ? cleanDomain : "https://" + cleanDomain
}

// Function to try validation with a URL
func tryValidation(url: String) async -> Bool {
let tempHttp = HTTPClient(token: nil, baseURL: url)
do {
let fetchedApiInfo = try await tempHttp.fetchApiInfo().get()
// Validate that this is actually a Revolt API
guard
!fetchedApiInfo.revolt.isEmpty, // Check revolt version exists
fetchedApiInfo.features.january != nil, // Check for required features
fetchedApiInfo.features.autumn != nil, // Check for required features
!fetchedApiInfo.ws.isEmpty, // Check websocket URL exists
!fetchedApiInfo.app.isEmpty // Check app URL exists
else {
return false
}

await MainActor.run {
viewState.apiInfo = fetchedApiInfo
viewState.userSettingsStore.store.serverUrl = url

if url != baseUrl {
customDomain = url // Update textbox if we added /api
}

isValidating = false
validationSuccess = "Successfully connected to server"
connectionStatus = .success
}
return true
} catch {
return false
}
}

Task {
// Try original URL first
if await tryValidation(url: baseUrl) {
return
}

// If not official server and first attempt failed, try with /api
if domain != officialServer {
let apiUrl = baseUrl + "/api"
if await tryValidation(url: apiUrl) {
return
}
}

// If both attempts failed, show error
await MainActor.run {
isValidating = false
validationError = "Unable to connect to server"
connectionStatus = .failed
}
}
}

private var statusIcon: some View {
Group {
switch connectionStatus {
case .untested:
Image(systemName: "link.circle.fill")
.foregroundStyle(.gray)
case .testing:
ProgressView()
.controlSize(.small)
case .success:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
case .failed:
Image(systemName: "x.circle.fill")
.foregroundStyle(.red)
}
}
}

var body: some View {
VStack(alignment: .leading) {
HStack {
Text("Server")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button(action: {
withAnimation {
showCustomServer.toggle()
if !showCustomServer {
validateAndUpdateApiInfo(officialServer)
viewState.userSettingsStore.store.serverUrl = officialServer
}
}
}) {
Text(showCustomServer ? "Use Official" : "Use Custom")
.font(.caption)
.foregroundStyle(viewState.theme.accent)
}
}

if showCustomServer {
HStack {
TextField(
"Domain (e.g. example.com)",
text: $customDomain
)
.textContentType(.URL)
.keyboardType(.URL)
.disabled(isValidating)
.focused($isTextFieldFocused)
.onChange(of: isTextFieldFocused) { oldValue, newValue in
if !newValue {
validateAndUpdateApiInfo(customDomain)
}
}
.onChange(of: customDomain) { oldValue, newValue in
print("onChange: \(oldValue) -> \(newValue)")
print("connectionStatus: \(connectionStatus)")
print("validationSuccess: \(validationSuccess)")
if oldValue != newValue {
connectionStatus = .untested
validationError = nil
validationSuccess = nil
}
}

Button(action: {
i
';f !customDomain.isEmpty {
validateAndUpdateApiInfo(customDomain)
}
}) {
statusIcon
}
.disabled(connectionStatus == .testing || customDomain.isEmpty)
}
.padding()
.background((colorScheme == .light) ? Color(white: 0.851) : Color(white: 0.2))
.clipShape(.rect(cornerRadius: 5))

if let error = validationError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
} else if let success = validationSuccess {
Text(success)
.font(.caption)
.foregroundStyle(.green)
}
} else {
Text("Official Revolt Server")
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background((colorScheme == .light) ? Color(white: 0.851) : Color(white: 0.2))
.clipShape(.rect(cornerRadius: 5))
}
}
.padding(.bottom)
.onAppear {
if viewState.userSettingsStore.store.serverUrl.isEmpty {
viewState.userSettingsStore.store.serverUrl = officialServer
validateAndUpdateApiInfo(officialServer)
}
}
}
}
2 changes: 2 additions & 0 deletions Revolt/Pages/Login/CreateAccount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ struct CreateAccount: View {
// Text(verbatim: error)
// .foregroundStyle(.red)
// }
ServerUrlSelector()

TextField(
"Email",
text: $email
Expand Down
2 changes: 2 additions & 0 deletions Revolt/Pages/Login/Login.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ struct LogIn: View {
.foregroundStyle((colorScheme == .light) ? Color.black : Color.white)

Group {
ServerUrlSelector()

if let error = errorMessage {
Text(verbatim: error)
.foregroundStyle(.red)
Expand Down
10 changes: 10 additions & 0 deletions Revolt/Pages/Settings/UserSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,16 @@ struct UserSettings: View {
var body: some View {
List {
Section("Account Info") {
HStack {
Text("Server")
Spacer()
if let apiInfo = viewState.apiInfo {
Text(URL(string: apiInfo.app)?.host ?? "")
.foregroundStyle(.secondary)
}
}
.listRowBackground(viewState.theme.background2)

Button(action: {
presentChangeUsernameSheet = true
}) {
Expand Down
Loading
Loading