Files
simplex-chat/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
T
503d091ec4 android, desktop, ios: show chat name in delete / leave / clear confirmation dialogs (#7021)
* android, desktop, ios: show chat name in delete / leave / clear confirmation dialogs

The dialogs confirming delete contact, delete/leave group/channel and
clear chat now show the chat's name on its own line above the existing
warning, so the user can see which chat the destructive action will
affect.

Pure code change: no new translation strings, no signature changes, no
new helpers. The name is read via existing displayName accessors on
GroupInfo / Contact / ChatInfo.

clearNoteFolderDialog is intentionally unchanged - the notes folder is a
single-instance object and its existing warning already identifies it.

* android, desktop, ios: also show chat name when deleting pending connection

deleteContactConnectionAlert was missed in the original inventory.
Same pattern as the other dispatchers - prepend the connection's
displayName on its own line above the existing warning - so a user
who set a custom name on a pending connection can see which one
they are about to delete.

* android: use <br> instead of \n for newline in delete confirmation dialog body

On Android, the alert body goes through HtmlCompat.fromHtml which
treats the input as HTML and collapses literal \n to a single space -
so "Tech Talk\n\nGroup will be deleted..." rendered as
"Tech Talk Group will be deleted...". Switch to <br><br>, which both
HtmlCompat (Android) and the Desktop parser at Utils.desktop.kt:75
correctly render as a newline.

* android, desktop: skip HTML parsing for delete confirmation dialog text

Add parseHtml: Boolean = true parameter to showAlertDialog and
showAlertDialogButtonsColumn; when false, the body text is wrapped as
AnnotatedString and routed through the existing AnnotatedString
AlertContent overload, bypassing escapedHtmlToAnnotatedString entirely.

The 8 dispatchers that embed the user-controlled chat displayName now
opt out (parseHtml = false). This means:
 - displayName is rendered as literal text - no HTML interpretation,
   so a contact whose alias is "<b>X</b>" or "<font color=..." no
   longer renders bold or coloured in the confirmation dialog
 - the platforms align: both iOS and Kotlin now use plain "\n\n" for
   the separator (no more <br><br> Kotlin-only workaround)
 - removes coupling to escapedHtmlToAnnotatedString in this path

* android, desktop: shorten parseHtml comment in AlertManager

* remove extra text

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2026-06-08 12:13:06 +01:00

1177 lines
49 KiB
Swift

//
// GroupChatInfoView.swift
// SimpleX (iOS)
//
// Created by JRoberts on 14.07.2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
// Spec: spec/client/chat-view.md
import SwiftUI
import SimpleXChat
let SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
// Spec: spec/client/chat-view.md#GroupChatInfoView
struct GroupChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat
@Binding var groupInfo: GroupInfo
@Binding var scrollToItemId: ChatItem.ID?
@Binding var composeState: ComposeState
var onSearch: () -> Void
@State var localAlias: String
@State private var showSharePicker = false
@FocusState private var aliasTextFieldFocused: Bool
@State private var alert: GroupChatInfoViewAlert? = nil
@State private var groupLink: GroupLink?
@State private var groupLinkMemberRole: GroupMemberRole = .member
@State private var groupLinkNavLinkActive: Bool = false
@State private var addMembersNavLinkActive: Bool = false
@State private var connectionStats: ConnectionStats?
@State private var connectionCode: String?
@State private var sendReceipts = SendReceipts.userDefault(true)
@State private var sendReceiptsUserDefault = true
@State private var progressIndicator = false
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State private var searchText: String = ""
@FocusState private var searchFocussed
@State private var showSecrets: Set<Int> = []
enum GroupChatInfoViewAlert: Identifiable {
case deleteGroupAlert
case clearChatAlert
case leaveGroupAlert
case cantInviteIncognitoAlert
case largeGroupReceiptsDisabled
case blockMemberAlert(mem: GroupMember)
case unblockMemberAlert(mem: GroupMember)
case blockForAllAlert(mem: GroupMember)
case unblockForAllAlert(mem: GroupMember)
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
switch self {
case .deleteGroupAlert: return "deleteGroupAlert"
case .clearChatAlert: return "clearChatAlert"
case .leaveGroupAlert: return "leaveGroupAlert"
case .cantInviteIncognitoAlert: return "cantInviteIncognitoAlert"
case .largeGroupReceiptsDisabled: return "largeGroupReceiptsDisabled"
case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)"
case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)"
case let .blockForAllAlert(mem): return "blockForAllAlert \(mem.groupMemberId)"
case let .unblockForAllAlert(mem): return "unblockForAllAlert \(mem.groupMemberId)"
case let .error(title, _): return "error \(title)"
}
}
}
var body: some View {
NavigationView {
let members = chatModel.groupMembers
.filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved }
.sorted { $0.wrapped.memberRole > $1.wrapped.memberRole }
ZStack {
List {
groupInfoHeader()
.listRowBackground(Color.clear)
localAliasTextEdit()
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.padding(.bottom, 18)
infoActionButtons()
.padding(.horizontal)
.frame(maxWidth: .infinity)
.frame(height: infoViewActionButtonHeight)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
if groupInfo.useRelays && groupInfo.membership.memberIncognito {
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
HStack {
Text("Your random profile")
Spacer()
Text(groupInfo.membership.chatViewName)
.foregroundStyle(.indigo)
}
}
}
let showUserSupportChat = groupInfo.membership.memberActive
&& ((groupInfo.fullGroupPreferences.support.on && groupInfo.membership.memberRole < .moderator)
|| groupInfo.membership.supportChat != nil)
if groupInfo.useRelays {
Section {
// TODO [relays] allow other owners to manage channel link (requires protocol changes to share link ownership)
if groupInfo.isOwner && groupLink != nil {
channelLinkButton()
} else if let link = groupInfo.groupProfile.publicGroup?.groupLink {
SimpleXLinkQRCode(uri: link)
Button {
showShareSheet(items: [simplexChatLink(link)])
} label: {
Label("Share link", systemImage: "square.and.arrow.up")
}
Button {
showSharePicker = true
} label: {
Label("Share via chat", systemImage: "arrowshape.turn.up.forward")
}
}
if groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole >= .owner }) {
channelMembersButton()
}
if groupInfo.membership.memberRole >= .moderator {
memberSupportButton()
}
if showUserSupportChat {
UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
}
} footer: {
if !groupInfo.isOwner && groupInfo.groupProfile.publicGroup?.groupLink != nil {
Text("You can share a link or a QR code - anybody will be able to join the channel.")
.foregroundColor(theme.colors.secondary)
}
}
} else {
Section {
if groupInfo.canAddMembers && groupInfo.businessChat == nil {
groupLinkButton()
}
if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator {
memberSupportButton()
}
if groupInfo.canModerate {
GroupReportsChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
}
if showUserSupportChat {
UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
}
} header: {
Text("")
}
}
Section {
if groupInfo.isOwner && groupInfo.businessChat == nil {
editGroupButton()
}
if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) {
addOrEditWelcomeMessage()
}
GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences)
} footer: {
let label: LocalizedStringKey = (
groupInfo.useRelays
? "Only channel owners can change channel preferences."
: groupInfo.businessChat == nil
? "Only group owners can change group preferences."
: "Only chat owners can change preferences."
)
Text(label)
.foregroundColor(theme.colors.secondary)
}
Section {
if !groupInfo.useRelays {
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
sendReceiptsOption()
} else {
sendReceiptsOptionDisabled()
}
}
NavigationLink {
ChatWallpaperEditorSheet(chat: chat)
} label: {
Label("Chat theme", systemImage: "photo")
}
ChatTTLOption(chat: chat, progressIndicator: $progressIndicator)
} footer: {
Text("Delete chat messages from your device.")
}
if !groupInfo.nextConnectPrepared && !groupInfo.useRelays {
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
if groupInfo.canAddMembers {
if (chat.chatInfo.incognito) {
Label("Invite members", systemImage: "plus")
.foregroundColor(Color(uiColor: .tertiaryLabel))
.onTapGesture { alert = .cantInviteIncognitoAlert }
} else {
addMembersButton()
}
}
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
.padding(.leading, 8)
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
let filteredMembers = s == ""
? members
: members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
MemberRowView(
chat: chat,
groupInfo: groupInfo,
groupMember: GMember(groupInfo.membership),
scrollToItemId: $scrollToItemId,
user: true,
alert: $alert
)
ForEach(filteredMembers) { member in
MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: member, scrollToItemId: $scrollToItemId, alert: $alert)
}
}
}
Section {
if groupInfo.useRelays && (groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole == .relay })) {
channelRelaysButton()
}
clearChatButton()
if groupInfo.canDelete {
deleteGroupButton()
}
if groupInfo.membership.memberCurrentOrPending {
if !groupInfo.useRelays || !groupInfo.isOwner
|| members.contains(where: { $0.wrapped.memberRole == .owner && $0.wrapped.groupMemberId != groupInfo.membership.groupMemberId }) {
leaveGroupButton()
}
}
}
if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", chat.chatInfo.localDisplayName)
infoRow("Database ID", "\(chat.chatInfo.apiId)")
}
}
}
.modifier(ThemedBackground(grouped: true))
.navigationBarHidden(true)
.disabled(progressIndicator)
.opacity(progressIndicator ? 0.6 : 1)
if progressIndicator {
ProgressView().scaleEffect(2)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.sheet(isPresented: $showSharePicker) {
shareChannelPicker(groupInfo: groupInfo, composeState: $composeState)
}
.alert(item: $alert) { alertItem in
switch(alertItem) {
case .deleteGroupAlert: return deleteGroupAlert()
case .clearChatAlert: return clearChatAlert()
case .leaveGroupAlert: return leaveGroupAlert()
case .cantInviteIncognitoAlert: return cantInviteIncognitoAlert()
case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert()
case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem)
case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem)
case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem)
case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem)
case let .error(title, error): return mkAlert(title: title, message: error)
}
}
.onAppear {
if let currentUser = chatModel.currentUser {
sendReceiptsUserDefault = currentUser.sendRcptsSmallGroups
}
sendReceipts = SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
if !groupInfo.useRelays || groupInfo.isOwner {
do {
if let gLink = try apiGetGroupLink(groupInfo.groupId) {
groupLink = gLink
groupLinkMemberRole = gLink.acceptMemberRole
}
} catch let error {
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
}
}
}
}
private func groupInfoHeader() -> some View {
VStack {
let cInfo = chat.chatInfo
// show actual display name, alias can be edited in this view
let displayName = (cInfo.groupInfo?.groupProfile.displayName ?? cInfo.displayName).trimmingCharacters(in: .whitespacesAndNewlines)
let fullName = cInfo.fullName.trimmingCharacters(in: .whitespacesAndNewlines)
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill))
.padding(.top, 12)
.padding()
Text(displayName)
.font(.largeTitle)
.multilineTextAlignment(.center)
.lineLimit(4)
.padding(.bottom, 2)
if fullName != "" && fullName != displayName && fullName != cInfo.displayName.trimmingCharacters(in: .whitespacesAndNewlines) {
Text(cInfo.fullName)
.font(.title2)
.multilineTextAlignment(.center)
.lineLimit(3)
.padding(.bottom, 2)
}
if let descr = cInfo.shortDescr?.trimmingCharacters(in: .whitespacesAndNewlines), descr != "" {
let r = markdownText(descr, textStyle: .subheadline, showSecrets: showSecrets, backgroundColor: theme.colors.background)
msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets, centered: true, smallFont: true)
.multilineTextAlignment(.center)
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
}
if let webPage = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupWebPage,
let url = URL(string: webPage) {
Link(destination: url) {
Text(webPage)
.font(.subheadline)
.lineLimit(1)
.truncationMode(.tail)
}
}
if groupInfo.useRelays,
let count = groupInfo.groupSummary.publicMemberCount,
count > 0 {
Text(subscriberCountStr(count))
.font(.subheadline)
.foregroundColor(theme.colors.secondary)
.padding(.bottom, 2)
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
private func localAliasTextEdit() -> some View {
TextField("Set chat name…", text: $localAlias)
.disableAutocorrection(true)
.focused($aliasTextFieldFocused)
.submitLabel(.done)
.onChange(of: aliasTextFieldFocused) { focused in
if !focused {
setGroupAlias()
}
}
.onSubmit {
setGroupAlias()
}
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondary)
}
private func setGroupAlias() {
Task {
do {
if let gInfo = try await apiSetGroupAlias(groupId: chat.chatInfo.apiId, localAlias: localAlias) {
await MainActor.run {
chatModel.updateGroup(gInfo)
}
}
} catch {
logger.error("setGroupAlias error: \(responseError(error))")
}
}
}
func infoActionButtons() -> some View {
GeometryReader { g in
let buttonWidth = g.size.width / 4
HStack(alignment: .center, spacing: 8) {
searchButton(width: buttonWidth)
if groupInfo.useRelays && groupInfo.isOwner {
channelLinkActionButton(width: buttonWidth)
} else if !groupInfo.useRelays && groupInfo.canAddMembers {
addMembersActionButton(width: buttonWidth)
}
if let nextNtfMode = chat.chatInfo.nextNtfMode {
muteButton(width: buttonWidth, nextNtfMode: nextNtfMode)
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
}
private func searchButton(width: CGFloat) -> some View {
InfoViewButton(image: "magnifyingglass", title: "search", width: width) {
dismiss()
onSearch()
}
.disabled(!groupInfo.ready || chat.chatItems.isEmpty)
}
private func addMembersActionButton(width: CGFloat) -> some View {
ZStack {
if chat.chatInfo.incognito {
InfoViewButton(image: "link.badge.plus", title: "invite", width: width) {
groupLinkNavLinkActive = true
}
NavigationLink(isActive: $groupLinkNavLinkActive) {
groupLinkDestinationView()
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
} else {
InfoViewButton(image: "person.fill.badge.plus", title: "invite", width: width) {
addMembersNavLinkActive = true
}
NavigationLink(isActive: $addMembersNavLinkActive) {
addMembersDestinationView()
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
}
.disabled(!groupInfo.ready)
}
private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View {
return InfoViewButton(
image: nextNtfMode.iconFilled,
title: "\(nextNtfMode.text(mentions: true))",
width: width
) {
toggleNotifications(chat, enableNtfs: nextNtfMode)
}
.disabled(!groupInfo.ready)
}
private func channelLinkActionButton(width: CGFloat) -> some View {
ZStack {
InfoViewButton(image: "link", title: "link", width: width) {
groupLinkNavLinkActive = true
}
NavigationLink(isActive: $groupLinkNavLinkActive) {
groupLinkDestinationView()
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
.disabled(!groupInfo.ready)
}
private func addMembersButton() -> some View {
let label: LocalizedStringKey = switch groupInfo.businessChat?.chatType {
case .customer: "Add team members"
case .business: "Add friends"
case .none: "Invite members"
}
return NavigationLink {
addMembersDestinationView()
} label: {
Label(label, systemImage: "plus")
}
}
private func addMembersDestinationView() -> some View {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
.onAppear {
searchFocussed = false
Task {
await chatModel.loadGroupMembers(groupInfo)
}
}
}
private struct MemberRowView: View {
var chat: Chat
var groupInfo: GroupInfo
@ObservedObject var groupMember: GMember
@Binding var scrollToItemId: ChatItem.ID?
@EnvironmentObject var theme: AppTheme
var user: Bool = false
@Binding var alert: GroupChatInfoViewAlert?
var body: some View {
let member = groupMember.wrapped
let v1 = HStack{
MemberProfileImage(member, size: 38)
.padding(.trailing, 2)
// TODO server connection status
VStack(alignment: .leading) {
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground)
(member.verified ? memberVerifiedShield + t : t)
.lineLimit(1)
(user ? Text ("you: ") + Text(member.memberStatus.shortText) : Text(memberConnStatus(member)))
.lineLimit(1)
.font(.caption)
.foregroundColor(theme.colors.secondary)
}
Spacer()
memberInfo(member)
}
let v = ZStack {
if user {
v1
} else {
NavigationLink {
memberInfoView()
} label: {
EmptyView()
}
.opacity(0)
v1
}
}
if user {
v
} else if groupInfo.membership.memberRole >= .moderator {
// TODO if there are more actions, refactor with lists of swipeActions
let canBlockForAll = member.canBlockForAll(groupInfo: groupInfo)
let canRemove = member.canBeRemoved(groupInfo: groupInfo)
if canBlockForAll && canRemove {
removeSwipe(member, blockForAllSwipe(member, v))
} else if canBlockForAll {
blockForAllSwipe(member, v)
} else if canRemove {
removeSwipe(member, v)
} else {
v
}
} else {
if !member.blockedByAdmin {
blockSwipe(member, v)
} else {
v
}
}
}
private func memberInfoView() -> some View {
GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember, scrollToItemId: $scrollToItemId)
.navigationBarHidden(false)
}
private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey {
if case .failed = member.activeConn?.connStatus {
return "failed"
} else if member.activeConn?.connDisabled ?? false {
return "disabled"
} else if member.activeConn?.connInactive ?? false {
return "inactive"
} else {
return member.memberStatus.shortText
}
}
@ViewBuilder private func memberInfo(_ member: GroupMember) -> some View {
if member.blocked {
Text("blocked")
.foregroundColor(theme.colors.secondary)
} else {
let role = member.memberRole
if [.owner, .admin, .moderator, .observer].contains(role) {
Text(member.memberRole.text)
.foregroundColor(theme.colors.secondary)
}
}
}
private func blockSwipe<V: View>(_ member: GroupMember, _ v: V) -> some View {
v.swipeActions(edge: .leading) {
if member.memberSettings.showMessages {
Button {
alert = .blockMemberAlert(mem: member)
} label: {
Label("Block member", systemImage: "hand.raised").foregroundColor(theme.colors.secondary)
}
} else {
Button {
alert = .unblockMemberAlert(mem: member)
} label: {
Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(theme.colors.primary)
}
}
}
}
private func blockForAllSwipe<V: View>(_ member: GroupMember, _ v: V) -> some View {
v.swipeActions(edge: .leading) {
if member.blockedByAdmin {
Button {
alert = .unblockForAllAlert(mem: member)
} label: {
Label("Unblock for all", systemImage: "hand.raised.slash").foregroundColor(theme.colors.primary)
}
} else {
Button {
alert = .blockForAllAlert(mem: member)
} label: {
Label("Block for all", systemImage: "hand.raised").foregroundColor(theme.colors.secondary)
}
}
}
}
private func removeSwipe<V: View>(_ member: GroupMember, _ v: V) -> some View {
v.swipeActions(edge: .trailing) {
Button(role: .destructive) {
showRemoveMemberAlert(groupInfo, member)
} label: {
Label("Remove member", systemImage: "trash")
.foregroundColor(Color.red)
}
}
}
private var memberVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + textSpace)
.font(.caption)
.baselineOffset(2)
.kerning(-2)
.foregroundColor(theme.colors.secondary)
}
}
private func groupLinkButton() -> some View {
NavigationLink {
groupLinkDestinationView()
} label: {
if groupLink == nil {
Label("Create group link", systemImage: "link.badge.plus")
} else {
Label("Group link", systemImage: "link")
}
}
}
private func channelLinkButton() -> some View {
NavigationLink {
groupLinkDestinationView()
} label: {
Label("Channel link", systemImage: "link")
}
}
private func groupLinkDestinationView() -> some View {
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: false,
creatingGroup: false,
isChannel: groupInfo.useRelays,
groupInfo: groupInfo,
composeState: $composeState
)
.navigationBarTitle(groupInfo.useRelays ? "Channel link" : "Group link")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
}
private func channelMembersButton() -> some View {
let label: LocalizedStringKey = groupInfo.isOwner ? "Subscribers" : "Owners"
return NavigationLink {
ChannelMembersView(chat: chat, groupInfo: groupInfo)
.navigationTitle(label)
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
Label(label, systemImage: "person.2")
}
}
private func channelRelaysButton() -> some View {
NavigationLink {
ChannelRelaysView(chat: chat, groupInfo: groupInfo)
.navigationTitle("Chat relays")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Chat relays", systemImage: "externaldrive.connected.to.line.below")
}
}
struct UserSupportChatNavLink: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var groupInfo: GroupInfo
@EnvironmentObject var chatModel: ChatModel
@Binding var scrollToItemId: ChatItem.ID?
@State private var navLinkActive = false
var body: some View {
let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil)
NavigationLink(isActive: $navLinkActive) {
SecondaryChatView(
chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()),
scrollToItemId: $scrollToItemId
)
} label: {
HStack {
Label("Chat with admins", systemImage: chat.supportUnreadCount > 0 ? "flag.fill" : "flag")
Spacer()
if chat.supportUnreadCount > 0 {
UnreadBadge(count: chat.supportUnreadCount, color: theme.colors.primary)
}
}
}
.onChange(of: navLinkActive) { active in
if active {
ItemsModel.loadSecondaryChat(groupInfo.id, chatFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo))
}
}
}
}
private func memberSupportButton() -> some View {
NavigationLink {
MemberSupportView(groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
.navigationBarTitle("Chats with members")
.modifier(ThemedBackground())
.navigationBarTitleDisplayMode(.large)
} label: {
HStack {
Label(
"Chats with members",
systemImage: chat.supportUnreadCount > 0 ? "flag.fill" : "flag"
)
Spacer()
if chat.supportUnreadCount > 0 {
UnreadBadge(count: chat.supportUnreadCount, color: theme.colors.primary)
}
}
}
}
struct GroupReportsChatNavLink: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var groupInfo: GroupInfo
@EnvironmentObject var chatModel: ChatModel
@Binding var scrollToItemId: ChatItem.ID?
@State private var navLinkActive = false
var body: some View {
NavigationLink(isActive: $navLinkActive) {
SecondaryChatView(
chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: .reports), chatItems: [], chatStats: ChatStats()),
scrollToItemId: $scrollToItemId
)
} label: {
HStack {
Label {
Text("Member reports")
} icon: {
Image(systemName: chat.chatStats.reportsCount > 0 ? "flag.fill" : "flag").foregroundColor(.red)
}
Spacer()
if chat.chatStats.reportsCount > 0 {
UnreadBadge(count: chat.chatStats.reportsCount, color: .red)
}
}
}
.onChange(of: navLinkActive) { active in
if active {
ItemsModel.loadSecondaryChat(chat.id, chatFilter: .msgContentTagContext(contentTag: .report))
}
}
}
}
private func editGroupButton() -> some View {
NavigationLink {
GroupProfileView(
groupInfo: $groupInfo,
groupProfile: groupInfo.groupProfile
)
} label: {
Label(groupInfo.useRelays ? "Edit channel profile" : "Edit group profile", systemImage: "pencil")
}
}
private func addOrEditWelcomeMessage() -> some View {
NavigationLink {
GroupWelcomeView(
groupInfo: $groupInfo,
groupProfile: groupInfo.groupProfile,
welcomeText: groupInfo.groupProfile.description ?? ""
)
.navigationTitle("Welcome message")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
groupInfo.groupProfile.description == nil
? Label("Add welcome message", systemImage: "plus.message")
: Label("Welcome message", systemImage: "message")
}
}
@ViewBuilder private func deleteGroupButton() -> some View {
let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel" : groupInfo.businessChat == nil ? "Delete group" : "Delete chat"
Button(role: .destructive) {
alert = .deleteGroupAlert
} label: {
Label(label, systemImage: "trash")
.foregroundColor(Color.red)
}
}
private func clearChatButton() -> some View {
Button() {
alert = .clearChatAlert
} label: {
Label("Clear conversation", systemImage: "gobackward")
.foregroundColor(Color.orange)
}
}
private func leaveGroupButton() -> some View {
let label: LocalizedStringKey = groupInfo.useRelays ? "Leave channel" : groupInfo.businessChat == nil ? "Leave group" : "Leave chat"
return Button(role: .destructive) {
alert = .leaveGroupAlert
} label: {
Label(label, systemImage: "rectangle.portrait.and.arrow.right")
.foregroundColor(Color.red)
}
}
// TODO reuse this and clearChatAlert with ChatInfoView
private func deleteGroupAlert() -> Alert {
let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel?" : groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?"
return Alert(
title: Text(label),
message: Text(chat.chatInfo.displayName + "\n\n") + deleteGroupAlertMessage(groupInfo),
primaryButton: .destructive(Text("Delete")) {
Task {
do {
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId)
await MainActor.run {
dismiss()
chatModel.chatId = nil
chatModel.removeChat(chat.chatInfo.id)
}
} catch let error {
logger.error("deleteGroupAlert apiDeleteChat error: \(error.localizedDescription)")
}
}
},
secondaryButton: .cancel()
)
}
private func clearChatAlert() -> Alert {
Alert(
title: Text("Clear conversation?"),
message: Text(chat.chatInfo.displayName + "\n\n") + Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."),
primaryButton: .destructive(Text("Clear")) {
Task {
await clearChat(chat)
await MainActor.run { dismiss() }
}
},
secondaryButton: .cancel()
)
}
private func leaveGroupAlert() -> Alert {
let titleLabel: LocalizedStringKey = groupInfo.useRelays ? "Leave channel?" : groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?"
let messageLabel: LocalizedStringKey = (
groupInfo.useRelays
? "You will stop receiving messages from this channel. Chat history will be preserved."
: groupInfo.businessChat == nil
? "You will stop receiving messages from this group. Chat history will be preserved."
: "You will stop receiving messages from this chat. Chat history will be preserved."
)
return Alert(
title: Text(titleLabel),
message: Text(chat.chatInfo.displayName + "\n\n") + Text(messageLabel),
primaryButton: .destructive(Text("Leave")) {
Task {
await leaveGroup(chat.chatInfo.apiId)
await MainActor.run { dismiss() }
}
},
secondaryButton: .cancel()
)
}
private func sendReceiptsOption() -> some View {
WrappedPicker(selection: $sendReceipts) {
ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in
Text(opt.text)
}
} label: {
Label("Send receipts", systemImage: "checkmark.message")
}
.onChange(of: sendReceipts) { _ in
setSendReceipts()
}
}
private func setSendReceipts() {
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
chatSettings.sendRcpts = sendReceipts.bool()
updateChatSettings(chat, chatSettings: chatSettings)
}
private func sendReceiptsOptionDisabled() -> some View {
HStack {
Label("Send receipts", systemImage: "checkmark.message")
Spacer()
Text("disabled")
.foregroundStyle(.secondary)
}
.onTapGesture {
alert = .largeGroupReceiptsDisabled
}
}
}
func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) {
if mem.memberRole == .relay {
let isLastActive = groupInfo.useRelays && mem.memberCurrent && {
let activeRelays = ChatModel.shared.groupMembers.filter { $0.wrapped.memberRole == .relay && $0.wrapped.memberCurrent }
return activeRelays.count <= 1
}()
showAlert(
NSLocalizedString("Remove relay?", comment: "alert title"),
message: isLastActive
? NSLocalizedString("This is the last active relay. Removing it will prevent message delivery to subscribers.", comment: "alert message")
: NSLocalizedString("Relay will be removed from channel - this cannot be undone!", comment: "alert message"),
actions: {[
UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
},
cancelAlertAction
]}
)
} else if groupInfo.useRelays {
showAlert(
NSLocalizedString("Remove subscriber?", comment: "alert title"),
message: NSLocalizedString("Subscriber will be removed from channel - this cannot be undone!", comment: "alert message"),
actions: {[
UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
},
UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in
removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss)
},
cancelAlertAction
]}
)
} else {
showAlert(
NSLocalizedString("Remove member?", comment: "alert title"),
message: groupInfo.businessChat == nil
? NSLocalizedString("Member will be removed from group - this cannot be undone!", comment: "alert message")
: NSLocalizedString("Member will be removed from chat - this cannot be undone!", comment: "alert message"),
actions: {[
UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
},
UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in
removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss)
},
cancelAlertAction
]}
)
}
}
func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, withMessages: Bool, dismiss: DismissAction?) {
Task {
do {
let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId], withMessages)
await MainActor.run {
ChatModel.shared.updateGroup(updatedGroupInfo)
updatedMembers.forEach { updatedMember in
_ = ChatModel.shared.upsertGroupMember(updatedGroupInfo, updatedMember)
if withMessages {
ChatModel.shared.removeMemberItems(updatedMember, byMember: groupInfo.membership, groupInfo)
}
}
dismiss?()
}
} catch let error {
logger.error("apiRemoveMembers error: \(responseError(error))")
await MainActor.run {
showAlert(
NSLocalizedString("Error removing member", comment: "alert title"),
message: responseError(error)
)
}
}
}
}
func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text {
groupInfo.useRelays ? (
groupInfo.membership.memberCurrent
? Text("Channel will be deleted for all subscribers - this cannot be undone!")
: Text("Channel will be deleted for you - this cannot be undone!")
) : groupInfo.businessChat == nil ? (
groupInfo.membership.memberCurrent
? Text("Group will be deleted for all members - this cannot be undone!")
: Text("Group will be deleted for you - this cannot be undone!")
) : (
groupInfo.membership.memberCurrent
? Text("Chat will be deleted for all members - this cannot be undone!")
: Text("Chat will be deleted for you - this cannot be undone!")
)
}
struct GroupPreferencesButton: View {
@Binding var groupInfo: GroupInfo
@State var preferences: FullGroupPreferences
@State var currentPreferences: FullGroupPreferences
var creatingGroup: Bool = false
private var label: LocalizedStringKey {
groupInfo.useRelays ? "Channel preferences"
: groupInfo.businessChat == nil ? "Group preferences"
: "Chat preferences"
}
var body: some View {
NavigationLink {
GroupPreferencesView(
groupInfo: $groupInfo,
preferences: $preferences,
currentPreferences: currentPreferences,
creatingGroup: creatingGroup,
savePreferences: savePreferences
)
.navigationBarTitle(label)
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
.onDisappear {
let saveText = NSLocalizedString(
creatingGroup ? "Save"
: groupInfo.useRelays ? "Save and notify subscribers"
: "Save and notify group members",
comment: "alert button"
)
if groupInfo.fullGroupPreferences != preferences {
showAlert(
title: NSLocalizedString("Save preferences?", comment: "alert title"),
buttonTitle: saveText,
buttonAction: { savePreferences() },
cancelButton: true
)
}
}
} label: {
if creatingGroup {
Text("Set group preferences")
} else {
Label(label, systemImage: "switch.2")
}
}
}
private func savePreferences() {
Task {
do {
var gp = groupInfo.groupProfile
gp.groupPreferences = toGroupPreferences(preferences)
let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp)
await MainActor.run {
groupInfo = gInfo
ChatModel.shared.updateGroup(gInfo)
currentPreferences = preferences
}
} catch {
logger.error("GroupPreferencesView apiUpdateGroup error: \(responseError(error))")
}
}
}
}
func cantInviteIncognitoAlert() -> Alert {
Alert(
title: Text("Can't invite contacts!"),
message: Text("You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed")
)
}
func largeGroupReceiptsDisabledAlert() -> Alert {
Alert(
title: Text("Receipts are disabled"),
message: Text("This group has over \(SMALL_GROUPS_RCPS_MEM_LIMIT) members, delivery receipts are not sent.")
)
}
@ViewBuilder
func shareChannelPicker(groupInfo: GroupInfo, composeState: Binding<ComposeState>? = nil) -> some View {
let v = ChatItemForwardingView(
title: "Share channel",
isProhibited: { $0.prohibitedByPref(hasSimplexLink: true, isMediaOrFileAttachment: false, isVoice: false) },
onSelectChat: { chat in shareChatLink(chat, sourceGroupInfo: groupInfo, composeState: composeState) },
includeLocal: false
)
if #available(iOS 16.0, *) {
v.presentationDetents([.fraction(0.8)])
} else {
v
}
}
func shareChatLink(_ destChat: Chat, sourceGroupInfo: GroupInfo, composeState: Binding<ComposeState>? = nil) {
let sendAsGroup = if let gInfo = destChat.chatInfo.groupInfo { gInfo.useRelays && gInfo.membership.memberRole >= .owner } else { false }
Task {
do {
let mc = try await apiShareChatMsgContent(
shareChatType: .group, shareChatId: Int64(sourceGroupInfo.groupId),
toChatType: destChat.chatInfo.chatType, toChatId: destChat.chatInfo.apiId,
toScope: destChat.chatInfo.groupChatScope(), sendAsGroup: sendAsGroup
)
if case let .chat(_, chatLink, ownerSig) = mc {
await MainActor.run {
dismissAllSheets {
let cs = ComposeState(preview: .chatLinkPreview(chatLink: chatLink, ownerSig: ownerSig))
if let composeState {
composeState.wrappedValue = cs
} else {
ChatModel.shared.draft = cs
ChatModel.shared.draftChatId = destChat.id
}
if destChat.id != ChatModel.shared.chatId {
ItemsModel.shared.loadOpenChat(destChat.id)
}
}
}
} else {
logger.error("shareChatLink: unexpected MsgContent: \(String(describing: mc))")
await MainActor.run {
showAlert(NSLocalizedString("Error sharing channel", comment: "alert title"), message: String(describing: mc))
}
}
} catch {
logger.error("shareChatLink error: \(error.localizedDescription)")
await MainActor.run {
showAlert(NSLocalizedString("Error sharing channel", comment: "alert title"), message: error.localizedDescription)
}
}
}
}
struct GroupChatInfoView_Previews: PreviewProvider {
static var previews: some View {
GroupChatInfoView(
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
groupInfo: Binding.constant(GroupInfo.sampleData),
scrollToItemId: Binding.constant(nil),
composeState: Binding.constant(ComposeState()),
onSearch: {},
localAlias: ""
)
}
}