mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-12 02:57:56 +00:00
* core: test that history posted before channel signing reaches later joiners as signature missing, and channel moderator delete for all Confirms the case the owner's new alert describes: a subscriber who joins after the owner turned on "Sign messages" sees the earlier posts as "signature missing" and later ones as signed, while earlier subscribers keep them unmarked. Also covers a non-owner channel moderator deleting a contributor's signed post for everyone. Run: 001-channel-signing-moderators, slice 1 * ios: keep "Chat with admins" off when saving group preferences toGroupPreferences left out the support preference, so the saved profile had none and core filled in its default, on: every channel preference save from iOS silently turned on chats with admins, which have no end-to-end encryption in channels. Android and desktop already pass it. Run: 001-channel-signing-moderators, slice 2 * ui: let channel owners turn on "Sign messages", warning that earlier messages will show signature missing The channel preference was hidden while signing was recipient-only. Owners now see the switch; turning it on asks for confirmation, because subscribers who join later receive the earlier posts unsigned through history and see them as "signature missing". The "Signature missing" explanation now says the message may predate signing or have been forwarded by a relay without its signature, instead of only implying tampering. Run: 001-channel-signing-moderators, slice 3 * ui: channel owners can make subscribers moderators, and moderators see the subscriber list Core already lets a channel owner make a subscriber a moderator; the role picker offered only subscriber and contributor. It now offers moderator too (not admin, by decision). Moderators see the full member list the owner sees, so they can reach the subscribers they may block (subscribers can't post, so there was no other way to reach them), and member info is headed Moderator or Contributor instead of Subscriber for those roles. Run: 001-channel-signing-moderators, slice 4 * ci: long-press and right-click for app tests, safer ui arguments, desktop Kotlin 4 GB, desktop session limits from claude-scope Message menus open only on long-press (iOS) or right-click (desktop), so Claude sessions couldn't reach Moderate, Info or Delete. Ported from f/directory-search unchanged, with its hardening: the guards refuse unquoted glob, brace and tilde expansion, and desktop ui checks numbers and sends one key per call so a key argument can't run an xdotool command. The desktop app job's Kotlin compiler gets 4 GB, as f/directory-search needed after merging master. Desktop sessions take max-turns and max-budget-usd from claude-scope like iOS (60 and 3 when unset, as before), and the Claude job may run 120 minutes. Run: 001-channel-signing-moderators, slice 5 * ci: Claude test plans for channel message signing and channel moderators This branch's iOS device test plan and desktop prompt replace master's smoke tests: the owner creates a channel on the preset relays, turns on Sign messages through its alert, and two more profiles in the same app join by the copied link; the owner makes one a moderator, who then sees the subscriber list, moderates a contributor's message and blocks the contributor. Signatures are checked as text in message Info. One layout, no test backend. Run: 001-channel-signing-moderators, slice 6 * fixup! ui: let channel owners turn on "Sign messages", warning that earlier messages will show signature missing * update plans * fixup! ui: let channel owners turn on "Sign messages", warning that earlier messages will show signature missing * fixup! ci: Claude test plans for channel message signing and channel moderators * fixup! ui: let channel owners turn on "Sign messages", warning that earlier messages will show signature missing * fixup! ci: Claude test plans for channel message signing and channel moderators * fixup! ui: let channel owners turn on "Sign messages", warning that earlier messages will show signature missing * fixup! ci: Claude test plans for channel message signing and channel moderators --------- Co-authored-by: spacedandy-agents-simplex <342056592+spacedandy-agents-simplex@users.noreply.github.com>
1237 lines
52 KiB
Swift
1237 lines
52 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.canModerate || 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("")
|
|
}
|
|
}
|
|
|
|
if groupInfo.useRelays && groupInfo.isOwner && groupLink != nil {
|
|
Section {
|
|
channelSimplexNameButton()
|
|
} header: {
|
|
if groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupDomainClaim?.shortName != nil {
|
|
Text("Channel SimpleX name").foregroundColor(theme.colors.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 groupInfo.useRelays && groupInfo.isOwner {
|
|
Section(header: Text("Advanced options").foregroundColor(theme.colors.secondary)) {
|
|
channelWebAccessButton()
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
groupSimplexNameView(groupInfo) { groupInfo = $0 }
|
|
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)
|
|
NameWithBadge((member.verified ? memberVerifiedShield + t : t), member.nameBadge)
|
|
.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(isChannel: groupInfo.isChannel))
|
|
.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 channelWebAccessButton() -> some View {
|
|
let title: LocalizedStringKey = groupInfo.isChannel ? "Channel webpage" : "Group webpage"
|
|
return NavigationLink {
|
|
ChannelWebAccessView(groupInfo: $groupInfo)
|
|
.navigationBarTitle(title)
|
|
.navigationBarTitleDisplayMode(.large)
|
|
} label: {
|
|
Label(title, systemImage: "globe")
|
|
}
|
|
}
|
|
|
|
private func channelSimplexNameButton() -> some View {
|
|
NavigationLink {
|
|
let domain = if let d = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupDomainClaim?.shortName { "#\(d)" } else { "" }
|
|
SetSimplexDomainView(
|
|
title: "SimpleX name",
|
|
footer: "Let people join via name registered with this channel link.",
|
|
prompt: "#channelname.testing",
|
|
simplexName: domain,
|
|
save: { domain in
|
|
do {
|
|
var access = groupInfo.groupProfile.publicGroup?.publicGroupAccess ?? PublicGroupAccess()
|
|
access.groupDomainClaim = domain.map { SimplexDomainClaim(domain: $0) }
|
|
let gInfo = try await apiSetPublicGroupAccess(groupInfo.groupId, access: access)
|
|
await MainActor.run {
|
|
chatModel.updateGroup(gInfo)
|
|
groupInfo = gInfo
|
|
}
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
)
|
|
} label: {
|
|
if let d = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupDomainClaim?.shortName {
|
|
Label("\(d)", systemImage: "number")
|
|
} else {
|
|
Label("Get SimpleX name (BETA)", systemImage: "number")
|
|
}
|
|
}
|
|
}
|
|
|
|
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.canModerate ? "Subscribers" : "Owners & contributors"
|
|
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: ""
|
|
)
|
|
}
|
|
}
|