mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-04-07 17:15:52 +00:00
474 lines
19 KiB
Swift
474 lines
19 KiB
Swift
//
|
|
// AddChannelView.swift
|
|
// SimpleX (iOS)
|
|
//
|
|
// Created by spaced4ndy on 23.02.2026.
|
|
// Copyright © 2026 SimpleX Chat. All rights reserved.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SimpleXChat
|
|
|
|
struct AddChannelView: View {
|
|
@EnvironmentObject var m: ChatModel
|
|
@EnvironmentObject var theme: AppTheme
|
|
@StateObject private var channelRelaysModel = ChannelRelaysModel.shared
|
|
@StateObject private var ss = SaveableSettings()
|
|
@State private var profile = GroupProfile(displayName: "", fullName: "")
|
|
@FocusState private var focusDisplayName: Bool
|
|
@State private var showChooseSource = false
|
|
@State private var showImagePicker = false
|
|
@State private var showTakePhoto = false
|
|
@State private var chosenImage: UIImage? = nil
|
|
@State private var hasRelays = true
|
|
@State private var groupInfo: GroupInfo? = nil
|
|
@State private var groupLink: GroupLink? = nil
|
|
@State private var groupRelays: [GroupRelay] = []
|
|
@State private var creationInProgress = false
|
|
@State private var showLinkStep = false
|
|
@State private var relayListExpanded = false
|
|
|
|
var body: some View {
|
|
Group {
|
|
if showLinkStep, let gInfo = groupInfo {
|
|
linkStepView(gInfo)
|
|
} else if let gInfo = groupInfo {
|
|
progressStepView(gInfo)
|
|
} else {
|
|
profileStepView()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Step 1: Profile
|
|
|
|
private func profileStepView() -> some View {
|
|
List {
|
|
Group {
|
|
ZStack(alignment: .center) {
|
|
ZStack(alignment: .topTrailing) {
|
|
ProfileImage(imageStr: profile.image, size: 128)
|
|
if profile.image != nil {
|
|
Button {
|
|
profile.image = nil
|
|
} label: {
|
|
Image(systemName: "multiply")
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 12)
|
|
}
|
|
}
|
|
}
|
|
editImageButton { showChooseSource = true }
|
|
.buttonStyle(BorderlessButtonStyle())
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
}
|
|
.listRowBackground(Color.clear)
|
|
.listRowSeparator(.hidden)
|
|
.listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0))
|
|
|
|
Section {
|
|
channelNameTextField()
|
|
NavigationLink {
|
|
NetworkAndServers()
|
|
.navigationTitle("Network & servers")
|
|
.modifier(ThemedBackground(grouped: true))
|
|
.environmentObject(ss)
|
|
} label: {
|
|
let color: Color = hasRelays ? .accentColor : .orange
|
|
settingsRow("externaldrive.connected.to.line.below", color: color) {
|
|
Text("Configure relays").foregroundColor(color)
|
|
}
|
|
}
|
|
let canCreate = canCreateProfile() && hasRelays && !creationInProgress
|
|
Button(action: createChannel) {
|
|
settingsRow("checkmark", color: canCreate ? theme.colors.primary : theme.colors.secondary) { Text("Create public channel") }
|
|
}
|
|
.disabled(!canCreate)
|
|
} footer: {
|
|
if !hasRelays {
|
|
ServersWarningView(warnStr: NSLocalizedString("Enable at least one chat relay in Network & Servers.", comment: "channel creation warning"))
|
|
} else {
|
|
let name = ChatModel.shared.currentUser?.displayName ?? ""
|
|
Text("Your profile **\(name)** will be shared with channel relays and subscribers.\nRelays can access channel messages.")
|
|
.foregroundColor(theme.colors.secondary)
|
|
}
|
|
}
|
|
.compactSectionSpacing()
|
|
}
|
|
.onAppear {
|
|
Task { hasRelays = await checkHasRelays() }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
|
focusDisplayName = true
|
|
}
|
|
}
|
|
.confirmationDialog("Channel image", isPresented: $showChooseSource, titleVisibility: .visible) {
|
|
Button("Take picture") { showTakePhoto = true }
|
|
Button("Choose from library") { showImagePicker = true }
|
|
}
|
|
.fullScreenCover(isPresented: $showTakePhoto) {
|
|
ZStack {
|
|
Color.black.edgesIgnoringSafeArea(.all)
|
|
CameraImagePicker(image: $chosenImage)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showImagePicker) {
|
|
LibraryImagePicker(image: $chosenImage) { _ in
|
|
await MainActor.run { showImagePicker = false }
|
|
}
|
|
}
|
|
.onChange(of: chosenImage) { image in
|
|
Task {
|
|
let resized: String? = if let image {
|
|
await resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
|
|
} else {
|
|
nil
|
|
}
|
|
await MainActor.run { profile.image = resized }
|
|
}
|
|
}
|
|
.modifier(ThemedBackground(grouped: true))
|
|
}
|
|
|
|
private func channelNameTextField() -> some View {
|
|
ZStack(alignment: .leading) {
|
|
let name = profile.displayName.trimmingCharacters(in: .whitespaces)
|
|
if name != mkValidName(name) {
|
|
Button {
|
|
showInvalidChannelNameAlert()
|
|
} label: {
|
|
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
|
}
|
|
} else {
|
|
Image(systemName: "pencil").foregroundColor(theme.colors.secondary)
|
|
}
|
|
TextField("Enter channel name…", text: $profile.displayName)
|
|
.padding(.leading, 36)
|
|
.focused($focusDisplayName)
|
|
.submitLabel(.continue)
|
|
.onSubmit {
|
|
if canCreateProfile() && hasRelays { createChannel() }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func canCreateProfile() -> Bool {
|
|
let name = profile.displayName.trimmingCharacters(in: .whitespaces)
|
|
return name != "" && validDisplayName(name)
|
|
}
|
|
|
|
private func createChannel() {
|
|
focusDisplayName = false
|
|
profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces)
|
|
profile.groupPreferences = GroupPreferences(history: GroupPreference(enable: .on))
|
|
creationInProgress = true
|
|
Task {
|
|
do {
|
|
let enabledRelays = try await chooseRandomRelays()
|
|
let relayIds = enabledRelays.compactMap { $0.chatRelayId }
|
|
guard !relayIds.isEmpty else {
|
|
await MainActor.run {
|
|
creationInProgress = false
|
|
hasRelays = false
|
|
}
|
|
return
|
|
}
|
|
guard let (gInfo, gLink, gRelays) = try await apiNewPublicGroup(
|
|
incognito: false, relayIds: relayIds, groupProfile: profile
|
|
) else {
|
|
await MainActor.run { creationInProgress = false }
|
|
return
|
|
}
|
|
await MainActor.run {
|
|
m.updateGroup(gInfo)
|
|
m.creatingChannelId = gInfo.id
|
|
groupInfo = gInfo
|
|
groupLink = gLink
|
|
groupRelays = gRelays.sorted { relayDisplayName($0) < relayDisplayName($1) }
|
|
channelRelaysModel.set(groupId: gInfo.groupId, groupRelays: gRelays)
|
|
creationInProgress = false
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
creationInProgress = false
|
|
showAlert(
|
|
NSLocalizedString("Error creating channel", comment: "alert title"),
|
|
message: responseError(error)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private let maxRelays = 3
|
|
|
|
private func chooseRandomRelays() async throws -> [UserChatRelay] {
|
|
let servers = try await getUserServers()
|
|
// Operator relays are grouped per operator; custom relays (nil operator)
|
|
// are treated independently to maximize trust distribution.
|
|
var operatorGroups: [[UserChatRelay]] = []
|
|
var customRelays: [UserChatRelay] = []
|
|
for op in servers {
|
|
let relays = op.chatRelays.filter { $0.enabled && !$0.deleted && $0.chatRelayId != nil }
|
|
guard !relays.isEmpty else { continue }
|
|
if op.operator != nil {
|
|
operatorGroups.append(relays.shuffled())
|
|
} else {
|
|
customRelays = relays.shuffled()
|
|
}
|
|
}
|
|
var selected: [UserChatRelay] = []
|
|
// Prefer at least one custom relay when available -
|
|
// user's own infrastructure for trust distribution.
|
|
if let relay = customRelays.first {
|
|
selected.append(relay)
|
|
customRelays.removeFirst()
|
|
if selected.count >= maxRelays { return selected }
|
|
}
|
|
// Round-robin across shuffled groups to distribute relays across operators.
|
|
var groups = operatorGroups + customRelays.map { [$0] }
|
|
groups.shuffle()
|
|
let maxDepth = groups.map(\.count).max() ?? 0
|
|
for depth in 0..<maxDepth {
|
|
for group in groups {
|
|
if depth < group.count {
|
|
selected.append(group[depth])
|
|
if selected.count >= maxRelays { return selected }
|
|
}
|
|
}
|
|
}
|
|
return selected
|
|
}
|
|
|
|
private func checkHasRelays() async -> Bool {
|
|
guard let servers = try? await getUserServers() else { return false }
|
|
return servers.contains { op in
|
|
op.chatRelays.contains { $0.enabled && !$0.deleted && $0.chatRelayId != nil }
|
|
}
|
|
}
|
|
|
|
// MARK: - Step 2: Progress
|
|
|
|
private func progressStepView(_ gInfo: GroupInfo) -> some View {
|
|
let failedCount = groupRelays.filter { relayMemberConnFailed($0) != nil }.count
|
|
let activeCount = groupRelays.filter { $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }.count
|
|
let total = groupRelays.count
|
|
return List {
|
|
Group {
|
|
ProfileImage(imageStr: gInfo.groupProfile.image, size: 128)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
|
|
Text(gInfo.groupProfile.displayName)
|
|
.font(.headline)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
}
|
|
.listRowBackground(Color.clear)
|
|
.listRowSeparator(.hidden)
|
|
.listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0))
|
|
|
|
Section {
|
|
Button {
|
|
withAnimation { relayListExpanded.toggle() }
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
if activeCount + failedCount < total {
|
|
RelayProgressIndicator(active: activeCount, total: total)
|
|
}
|
|
if failedCount > 0 {
|
|
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d failed", comment: "channel creation progress with errors"), activeCount, total, failedCount))
|
|
} else {
|
|
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel creation progress"), activeCount, total))
|
|
}
|
|
Spacer()
|
|
Image(systemName: relayListExpanded ? "chevron.up" : "chevron.down")
|
|
.foregroundColor(theme.colors.secondary)
|
|
}
|
|
}
|
|
.foregroundColor(theme.colors.onBackground)
|
|
|
|
if relayListExpanded {
|
|
ForEach(groupRelays) { relay in
|
|
let failed = relayMemberConnFailed(relay)
|
|
if let err = failed {
|
|
Button {
|
|
showAlert(
|
|
NSLocalizedString("Relay connection failed", comment: "alert title"),
|
|
message: err
|
|
)
|
|
} label: {
|
|
relayRow(relay, connFailed: true)
|
|
}
|
|
.buttonStyle(.plain)
|
|
} else {
|
|
relayRow(relay, connFailed: false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.compactSectionSpacing()
|
|
|
|
Section {
|
|
Button("Channel link") {
|
|
if activeCount >= total {
|
|
showLinkStep = true
|
|
} else if activeCount > 0 {
|
|
let actions: [UIAlertAction] = if activeCount + failedCount < total {
|
|
[
|
|
UIAlertAction(title: NSLocalizedString("Proceed", comment: "alert action"), style: .default) { _ in showLinkStep = true },
|
|
UIAlertAction(title: NSLocalizedString("Wait", comment: "alert action"), style: .cancel) { _ in }
|
|
]
|
|
} else {
|
|
[
|
|
UIAlertAction(title: NSLocalizedString("Proceed", comment: "alert action"), style: .default) { _ in showLinkStep = true },
|
|
cancelAlertAction
|
|
]
|
|
}
|
|
showAlert(
|
|
NSLocalizedString("Not all relays connected", comment: "alert title"),
|
|
message: String.localizedStringWithFormat(NSLocalizedString("Channel will start working with %d of %d relays. Proceed?", comment: "alert message"), activeCount, total),
|
|
actions: { actions }
|
|
)
|
|
}
|
|
}
|
|
.disabled(activeCount == 0)
|
|
}
|
|
}
|
|
.navigationTitle("Creating channel")
|
|
.navigationBarBackButtonHidden(true)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Cancel") { cancelChannelCreation(gInfo) }
|
|
}
|
|
}
|
|
.onChange(of: channelRelaysModel.groupRelays) { relays in
|
|
guard channelRelaysModel.groupId == gInfo.groupId else { return }
|
|
groupRelays = relays.sorted { relayDisplayName($0) < relayDisplayName($1) }
|
|
if relays.allSatisfy({ $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }) {
|
|
showLinkStep = true
|
|
channelRelaysModel.reset()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func relayMemberConnFailed(_ relay: GroupRelay) -> String? {
|
|
m.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })?
|
|
.wrapped.activeConn?.connFailedErr
|
|
}
|
|
|
|
private func relayRow(_ relay: GroupRelay, connFailed: Bool) -> some View {
|
|
HStack {
|
|
Text(relayDisplayName(relay))
|
|
Spacer()
|
|
relayStatusIndicator(relay.relayStatus, connFailed: connFailed)
|
|
}
|
|
}
|
|
|
|
// MARK: - Step 3: Link
|
|
|
|
private func linkStepView(_ gInfo: GroupInfo) -> some View {
|
|
GroupLinkView(
|
|
groupId: gInfo.groupId,
|
|
groupLink: $groupLink,
|
|
groupLinkMemberRole: Binding.constant(.observer), // TODO [relays] starting role should be communicated in protocol from owner to relays
|
|
showTitle: false,
|
|
creatingGroup: true,
|
|
isChannel: true
|
|
) {
|
|
m.creatingChannelId = nil
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
dismissAllSheets(animated: true) {
|
|
ItemsModel.shared.loadOpenChat(gInfo.id)
|
|
}
|
|
}
|
|
}
|
|
.navigationBarTitle("Channel link")
|
|
}
|
|
|
|
private func cancelChannelCreation(_ gInfo: GroupInfo) {
|
|
m.creatingChannelId = nil
|
|
channelRelaysModel.reset()
|
|
dismissAllSheets(animated: true)
|
|
Task {
|
|
do {
|
|
try await apiDeleteChat(type: .group, id: gInfo.apiId)
|
|
await MainActor.run { m.removeChat(gInfo.id) }
|
|
} catch {
|
|
logger.error("cancelChannelCreation error: \(responseError(error))")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func showInvalidChannelNameAlert() {
|
|
let validName = mkValidName(profile.displayName)
|
|
if validName == "" {
|
|
showAlert(NSLocalizedString("Invalid name!", comment: "alert title"))
|
|
} else {
|
|
showAlert(
|
|
NSLocalizedString("Invalid name!", comment: "alert title"),
|
|
message: String.localizedStringWithFormat(NSLocalizedString("Correct name to %@?", comment: "alert message"), validName),
|
|
actions: {[
|
|
UIAlertAction(title: NSLocalizedString("Ok", comment: "alert action"), style: .default) { _ in
|
|
profile.displayName = validName
|
|
},
|
|
cancelAlertAction
|
|
]}
|
|
)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
func relayDisplayName(_ relay: GroupRelay) -> String {
|
|
if !relay.userChatRelay.displayName.isEmpty { return relay.userChatRelay.displayName }
|
|
if let domain = relay.userChatRelay.domains.first { return domain }
|
|
if let link = relay.relayLink { return hostFromRelayLink(link) }
|
|
return "relay \(relay.groupRelayId)"
|
|
}
|
|
|
|
func relayStatusIndicator(_ status: RelayStatus, connFailed: Bool = false) -> some View {
|
|
let color: Color = connFailed ? .red : (status == .rsActive ? .green : .yellow)
|
|
let text: LocalizedStringKey = connFailed ? "failed" : status.text
|
|
return HStack(spacing: 4) {
|
|
Circle()
|
|
.fill(color)
|
|
.frame(width: 8, height: 8)
|
|
Text(text)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
if connFailed {
|
|
Image(systemName: "exclamationmark.circle")
|
|
.foregroundColor(.accentColor)
|
|
.font(.caption)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct RelayProgressIndicator: View {
|
|
var active: Int
|
|
var total: Int
|
|
|
|
var body: some View {
|
|
if active == 0 {
|
|
ProgressView()
|
|
.frame(width: 20, height: 20)
|
|
} else {
|
|
ZStack {
|
|
Circle()
|
|
.stroke(Color(uiColor: .tertiaryLabel), style: StrokeStyle(lineWidth: 2.5))
|
|
Circle()
|
|
.trim(from: 0, to: Double(active) / Double(max(total, 1)))
|
|
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
|
|
.rotationEffect(.degrees(-90))
|
|
}
|
|
.frame(width: 20, height: 20)
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
AddChannelView()
|
|
}
|