diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 61da8dc6c..fe30826ce 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -270,6 +270,26 @@ final class ConversationController { applyCover(cover, to: conversationID) } + /// Re-reads the viewer's permissions for a stored chat. The row keeps the state it was saved with + /// and the stream only carries version changes, so a chat saved before the moderation permissions + /// existed would never show its host the Silence and Ban actions. A failed fetch leaves it as is. + func refreshViewerPermissions(for conversationID: ConversationID) async { + // The transcript and the profile both ask when opened; one fetch serves a visit that + // touches both, and a later visit asks again. + if let last = lastPermissionsRefresh[conversationID], Date.now.timeIntervalSince(last) < 30 { + return + } + lastPermissionsRefresh[conversationID] = .now + guard conversation(withID: conversationID) != nil, + let fetched = try? await fetching.getChat(owner: owner, conversationID: conversationID), + let viewerState = fetched.viewerState else { + return + } + if store.refreshViewerPermissions(from: viewerState, in: conversationID) { + persistConversation(conversationID) + } + } + private func applyCover(_ cover: ProfilePicture, to conversationID: ConversationID) { _ = store.apply(.coverPictureChanged(conversationID: conversationID, coverPicture: cover)) persistConversation(conversationID) @@ -346,6 +366,8 @@ final class ConversationController { @ObservationIgnored private let contactNaming: any DMContactNaming @ObservationIgnored let database: Database @ObservationIgnored let owner: KeyPair + /// When each chat last had its viewer permissions re-read, so a transcript and a profile opened together share one fetch. + @ObservationIgnored private var lastPermissionsRefresh: [ConversationID: Date] = [:] @ObservationIgnored private var startTask: Task? /// The feed load in flight, joined by every caller that arrives before it finishes. @ObservationIgnored private var feedLoadTask: Task? @@ -1959,7 +1981,7 @@ final class ConversationController { case .delete: "Couldn't Delete Message" // Neither of these mutates the transcript, so neither can raise a mutation alert. // Report files straight from its sheet and reports its own outcome there. - case .copy, .reply, .report, .forward: "Something Went Wrong" + case .copy, .reply, .report, .forward, .silence, .remove: "Something Went Wrong" } } } diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index 16dde8eca..6848376ca 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -168,6 +168,10 @@ extension AppRouter { /// The group an invite link admits to, previewed before joining; nil for a link that holds /// no usable secret. The secret prints redacted, and ``payload`` never carries it. case groupInvite(InviteLinkSecret?) + /// The members the host silenced in a group, with Unsilence on each row. + case silencedMembers(ConversationID) + /// The members the host removed from a group, with Unban on each row. + case removedMembers(ConversationID) /// The stack this destination naturally belongs in. Cross-stack /// navigation uses this to know which sheet to present, or which tab @@ -193,7 +197,8 @@ extension AppRouter { case .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup, .newPrivateGroup, .tipConversation, .tipConversationWithKeyboard, .chatMessage, .chatRequest, .userProfile, .chatProfile, .editGroup, .editGroupName, .editGroupPicture, .editGroupCover, - .editGroupDescription, .editGroupBalanceRequirement, .archivedChats, .chatSearch, .requests, .joinRequests, .inviteLinks, .groupMembers, .groupInvite: + .editGroupDescription, .editGroupBalanceRequirement, .archivedChats, .chatSearch, .requests, .joinRequests, .inviteLinks, .groupMembers, .groupInvite, + .silencedMembers, .removedMembers: return .tips } } @@ -269,6 +274,8 @@ extension AppRouter { case .inviteLinks: "inviteLinks" case .groupMembers: "groupMembers" case .groupInvite: "groupInvite" + case .silencedMembers: "silencedMembers" + case .removedMembers: "removedMembers" } } @@ -305,7 +312,9 @@ extension AppRouter { .editGroupDescription(let conversationID), .joinRequests(let conversationID), .inviteLinks(let conversationID), - .groupMembers(let conversationID): + .groupMembers(let conversationID), + .silencedMembers(let conversationID), + .removedMembers(let conversationID): return conversationID.description case .editGroupBalanceRequirement(let conversationID, let role): return "\(conversationID.description) \(role)" diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index 8ecc259b5..0944a12e8 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -262,6 +262,14 @@ struct DestinationView: View { case .groupInvite(let secret): GroupInviteScreen(secret: secret) + case .silencedMembers(let conversationID): + RestrictedUsersScreen(conversationID: conversationID, list: .silenced) + .id(conversationID) + + case .removedMembers(let conversationID): + RestrictedUsersScreen(conversationID: conversationID, list: .removed) + .id(conversationID) + case .tipConversation(let conversationID): // `.id` forces fresh view identity per conversation. ConversationScreen(context: .existing(conversationID)) diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index 90e4d94d6..c2030a0c0 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -680,7 +680,7 @@ extension ChatItem { /// last: it is the rarest row and the only one that never appears on your own message, so the /// menu reads "yours ends with delete, theirs ends with report". nonisolated private static func orderedActions(_ capabilities: Set) -> [MessageCapability] { - [.copy, .reply, .forward, .edit, .delete, .report].filter(capabilities.contains) + [.copy, .reply, .forward, .edit, .delete, .report, .silence, .remove].filter(capabilities.contains) } /// The group lines for a payment in a group chat, or nil when its receiver isn't known; a diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index c629fb603..a26516a8b 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -338,7 +338,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { case .reply: screen?.focusComposer() // Each raises a sheet over the transcript, so the keyboard goes down first. - case .delete, .report, .forward: screen?.dismissKeyboard() + case .delete, .report, .forward, .silence, .remove: screen?.dismissKeyboard() case .copy: break } } diff --git a/Flipcash/Core/Screens/Conversation/ConversationGate.swift b/Flipcash/Core/Screens/Conversation/ConversationGate.swift index dc8ce5618..918264238 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationGate.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationGate.swift @@ -37,6 +37,10 @@ enum ConversationGateRequirement: Equatable { case creator /// A speaker rule this build can't read. Blocks for everyone; the fix is updating the app. case unsupported + /// The group's host silenced the viewer. Not a rule of the chat, so ``conversationGate`` never + /// produces it; ``ConversationGatePresentation/silenced(by:at:)`` lays it over the verdict. + /// There is no action a user can take, so no CTA. + case silenced(ConversationSilenceState) } /// Whether one class of rules is met, and if not, which of them aren't. @@ -167,7 +171,7 @@ func conversationGate( // can fail (never, balance, staff) blocks both, and so does an unmet listener rule. let blocksReactions = !listenerUnmet.isEmpty || speakerUnmet.contains { requirement in switch requirement { - case .creator, .unsupported: return false + case .creator, .unsupported, .silenced: return false case .minimumBalance, .staff, .never: return true } } @@ -345,6 +349,23 @@ enum ConversationGatePresentation: Equatable { } } +extension ConversationGatePresentation { + + /// Lays the viewer's silence over the rules' verdict: a member the host silenced reads and + /// reacts but doesn't post, so an open composer or any other read-only reason becomes the + /// silence pill. A viewer who isn't in the chat yet, or who is refused it, sees no change. + /// + /// Takes `now` rather than reading the clock, because a timed silence lapses with no event + /// and the caller owns the wake-up that re-asks. + func silenced(by silence: ConversationSilenceState?, at now: Date) -> ConversationGatePresentation { + guard let silence, silence.isActive(at: now) else { return self } + switch self { + case .open, .readOnly: return .readOnly(.silenced(silence)) + case .join, .blocked, .undetermined: return self + } + } +} + /// Why a cash link in a chat can't be collected from it. enum CashCollectionBlock: Equatable { /// The viewer reads the chat but hasn't joined it. @@ -393,7 +414,7 @@ func groupRequirementLine(_ headline: ConversationGateRequirement?, mintName: St return "Balance Requirement:\n\(holding)" case .staff: return "This chat is for Flipcash staff" - case .never, .creator, .unsupported: + case .never, .creator, .unsupported, .silenced: return nil case nil: return nil diff --git a/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift b/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift index e5cf67118..c128bd539 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift @@ -94,6 +94,29 @@ struct ConversationGatePanel: View { static let creatorSentence = "Only the creator can send messages" static let unsupportedSentence = "Update Flipcash to send messages" + /// "You're silenced until 6:02 PM" for a timed silence — the time alone when it ends today, + /// otherwise the date and time — or "You can't send messages in this group" for a forever one. + static func silencedSentence( + _ silence: ConversationSilenceState, + now: Date, + calendar: Calendar = .current, + locale: Locale = .current + ) -> String { + switch silence { + case .forever: + return "You can't send messages in this group" + case .until(let end): + return "You can't send messages until \(endText(end, now: now, calendar: calendar, locale: locale))" + } + } + + /// An end time as the time alone when it falls today, otherwise the date and time. + static func endText(_ end: Date, now: Date, calendar: Calendar = .current, locale: Locale = .current) -> String { + let style = Date.FormatStyle(date: .omitted, time: .shortened).locale(locale) + let dated = Date.FormatStyle(date: .abbreviated, time: .shortened).locale(locale) + return calendar.isDate(end, inSameDayAs: now) ? end.formatted(style) : end.formatted(dated) + } + private var card: some View { VStack(spacing: Layout.gap) { if let sentence { @@ -158,6 +181,9 @@ struct ConversationGatePanel: View { return Self.creatorSentence case .unsupported: return Self.unsupportedSentence + case .silenced(let silence): + // Drawn as the pill too. + return Self.silencedSentence(silence, now: .now) } } @@ -198,7 +224,7 @@ struct ConversationGatePanel: View { Button(addFundsTitle(mint: mint), action: onAddFunds) .accessibilityIdentifier("conversation-gate-add-funds") .buttonStyle(.filled) - case .staff, .never, .creator, .unsupported: + case .staff, .never, .creator, .unsupported, .silenced: // Nothing the user can do about being staff, about a chat nobody may post in, or about not being its creator, // so a button here would be a lie. EmptyView() @@ -246,8 +272,8 @@ private extension ConversationGateRequirement { /// Whether the read-only state is drawn as the composer-shaped pill: the ones with no action. var isStaticReadOnly: Bool { switch self { - case .never, .creator, .unsupported: true - case .minimumBalance, .staff: false + case .never, .creator, .unsupported, .silenced: true + case .minimumBalance, .staff: false } } } diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 5a427a905..a5f626ac6 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -83,6 +83,10 @@ struct ConversationScreen: View { /// Whether the encryption explainer opened from the transcript's marker is showing. @State private var isShowingEncryptionInfo = false @State private var messageReport: MessageReportRequest? + @State private var silenceTarget: ModerationTarget? + /// The instant ``gate`` judges a timed silence against. A silence lapses with no event, so + /// ``silenceExpiry`` moves this to the end and the composer returns without reopening the chat. + @State private var silenceClock = Date.now @State private var messageShare: MessageShareRequest? /// Whether `Group: Gate Shown` has gone out for this visit. The destination is keyed by /// conversation ID, so a new push is a new visit; a return from a pushed screen is not. @@ -219,7 +223,12 @@ struct ConversationScreen: View { /// Tapping a face in the gutter opens that person's profile — the same screen the counterpart's /// own card opens in a DM. private func openAuthorProfile(_ userID: UserID) { - router.push(.userProfile(userID, origin: .groupMember)) + router.push(.userProfile(userID, origin: groupMemberOrigin)) + } + + /// Where a profile opened from this transcript says it came from. Only a group has members. + private var groupMemberOrigin: UserProfileOrigin { + conversationID.map(UserProfileOrigin.groupMember) ?? .directMessage } /// Tapping the title opens the counterpart's contact card: their address-book @@ -270,6 +279,16 @@ struct ConversationScreen: View { return awaitingMetadata ? .undetermined : .open } return conversationGatePresentation(gateVerdicts, isMember: conversationController.isMember(of: conversation)) + .silenced(by: conversation.viewerState?.silence, at: silenceClock) + } + + /// The silence the viewer is under, when the host has set one that hasn't lapsed. + private var activeSilence: ConversationSilenceState? { + guard let conversationID, + let silence = conversationController.conversation(withID: conversationID)?.viewerState?.silence, + silence.isActive(at: silenceClock) + else { return nil } + return silence } /// Whether this chat's rules are simply unknown, rather than absent. @@ -394,7 +413,7 @@ struct ConversationScreen: View { switch requirement { case .minimumBalance(let amount, let mint): return balanceShortfall(of: amount, mint: mint, holdings: session, rates: ratesController.cachedRates) - case .staff, .never, .creator, .unsupported: + case .staff, .never, .creator, .unsupported, .silenced: return nil } } @@ -402,7 +421,7 @@ struct ConversationScreen: View { private static func mint(of requirement: ConversationGateRequirement) -> PublicKey? { switch requirement { case .minimumBalance(_, let mint): mint - case .staff, .never, .creator, .unsupported: nil + case .staff, .never, .creator, .unsupported, .silenced: nil } } @@ -657,7 +676,7 @@ struct ConversationScreen: View { router.push(.currencyInfo(mint)) }, openProfile: { userID in - router.push(.userProfile(userID, origin: .groupMember)) + router.push(.userProfile(userID, origin: groupMemberOrigin)) } ) .task { @@ -690,7 +709,7 @@ struct ConversationScreen: View { .sheet(item: $reactorsRequest, onDismiss: { guard let userID = pendingReactorProfile else { return } pendingReactorProfile = nil - router.push(.userProfile(userID, origin: groupConversation == nil ? .directMessage : .groupMember)) + router.push(.userProfile(userID, origin: groupConversation == nil ? .directMessage : groupMemberOrigin)) }) { request in ReactorsSheet(pills: request.pills, model: request.model) { userID in pendingReactorProfile = userID @@ -993,6 +1012,12 @@ struct ConversationScreen: View { guard groupConversation != nil else { return } await sessionContainer.knownAuthors.reload() } + // A group saved before the moderation permissions existed holds them as false, so the host + // would see no Silence or Remove row. The controller fetches once per open, shared with the profile. + .task(id: groupConversation?.id) { + guard let id = groupConversation?.id else { return } + await conversationController.refreshViewerPermissions(for: id) + } // Payment requests change with no event, so poll while the chat is on screen in the // foreground. A return to the foreground re-keys the task, which refetches at once. .task(id: PaymentRequestPoll(conversationID: groupConversation?.id, isActive: scenePhase == .active)) { @@ -1015,6 +1040,17 @@ struct ConversationScreen: View { .onChange(of: groupConversation?.id, initial: true) { _, groupID in mentions = groupID.map { MentionPickerModel(source: sessionContainer.rosterSearch, chatID: $0) } } + .onChange(of: activeSilence) { previous, current in + guard GroupModerationCopy.silenceLifted(from: previous, to: current) else { return } + container.toasts.show(.init(GroupModerationCopy.silenceLiftedToast, systemImage: "bubble.left", duration: .seconds(3))) + } + .task(id: activeSilence) { + silenceClock = .now + guard case .until(let end)? = activeSilence else { return } + try? await Task.sleep(for: .seconds(max(0, end.timeIntervalSinceNow)) + .milliseconds(250)) + guard !Task.isCancelled else { return } + silenceClock = .now + } // Name the senders the chat's roster and the local cache both leave out. Keyed on that set, // so it runs when a page of older messages reveals a sender nothing here can name — and not // again once the fetch has landed them. @@ -1076,6 +1112,7 @@ struct ConversationScreen: View { router.push(.tipConversation(chatID)) } } + .silenceUserSheet($silenceTarget) .fullScreenCover(item: $messageReport) { report in NavigationStack { ReportFlowScreen( @@ -1307,9 +1344,36 @@ struct ConversationScreen: View { case .forward: guard let conversationID else { return } messageShare = MessageShareRequest(id: stableID, url: .messageLink(in: conversationID, messageID: message.id)) + case .silence: + silenceTarget = moderationTarget(for: message) + case .remove: + guard let target = moderationTarget(for: message) else { return } + session.dialogItem = target.removeDialog(moderation: groupModeration(target.conversationID)) { error in + session.dialogItem = .error( + title: "Something Went Wrong", + subtitle: "We were unable to remove \(target.firstName). Please try again" + ) + ErrorReporting.captureError(error, reason: "Failed to remove user") + } } } + /// The sender of `message` as the host's Silence and Remove prompts address them. + private func moderationTarget(for message: ConversationMessage) -> ModerationTarget? { + guard let conversationID, let senderID = message.senderID else { return nil } + let name = coordinator?.attributedName(for: senderID) ?? "" + return ModerationTarget( + conversationID: conversationID, + userID: senderID, + firstName: name.isEmpty ? "Member" : GroupModerationCopy.firstName(of: name), + groupName: title + ) + } + + private func groupModeration(_ conversationID: ConversationID) -> GroupModeration { + GroupModeration(moderation: container.flipClient, owner: session.ownerKeyPair, conversationID: conversationID, didRemove: { [rosters = sessionContainer.groupRosters] in rosters.remove($0, from: conversationID) }) + } + /// The transcript's own `ChatMessage` for a row, by its stable id — what the reaction handlers /// need (pills, `canReact`), as opposed to `handleMessageAction`'s `ConversationMessage` lookup. private func chatMessage(withStableID stableID: String) -> ChatMessage? { @@ -1712,9 +1776,10 @@ struct ConversationScreen: View { await loadTranscript(for: conversationID) } catch { Analytics.groupJoined(error: error, memberCount: memberCount, gated: gated) + let banned: Bool = { if case ErrorJoinChat.banned = error { return true } else { return false } }() session.dialogItem = DialogItem.alert( - title: "Couldn't Join Chat", - subtitle: joinFailureSubtitle(error) + title: banned ? GroupModerationCopy.bannedTitle : "Couldn't Join Chat", + subtitle: banned ? GroupModerationCopy.bannedSubtitle(groupName: title) : joinFailureSubtitle(error) ) { DialogAction.okay(kind: .standard) } diff --git a/Flipcash/Core/Screens/Conversation/GroupLinkCard.swift b/Flipcash/Core/Screens/Conversation/GroupLinkCard.swift index d7d22b408..a14fef087 100644 --- a/Flipcash/Core/Screens/Conversation/GroupLinkCard.swift +++ b/Flipcash/Core/Screens/Conversation/GroupLinkCard.swift @@ -112,7 +112,7 @@ nonisolated extension ConversationGateRequirement { var mint: PublicKey? { switch self { case .minimumBalance(_, let mint): mint - case .staff, .never, .creator, .unsupported: nil + case .staff, .never, .creator, .unsupported, .silenced: nil } } } diff --git a/Flipcash/Core/Screens/Members/GroupMembersScreen.swift b/Flipcash/Core/Screens/Members/GroupMembersScreen.swift index 300b95256..54d6720d9 100644 --- a/Flipcash/Core/Screens/Members/GroupMembersScreen.swift +++ b/Flipcash/Core/Screens/Members/GroupMembersScreen.swift @@ -60,7 +60,7 @@ struct GroupMembersScreen: View { private func row(_ portrait: GroupPortrait, joinedAt: Date?) -> some View { Button { - router.push(.userProfile(portrait.userID, origin: .groupMember)) + router.push(.userProfile(portrait.userID, origin: .groupMember(conversationID))) } label: { HStack(spacing: 12) { ContactAvatarView( diff --git a/Flipcash/Core/Screens/Members/GroupRosters.swift b/Flipcash/Core/Screens/Members/GroupRosters.swift index 0039f9261..f78d50259 100644 --- a/Flipcash/Core/Screens/Members/GroupRosters.swift +++ b/Flipcash/Core/Screens/Members/GroupRosters.swift @@ -26,6 +26,10 @@ final class GroupRosters { /// The roster version at which each departed member left, so a trailing page can't resurrect them. private var departures: [ConversationID: [UserID: UInt64]] = [:] + /// Members the stream delivered while a read of that chat was in flight. The read was pinned + /// before them, so its page cannot be taken as proof they are gone. + private var joinedDuringRead: [ConversationID: Set] = [:] + @ObservationIgnored private let getRoster: GetRoster init(getRoster: @escaping GetRoster) { @@ -44,10 +48,17 @@ final class GroupRosters { /// Reads `conversationID`'s roster and merges it into what the stream has delivered. A failed /// read keeps what was held. + /// + /// The read pages to exhaustion, so it is the whole roster: a held member it leaves out was removed + /// without the stream naming them (a ban reaches the other members as an undisclosed + /// `membershipChanged`, which moves the count and nothing else), and is dropped here. func refresh(_ conversationID: ConversationID) async { + joinedDuringRead[conversationID] = [] + defer { joinedDuringRead[conversationID] = nil } do { let page = try await getRoster(conversationID) merge(page, into: conversationID) + dropMembers(missingFrom: page, in: conversationID) } catch { logger.warning("Failed to read a group roster", metadata: [ "conversationID": "\(conversationID)", @@ -66,16 +77,35 @@ final class GroupRosters { case .joined(let member, _): guard let userID = member.userID else { continue } departures[conversationID]?[userID] = nil + joinedDuringRead[conversationID]?.insert(userID) insert(member, userID: userID, into: conversationID) case .left(let userID): members[conversationID]?[userID] = nil departures[conversationID, default: [:]][userID] = update.rosterSummary.version case .membershipChanged: - break + // The update names no one, so the list can't be patched. The count has moved, and a + // list left as it was would still show whoever was removed: read it again. + Task { await refresh(conversationID) } } } } + /// Drops `userID` at once, for a removal this device just made. The server sends the host no + /// departure for it, and the list should change in the same beat as the count. + func remove(_ userID: UserID, from conversationID: ConversationID) { + guard let held = members[conversationID]?[userID] else { return } + members[conversationID]?[userID] = nil + departures[conversationID, default: [:]][userID] = held.version + } + + private func dropMembers(missingFrom page: [ConversationMember], in conversationID: ConversationID) { + let pageIDs = Set(page.compactMap(\.userID)) + let kept = joinedDuringRead[conversationID] ?? [] + for userID in Array((members[conversationID] ?? [:]).keys) where !pageIDs.contains(userID) && !kept.contains(userID) { + members[conversationID]?[userID] = nil + } + } + private func merge(_ page: [ConversationMember], into conversationID: ConversationID) { if members[conversationID] == nil { members[conversationID] = [:] diff --git a/Flipcash/Core/Screens/Moderation/GroupModeration.swift b/Flipcash/Core/Screens/Moderation/GroupModeration.swift new file mode 100644 index 000000000..5bd4ba2b2 --- /dev/null +++ b/Flipcash/Core/Screens/Moderation/GroupModeration.swift @@ -0,0 +1,254 @@ +// +// GroupModeration.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore +import FlipcashUI + +/// The host's moderation calls on a group: silence, unsilence, ban, unban, and the list of everyone +/// currently restricted. `FlipClient` conforms; tests supply a fake. +protocol ConversationModeration: AnyObject, Sendable { + func silenceUser(owner: KeyPair, conversationID: ConversationID, userID: UserID, silence: ConversationSilenceState) async throws + func unsilenceUser(owner: KeyPair, conversationID: ConversationID, userID: UserID) async throws + func banUser(owner: KeyPair, conversationID: ConversationID, userID: UserID) async throws + func unbanUser(owner: KeyPair, conversationID: ConversationID, userID: UserID) async throws + func getRestrictedUsers(owner: KeyPair, conversationID: ConversationID) async throws -> [ConversationRestrictedUser] +} + +extension FlipClient: ConversationModeration {} + +/// How long to silence a member for. The durations match Mute Notifications, but the last one reads +/// "Forever": "Always" would name a state, and this names how long the host's action lasts. +enum SilenceDuration: CaseIterable, Hashable { + case oneHour, eightHours, oneWeek, forever + + var title: String { + switch self { + case .oneHour: "1 Hour" + case .eightHours: "8 Hours" + case .oneWeek: "1 Week" + case .forever: "Forever" + } + } + + var systemSymbol: SystemSymbol { + switch self { + case .oneHour, .eightHours, .oneWeek: .clock + case .forever: .speakerSlash + } + } + + /// The silence this choice applies when picked at `now`. A timed one is an instant, not a + /// duration: the server stores the end and the client owns the countdown against it. + func silence(from now: Date) -> ConversationSilenceState { + switch self { + case .oneHour: .until(now.addingTimeInterval(60 * 60)) + case .eightHours: .until(now.addingTimeInterval(8 * 60 * 60)) + case .oneWeek: .until(now.addingTimeInterval(7 * 24 * 60 * 60)) + case .forever: .forever + } + } +} + +/// Copy for the host's moderation prompts, kept apart from the views so the wording is one place. +enum GroupModerationCopy { + + static func silenceTitle(firstName: String) -> String { + "Restrict \(firstName)" + } + + static func silenceCaption(firstName: String) -> String { + "\(firstName) can still read the group but can't send messages" + } + + static func removeTitle(firstName: String) -> String { + "Remove \(firstName)?" + } + + static func removeSubtitle(groupName: String) -> String { + "They'll leave \(groupName) and can't rejoin until you unban them. Their messages stay in the chat" + } + + static let bannedTitle = "You Can't Join This Group" + + static func bannedSubtitle(groupName: String) -> String { + "The host removed you from \(groupName)" + } + + static func silencedToast(firstName: String, duration: SilenceDuration) -> String { + switch duration { + case .oneHour: "\(firstName) can't send messages for 1 hour" + case .eightHours: "\(firstName) can't send messages for 8 hours" + case .oneWeek: "\(firstName) can't send messages for 1 week" + case .forever: "\(firstName) can't send messages in this group" + } + } + + static func unsilencedToast(firstName: String) -> String { + "\(firstName) can send messages again" + } + + static let silenceLiftedToast = "You are now able to chat again" + + /// Whether the viewer's silence just lifted: they were under one and now are not. It is the one + /// transition both the host's unsilence and a timed silence running out pass through, so keying on + /// it shows the toast once per lift. Opening a chat already unsilenced has no earlier silence, and a + /// silence replaced by another is not a lift. + static func silenceLifted(from previous: ConversationSilenceState?, to current: ConversationSilenceState?) -> Bool { + previous != nil && current == nil + } + + static let noSilenced = "No One Restricted" + static let noRemoved = "No One Removed" + static let noSilencedBody = "Stop someone from sending messages in this group by tapping their profile and selecting Restrict" + static let noRemovedBody = "Remove someone from this group by tapping their profile and selecting Remove from Group" + + /// The first word of a display name, which is how the host's prompts address the member. + static func firstName(of name: String) -> String { + name.split(separator: " ").first.map(String.init) ?? name + } +} + +/// The host's moderation actions on one group, bound to the signed-in owner. +@MainActor +struct GroupModeration { + + let moderation: any ConversationModeration + let owner: KeyPair + let conversationID: ConversationID + /// Told the toast message once a silence change has succeeded, never on a failure. + var announce: @MainActor (String) -> Void = { _ in } + /// Told the member once a removal has succeeded, so the roster can drop them with the count. + var didRemove: @MainActor (UserID) -> Void = { _ in } + + func silence(_ userID: UserID, firstName: String, duration: SilenceDuration, now: Date = .now) async throws { + try await moderation.silenceUser(owner: owner, conversationID: conversationID, userID: userID, silence: duration.silence(from: now)) + announce(GroupModerationCopy.silencedToast(firstName: firstName, duration: duration)) + } + + func unsilence(_ userID: UserID, firstName: String) async throws { + try await moderation.unsilenceUser(owner: owner, conversationID: conversationID, userID: userID) + announce(GroupModerationCopy.unsilencedToast(firstName: firstName)) + } + + /// Puts `userID` out of the group and bars them from rejoining. The roster drops them through + /// the `MemberLeft` update the other members get, so nothing local needs to move. + func remove(_ userID: UserID) async throws { + try await moderation.banUser(owner: owner, conversationID: conversationID, userID: userID) + didRemove(userID) + } + + func unban(_ userID: UserID) async throws { + try await moderation.unbanUser(owner: owner, conversationID: conversationID, userID: userID) + } + + func restrictedUsers() async throws -> [ConversationRestrictedUser] { + try await moderation.getRestrictedUsers(owner: owner, conversationID: conversationID) + } +} + +/// Which of the host's two lists a restricted user belongs on, and how a row reads. +enum RestrictedUserList: Hashable { + case silenced + case removed +} + +extension ConversationRestrictedUser { + + /// Whether the user is banned, which wins the row when they are also silenced. + var isRemoved: Bool { bannedAt != nil } + + /// Whether the silence is still in force at `date`. A timed one lapses with no event, and the + /// index can still list it for a moment after. + func isSilenced(at date: Date) -> Bool { + silence?.isActive(at: date) ?? false + } + + /// Whether this user belongs on `list` as of `date`: ban wins, so a user who is both appears + /// only under Removed. + func belongs(on list: RestrictedUserList, at date: Date) -> Bool { + switch list { + case .silenced: !isRemoved && isSilenced(at: date) + case .removed: isRemoved + } + } + + /// The second line of the user's row on `list`. + func detail(on list: RestrictedUserList, now: Date, calendar: Calendar = .current, locale: Locale = .current) -> String { + switch list { + case .silenced: + switch silence { + case .until(let end): + return "Restricted · until \(ConversationGatePanel.endText(end, now: now, calendar: calendar, locale: locale))" + case .forever, nil: + return "Restricted · forever" + } + case .removed: + let date = (bannedAt ?? now).formatted(Date.FormatStyle().month(.abbreviated).day().locale(locale)) + let base = "Removed \(date)" + return isSilenced(at: now) ? "\(base) · also restricted" : base + } + } +} + +/// One of the host's lists, loaded from a single paged read and edited locally afterward. +/// +/// The server's index trails writes briefly, so a row the host just acted on is removed here rather +/// than refetched, and no read follows a write. +@MainActor +@Observable +final class RestrictedUsersModel { + + enum Phase: Equatable { + case loading + case loaded + case failed + } + + let list: RestrictedUserList + + private(set) var phase: Phase = .loading + private(set) var users: [ConversationRestrictedUser] = [] + /// Rows whose action is in flight, by `ConversationRestrictedUser.id`. + private(set) var inFlight: Set = [] + + @ObservationIgnored private let moderation: GroupModeration + + init(list: RestrictedUserList, moderation: GroupModeration) { + self.list = list + self.moderation = moderation + } + + /// The rows to draw as of `date`. + func rows(at date: Date) -> [ConversationRestrictedUser] { + users.filter { $0.belongs(on: list, at: date) } + } + + func load() async { + do { + users = try await moderation.restrictedUsers() + phase = .loaded + } catch { + guard !Task.isCancelled else { return } + phase = .failed + ErrorReporting.captureError(error, reason: "Failed to load restricted users") + } + } + + /// Runs the list's undo (Unsilence or Unban) on `user` and drops the row on success. Throws so + /// the screen can say it failed; the row stays. + func undo(_ user: ConversationRestrictedUser) async throws { + guard let userID = user.userID, !inFlight.contains(user.id) else { return } + inFlight.insert(user.id) + defer { inFlight.remove(user.id) } + switch list { + case .silenced: try await moderation.unsilence(userID, firstName: GroupModerationCopy.firstName(of: user.displayName)) + case .removed: try await moderation.unban(userID) + } + users.removeAll { $0.id == user.id } + } +} diff --git a/Flipcash/Core/Screens/Moderation/ModerationPrompts.swift b/Flipcash/Core/Screens/Moderation/ModerationPrompts.swift new file mode 100644 index 000000000..3d6e2474c --- /dev/null +++ b/Flipcash/Core/Screens/Moderation/ModerationPrompts.swift @@ -0,0 +1,123 @@ +// +// ModerationPrompts.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The member and group a Silence or Remove prompt is about. +struct ModerationTarget: Identifiable, Equatable { + let conversationID: ConversationID + let userID: UserID + /// What the prompt calls the member: their first name. + let firstName: String + let groupName: String + + var id: String { "\(conversationID)-\(userID)" } +} + +/// The duration sheet for silencing one member. Picking a row applies it and dismisses, with no +/// confirm: picking again replaces the duration. +struct SilenceUserSheet: View { + + let target: ModerationTarget + @Binding var isPresented: Bool + + @Environment(Container.self) private var container + @Environment(SessionContainer.self) private var sessionContainer + @Environment(ToastController.self) private var toasts + + @State private var isWorking = false + + var body: some View { + ChatDurationSheet( + title: GroupModerationCopy.silenceTitle(firstName: target.firstName), + caption: GroupModerationCopy.silenceCaption(firstName: target.firstName), + isPresented: $isPresented + ) { + ForEach(SilenceDuration.allCases, id: \.self) { duration in + ChatActionRow( + icon: .system(duration.systemSymbol), + title: duration.title, + accessibilityIdentifier: "silence-user-\(duration)" + ) { + guard !isWorking else { return } + Task { await apply(duration) } + } + } + } + } + + private func apply(_ duration: SilenceDuration) async { + isWorking = true + defer { isWorking = false } + let moderation = GroupModeration( + moderation: container.flipClient, + owner: sessionContainer.session.ownerKeyPair, + conversationID: target.conversationID, + announce: { message in + // The sheet is still on its way out, and a toast shown over it is dropped. + Task { + try? await Task.sleep(for: .milliseconds(600)) + toasts.show(.init(message, systemImage: "speaker.slash", duration: .seconds(3))) + } + } + ) + do { + try await moderation.silence(target.userID, firstName: target.firstName, duration: duration) + isPresented = false + } catch { + isPresented = false + sessionContainer.session.dialogItem = .error( + title: "Something Went Wrong", + subtitle: "We were unable to restrict \(target.firstName). Please try again" + ) + ErrorReporting.captureError(error, reason: "Failed to silence user") + } + } +} + +extension View { + + /// Presents the Silence duration sheet for `target` while it is non-nil. + func silenceUserSheet(_ target: Binding) -> some View { + sheet(item: target) { value in + SilenceUserSheet( + target: value, + isPresented: Binding( + get: { target.wrappedValue != nil }, + set: { if !$0 { target.wrappedValue = nil } } + ) + ) + } + } +} + +extension ModerationTarget { + + /// The bottom-bar confirm for Remove, the same alert Block uses. Removing is a ban, so it says + /// they can't come back until unbanned. + @MainActor + func removeDialog(moderation: GroupModeration, onFailure: @escaping @MainActor (Error) -> Void) -> DialogItem { + let userID = userID + return .alert( + title: GroupModerationCopy.removeTitle(firstName: firstName), + subtitle: GroupModerationCopy.removeSubtitle(groupName: groupName) + ) { + DialogAction.destructive("Remove") { + Task { + do { + try await moderation.remove(userID) + } catch { + onFailure(error) + } + } + } + DialogAction.cancel() + } + } +} diff --git a/Flipcash/Core/Screens/Moderation/RestrictedUsersScreen.swift b/Flipcash/Core/Screens/Moderation/RestrictedUsersScreen.swift new file mode 100644 index 000000000..216cbeeab --- /dev/null +++ b/Flipcash/Core/Screens/Moderation/RestrictedUsersScreen.swift @@ -0,0 +1,169 @@ +// +// RestrictedUsersScreen.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// One of the host's moderation lists: the members they silenced, or the ones they removed, each +/// with the button that undoes it. +struct RestrictedUsersScreen: View { + + let conversationID: ConversationID + let list: RestrictedUserList + + @Environment(Container.self) private var container + @Environment(SessionContainer.self) private var sessionContainer + @Environment(ToastController.self) private var toasts + @Environment(AppRouter.self) private var router + + @State private var model: RestrictedUsersModel? + + private var title: String { + switch list { + case .silenced: "Restricted" + case .removed: "Removed" + } + } + + var body: some View { + Background(color: .backgroundMain) { + content + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .task(id: conversationID) { + let model = RestrictedUsersModel( + list: list, + moderation: GroupModeration( + moderation: container.flipClient, + owner: sessionContainer.session.ownerKeyPair, + conversationID: conversationID, + announce: { toasts.show(.init($0, systemImage: "speaker.wave.2", duration: .seconds(3))) } + ) + ) + self.model = model + await model.load() + } + } + + @ViewBuilder + private var content: some View { + if let model { + switch model.phase { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed: + EmptyStateMessage("Couldn't load this list") + case .loaded: + TimelineView(.everyMinute) { context in + let rows = model.rows(at: context.date) + if rows.isEmpty { + EmptyStateMessage( + list == .silenced ? GroupModerationCopy.noSilenced : GroupModerationCopy.noRemoved, + message: list == .silenced ? GroupModerationCopy.noSilencedBody : GroupModerationCopy.noRemovedBody + ) + } else { + List(rows) { user in + RestrictedUserRow( + user: user, + list: list, + now: context.date, + imageData: user.userID.flatMap { sessionContainer.profileAvatars.data(for: $0) }, + isWorking: model.inFlight.contains(user.id), + openProfile: { openProfile(user) }, + undo: { Task { await undo(user, in: model) } } + ) + .task(id: user.userID) { + await sessionContainer.profileAvatars.load(userID: user.userID, picture: user.profilePicture) + } + .listRowInsets(EdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .animation(.default, value: rows.map(\.id)) + } + } + } + } else { + Color.clear + } + } + + private func openProfile(_ user: ConversationRestrictedUser) { + guard let userID = user.userID else { return } + router.push(.userProfile(userID, origin: .groupMember(conversationID))) + } + + private func undo(_ user: ConversationRestrictedUser, in model: RestrictedUsersModel) async { + do { + try await model.undo(user) + } catch { + let action = list == .silenced ? "lift the restriction on" : "unban" + sessionContainer.session.dialogItem = .error( + title: "Something Went Wrong", + subtitle: "We were unable to \(action) \(GroupModerationCopy.firstName(of: user.displayName)). Please try again" + ) + ErrorReporting.captureError(error, reason: "Failed to \(action) user") + } + } +} + +private struct RestrictedUserRow: View { + + let user: ConversationRestrictedUser + let list: RestrictedUserList + let now: Date + let imageData: Data? + let isWorking: Bool + let openProfile: () -> Void + let undo: () -> Void + + var body: some View { + HStack(spacing: 12) { + Button(action: openProfile) { + HStack(spacing: 12) { + ContactAvatarView( + id: user.userID?.uuidString ?? user.displayName, + displayName: user.displayName, + imageData: imageData, + size: 40 + ) + VStack(alignment: .leading, spacing: 2) { + Text(user.displayName) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + Text(user.detail(on: list, now: now)) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + } + .lineLimit(1) + Spacer(minLength: 8) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Button(action: undo) { + // The label keeps its width while the spinner shows. + Text(list == .silenced ? "Lift" : "Unban") + .opacity(isWorking ? 0 : 1) + .overlay { + if isWorking { + ProgressView() + } + } + } + .buttonStyle(JoinRequestCapsuleStyle(kind: .secondary)) + .allowsHitTesting(!isWorking) + .accessibilityIdentifier("restricted-user-undo") + } + } +} diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index 932274e1e..2fe6d53a3 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -187,14 +187,14 @@ struct ChatProfileScreen: View { count: conversation?.rosterSummary.memberCount, onCount: rosterPortraits.isEmpty ? nil : { router.push(.groupMembers(conversationID)) } ) { userID in - router.push(.userProfile(userID, origin: .groupMember)) + router.push(.userProfile(userID, origin: .groupMember(conversationID))) } .padding(.top, 24) } if GroupChattingGrid.isVisible(isPrivate: isPrivate, chatters: chatters) { GroupChattingGrid(chatters: chatters) { userID in - router.push(.userProfile(userID, origin: .groupMember)) + router.push(.userProfile(userID, origin: .groupMember(conversationID))) } .padding(.top, 24) } @@ -258,6 +258,7 @@ struct ChatProfileScreen: View { let conversation = await conversationController.hydratedConversation(withID: conversationID) await sessionContainer.profileAvatars.load(.chat(conversationID), picture: conversation?.picture) await conversationController.fillCoverPicture(for: conversationID) + await conversationController.refreshViewerPermissions(for: conversationID) } // No push says a request was decided, so every visit asks. Keyed on membership so an // admission that lands while the profile is open stops the asking. @@ -591,8 +592,8 @@ struct ChatProfileScreen: View { } } catch let error as LobbyRequests.RequestError { dialogItem = .info( - title: PrivateGroupCopy.unavailableTitle(groupName: title), - subtitle: error.explanation + title: error == .banned ? GroupModerationCopy.bannedTitle : PrivateGroupCopy.unavailableTitle(groupName: title), + subtitle: error == .banned ? GroupModerationCopy.bannedSubtitle(groupName: title) : error.explanation ) { DialogAction.okay(kind: .standard) } @@ -639,6 +640,12 @@ struct ChatProfileScreen: View { // MARK: - Overflow - + /// Whether the viewer may silence or ban in this group, which is what opens the Moderation row. + private var canModerate: Bool { + guard let state = conversation?.viewerState else { return false } + return state.canSilence || state.canBan + } + private var overflowMenu: some View { Menu { Button { @@ -654,6 +661,24 @@ struct ChatProfileScreen: View { Label("Mute Notifications", systemImage: "bell.slash") } } + // The host's two lists, behind one row so the overflow stays short. Gated on the + // viewer's server-computed flags, never on being the creator. + if canModerate { + Menu { + Button { + router.push(.silencedMembers(conversationID)) + } label: { + Label("Restricted", systemImage: "speaker.slash") + } + Button { + router.push(.removedMembers(conversationID)) + } label: { + Label("Removed", systemImage: "person.badge.minus") + } + } label: { + Label("Moderation", systemImage: "shield") + } + } Divider() // Outside the membership check: a group you have already left is the one you are most // likely to report. diff --git a/Flipcash/Core/Screens/Profile/GroupProfileState.swift b/Flipcash/Core/Screens/Profile/GroupProfileState.swift index ebf2fca1d..0841fae14 100644 --- a/Flipcash/Core/Screens/Profile/GroupProfileState.swift +++ b/Flipcash/Core/Screens/Profile/GroupProfileState.swift @@ -54,7 +54,7 @@ nonisolated private extension ConversationGateRequirement { var minimumBalance: (FiatAmount, PublicKey?)? { switch self { case .minimumBalance(let amount, let mint): return (amount, mint) - case .staff, .never, .creator, .unsupported: return nil + case .staff, .never, .creator, .unsupported, .silenced: return nil } } diff --git a/Flipcash/Core/Screens/Profile/LobbyRequests.swift b/Flipcash/Core/Screens/Profile/LobbyRequests.swift index 2f009db28..3204e1464 100644 --- a/Flipcash/Core/Screens/Profile/LobbyRequests.swift +++ b/Flipcash/Core/Screens/Profile/LobbyRequests.swift @@ -34,6 +34,8 @@ final class LobbyRequests { case lobbyFull /// The user is waiting on too many groups already. case tooManyLobbies + /// The host removed the user from the group and hasn't unbanned them. + case banned } typealias EnterLobby = @MainActor @Sendable (ConversationID) async throws -> Lobby @@ -89,6 +91,8 @@ final class LobbyRequests { throw RequestError.lobbyFull } catch ErrorEnterLobby.tooManyLobbies { throw RequestError.tooManyLobbies + } catch ErrorEnterLobby.banned { + throw RequestError.banned } } diff --git a/Flipcash/Core/Screens/Profile/MuteChatSheet.swift b/Flipcash/Core/Screens/Profile/MuteChatSheet.swift index 8eaa0717b..f6d4de57f 100644 --- a/Flipcash/Core/Screens/Profile/MuteChatSheet.swift +++ b/Flipcash/Core/Screens/Profile/MuteChatSheet.swift @@ -73,66 +73,27 @@ struct MuteChatSheet: View { } var body: some View { - PartialSheet(background: .backgroundMain) { - VStack(spacing: 20) { - header - - // What muting does and doesn't do, the way WhatsApp captions the same sheet. Worth - // saying here because the answer isn't obvious and is easy to get wrong: a muted - // chat still delivers, still stores, and still counts unread. - Text("You'll still receive messages and see unread counts. Only notifications are silenced") - .font(.appTextSmall) - .foregroundStyle(Color.textSecondary) - .multilineTextAlignment(.center) - // The only flexible row in this stack, so a height proposal short of what the - // content needs lands here -- and a `Text` answers that by dropping lines and - // ellipsizing. `PartialSheet` opens at the full screen height and corrects to - // its measured height, and the row count changes with the unmute row, so short - // proposals do happen. `fixedSize` makes the wrapped height a floor instead. - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity) - - VStack(spacing: 12) { - ForEach(options, id: \.title) { row in - ChatActionRow( - icon: .system(icon(for: row.option)), - title: row.title, - accessibilityIdentifier: "mute-chat-\(identifier(for: row.option))" - ) { - guard !isWorking else { return } - Task { await apply(row.option) } - } - } + // What muting does and doesn't do, the way WhatsApp captions the same sheet. Worth saying + // here because the answer isn't obvious and is easy to get wrong: a muted chat still + // delivers, still stores, and still counts unread. + ChatDurationSheet( + title: "Mute Notifications", + caption: "You'll still receive messages and see unread counts. Only notifications are silenced", + isPresented: $isPresented + ) { + ForEach(options, id: \.title) { row in + ChatActionRow( + icon: .system(icon(for: row.option)), + title: row.title, + accessibilityIdentifier: "mute-chat-\(identifier(for: row.option))" + ) { + guard !isWorking else { return } + Task { await apply(row.option) } } } - .padding(.horizontal, 16) - .padding(.top, 20) - .padding(.bottom, bottomPadding) } } - /// The sheet's own title bar — ``PartialSheet`` sizes itself to its content, so there is no - /// navigation bar to hang a title on. Mirrors ``ShareToChatsSheet/header``. - private var header: some View { - ZStack { - Text("Mute Notifications") - .font(.appBarButton) - .foregroundStyle(Color.textMain) - - HStack { - Spacer() - CloseButton(style: .glass, binding: $isPresented) - } - } - } - - /// The design's 16 under the last row, less whatever the sheet already holds back for the home - /// indicator. - private var bottomPadding: CGFloat { - let reserved = UIApplication.shared.currentKeyWindow?.safeAreaInsets.bottom ?? 0 - return max(0, 16 - reserved) - } - private func icon(for option: Option) -> SystemSymbol { switch option { case .until: .clock @@ -191,3 +152,63 @@ struct MuteChatSheet: View { } } } + +/// The layout the chat duration sheets share: a title bar with a close button, one caption saying +/// what the choice does, and a stack of ``ChatActionRow``s. ``MuteChatSheet`` and +/// ``SilenceUserSheet`` differ only in copy and in what a row does. +struct ChatDurationSheet: View { + + let title: String + let caption: String + @Binding var isPresented: Bool + @ViewBuilder let rows: () -> Rows + + var body: some View { + PartialSheet(background: .backgroundMain) { + VStack(spacing: 20) { + header + + Text(caption) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + // The only flexible row in this stack, so a height proposal short of what the + // content needs lands here -- and a `Text` answers that by dropping lines and + // ellipsizing. `PartialSheet` opens at the full screen height and corrects to + // its measured height, and the row count can change, so short proposals do + // happen. `fixedSize` makes the wrapped height a floor instead. + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + + VStack(spacing: 12) { + rows() + } + } + .padding(.horizontal, 16) + .padding(.top, 20) + .padding(.bottom, bottomPadding) + } + } + + /// The sheet's own title bar — ``PartialSheet`` sizes itself to its content, so there is no + /// navigation bar to hang a title on. Mirrors ``ShareToChatsSheet/header``. + private var header: some View { + ZStack { + Text(title) + .font(.appBarButton) + .foregroundStyle(Color.textMain) + + HStack { + Spacer() + CloseButton(style: .glass, binding: $isPresented) + } + } + } + + /// The design's 16 under the last row, less whatever the sheet already holds back for the home + /// indicator. + private var bottomPadding: CGFloat { + let reserved = UIApplication.shared.currentKeyWindow?.safeAreaInsets.bottom ?? 0 + return max(0, 16 - reserved) + } +} diff --git a/Flipcash/Core/Screens/Profile/PrivateGroupCopy.swift b/Flipcash/Core/Screens/Profile/PrivateGroupCopy.swift index ee361b9ba..1ed64d370 100644 --- a/Flipcash/Core/Screens/Profile/PrivateGroupCopy.swift +++ b/Flipcash/Core/Screens/Profile/PrivateGroupCopy.swift @@ -112,6 +112,9 @@ extension LobbyRequests.RequestError { "Too many people are waiting for the host to respond. Try again later" case .tooManyLobbies: "You have too many pending requests. Cancel one to request another" + case .banned: + // Not shown: the banned request is worded by ``GroupModerationCopy`` with its own title. + "The host removed you from this group" } } } diff --git a/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift b/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift index 32d5ce11d..e144d2116 100644 --- a/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift +++ b/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift @@ -9,6 +9,9 @@ nonisolated enum ProfileMenuItem: Equatable { case report case block case unblock + /// Host-only, in the group the profile was opened from. + case silence + case removeFromGroup /// The entry's label. var title: String { @@ -17,6 +20,8 @@ nonisolated enum ProfileMenuItem: Equatable { case .report: return "Report" case .block: return "Block" case .unblock: return "Unblock" + case .silence: return "Restrict" + case .removeFromGroup: return "Remove from Group" } } @@ -27,14 +32,16 @@ nonisolated enum ProfileMenuItem: Equatable { case .report: return "exclamationmark.bubble" case .block: return "nosign" case .unblock: return "checkmark.circle" + case .silence: return "speaker.slash" + case .removeFromGroup: return "person.badge.minus" } } /// Whether the entry is drawn as a destructive action. Unblock restores something, so it isn't. var isDestructive: Bool { switch self { - case .report, .block: return true - case .mute, .unblock: return false + case .report, .block, .removeFromGroup: return true + case .mute, .unblock, .silence: return false } } } @@ -50,4 +57,15 @@ nonisolated enum ProfileMenuItems { items.append(contentsOf: [.report, .block]) return items } + + /// The host's section for a profile opened from a group: Silence when the viewer can silence, + /// Remove when they can ban. Empty for the group's creator, who can't be restricted, and for + /// the viewer's own profile. Block stays in the main section and stays global. + static func moderation(canSilence: Bool, canBan: Bool, isCreator: Bool, isSelf: Bool) -> [ProfileMenuItem] { + guard !isCreator, !isSelf else { return [] } + var items: [ProfileMenuItem] = [] + if canSilence { items.append(.silence) } + if canBan { items.append(.removeFromGroup) } + return items + } } diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index f6fd8edcf..4ee164c56 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -51,6 +51,7 @@ private struct UserProfileContent: View { @Environment(ConversationController.self) private var conversationController @Environment(BlocklistController.self) private var blocklistController @Environment(ToastController.self) private var toasts + @Environment(Container.self) private var container let origin: UserProfileOrigin @@ -60,6 +61,7 @@ private struct UserProfileContent: View { @State private var dialogItem: DialogItem? @State private var isPickingMuteDuration = false @State private var isReporting = false + @State private var silenceTarget: ModerationTarget? @State private var startChattingRequest: StartChattingRequest? @State private var postPayment = PostPaymentChat() /// Where this screen sits in its stack, recorded when it first appears. @@ -119,6 +121,32 @@ private struct UserProfileContent: View { return ProfileMenuItems.resolve(isBlocked: isBlocked, hasDM: dmID != nil) } + /// The group this profile was opened from, when the viewer can moderate it. + private var moderationGroup: Conversation? { + guard case .groupMember(let id) = origin else { return nil } + return conversationController.conversation(withID: id) + } + + private var moderationItems: [ProfileMenuItem] { + guard let group = moderationGroup, let state = group.viewerState else { return [] } + return ProfileMenuItems.moderation( + canSilence: state.canSilence, + canBan: state.canBan, + isCreator: group.creator == model.userID, + isSelf: isSelf + ) + } + + private var moderationTarget: ModerationTarget? { + guard let group = moderationGroup else { return nil } + return ModerationTarget( + conversationID: group.id, + userID: model.userID, + firstName: GroupModerationCopy.firstName(of: model.displayName), + groupName: group.title ?? "this group" + ) + } + /// Whether this profile is the visible top of its stack, with its tab or sheet active. private var isScreenInFront: Bool { guard let position else { return false } @@ -196,7 +224,7 @@ private struct UserProfileContent: View { } // The system bar carries back and the overflow menu, and the soft edge the banner scrolls under. .toolbar { - if !menuItems.isEmpty { + if !menuItems.isEmpty || !moderationItems.isEmpty { ToolbarItem(placement: .topBarTrailing) { overflowMenu } @@ -216,6 +244,7 @@ private struct UserProfileContent: View { } } .dialog(item: $dialogItem) + .silenceUserSheet($silenceTarget) .sheet(isPresented: $isPickingMuteDuration) { if let dmID { MuteChatSheet(conversationID: dmID, isPresented: $isPickingMuteDuration) @@ -293,7 +322,19 @@ private struct UserProfileContent: View { } // Menu icons follow the app's white tint while the title follows the role, so a // destructive entry's icon is tinted red to match its title. - .tint(item.isDestructive ? .red : nil) + .tint(item.isDestructive ? Color.textError : nil) + } + if !moderationItems.isEmpty { + Section(moderationGroup?.title.map { "In \($0)" } ?? "In this group") { + ForEach(moderationItems, id: \.title) { item in + Button(role: item.isDestructive ? .destructive : nil) { + perform(item) + } label: { + Label(item.title, systemImage: item.systemImage) + } + .tint(item.isDestructive ? Color.textError : nil) + } + } } } label: { Image.system(.ellipsis) @@ -321,6 +362,18 @@ private struct UserProfileContent: View { dialogItem = blockDialog() case .unblock: Task { await model.unblock() } + case .silence: + silenceTarget = moderationTarget + case .removeFromGroup: + guard let target = moderationTarget else { return } + let moderation = GroupModeration(moderation: container.flipClient, owner: sessionContainer.session.ownerKeyPair, conversationID: target.conversationID, didRemove: { [rosters = sessionContainer.groupRosters] in rosters.remove($0, from: target.conversationID) }) + dialogItem = target.removeDialog(moderation: moderation) { error in + sessionContainer.session.dialogItem = .error( + title: "Something Went Wrong", + subtitle: "We were unable to remove \(target.firstName). Please try again" + ) + ErrorReporting.captureError(error, reason: "Failed to remove user") + } } } @@ -404,8 +457,9 @@ private struct UserProfileContent: View { nonisolated enum UserProfileOrigin: Hashable { /// The title or card of the DM with this person. case directMessage - /// Their face in a group transcript. - case groupMember + /// Their face in a group transcript or its member list. Carries the group, since the host's + /// Silence and Remove act on that group only. + case groupMember(ConversationID) /// Their `@handle` or person link card tapped in a message, in a chat that is not a DM with them. case mention /// A `flipcash.com/` or `flipcash.com/` link opened into the app. diff --git a/Flipcash/Core/Screens/Settings/BlockedUsersScreen.swift b/Flipcash/Core/Screens/Settings/BlockedUsersScreen.swift index f3a8bad9b..dc5a08ec8 100644 --- a/Flipcash/Core/Screens/Settings/BlockedUsersScreen.swift +++ b/Flipcash/Core/Screens/Settings/BlockedUsersScreen.swift @@ -23,16 +23,10 @@ struct BlockedUsersScreen: View { Background(color: .backgroundMain) { Group { if blocklistController.blockedUsers.isEmpty && hasLoaded { - VStack(spacing: 8) { - Text("No One Blocked") - .font(.appTextLarge) - .foregroundStyle(.textMain) - Text("Block people from sending you messages by tapping their profile and selecting block") - .font(.appTextSmall) - .foregroundStyle(.textSecondary) - .multilineTextAlignment(.center) - } - .padding(.horizontal, 40) + EmptyStateMessage( + "No One Blocked", + message: "Block people from sending you messages by tapping their profile and selecting block" + ) } else if !blocklistController.blockedUsers.isEmpty { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { diff --git a/Flipcash/Core/Screens/Settings/TrustedWebsitesScreen.swift b/Flipcash/Core/Screens/Settings/TrustedWebsitesScreen.swift index 1035494f2..0f74dd4ef 100644 --- a/Flipcash/Core/Screens/Settings/TrustedWebsitesScreen.swift +++ b/Flipcash/Core/Screens/Settings/TrustedWebsitesScreen.swift @@ -18,16 +18,10 @@ struct TrustedWebsitesScreen: View { var body: some View { Background(color: .backgroundMain) { if trustedWebsites.entries.isEmpty { - VStack(spacing: 8) { - Text("No Trusted Websites") - .font(.appTextLarge) - .foregroundStyle(.textMain) - Text("Skip the “You're Leaving Flipcash” warning for a website by checking “Don't ask again” when you open its link") - .font(.appTextSmall) - .foregroundStyle(.textSecondary) - .multilineTextAlignment(.center) - } - .padding(.horizontal, 40) + EmptyStateMessage( + "No Trusted Websites", + message: "Skip the “You're Leaving Flipcash” warning for a website by checking “Don't ask again” when you open its link" + ) } else { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift index 7298ae005..06ea2ac97 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift @@ -371,6 +371,11 @@ public struct ChatAuthor: Hashable, Sendable, Codable { /// picture. public let blurhash: String? + /// The first word of ``name``, or `nil` when the roster does not name this sender. + public var firstName: String? { + name.split(separator: " ").first.map(String.init) + } + public init(id: UserID, name: String, blurhash: String? = nil) { self.id = id self.name = name diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift index 131c2d69d..e468d4a89 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift @@ -84,6 +84,25 @@ public struct ConversationViewerState: Hashable, Codable, Sendable { self.canBan = canBan self.canInvite = canInvite } + + private enum CodingKeys: String, CodingKey { + case mute, canEdit, version, silence, canSilence, canBan + } + + /// Reads a row stored before a field existed. The synthesized decoder throws on a missing key, + /// and the store reads that as "no viewer state", which would drop the saved mute along with + /// the new permission. A missing permission is `false`, the same as an unset one on the wire. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + mute: try container.decodeIfPresent(ConversationMuteState.self, forKey: .mute), + canEdit: try container.decodeIfPresent(Bool.self, forKey: .canEdit) ?? false, + version: try container.decodeIfPresent(UInt64.self, forKey: .version) ?? 0, + silence: try container.decodeIfPresent(ConversationSilenceState.self, forKey: .silence), + canSilence: try container.decodeIfPresent(Bool.self, forKey: .canSilence) ?? false, + canBan: try container.decodeIfPresent(Bool.self, forKey: .canBan) ?? false + ) + } } extension ConversationViewerState { @@ -235,6 +254,15 @@ public struct ConversationRestrictedUser: Hashable, Sendable, Identifiable { public let bannedAt: Date? public var id: String { userID?.uuidString ?? displayName } + + public init(userID: UserID?, displayName: String, username: Username? = nil, profilePicture: ProfilePicture? = nil, silence: ConversationSilenceState? = nil, bannedAt: Date? = nil) { + self.userID = userID + self.displayName = displayName + self.username = username + self.profilePicture = profilePicture + self.silence = silence + self.bannedAt = bannedAt + } } extension ConversationRestrictedUser { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift index 17d5d8847..c122ab295 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift @@ -556,6 +556,29 @@ public struct ConversationStore: Sendable { conversations[index].viewerState = viewerState } + /// Takes the viewer's permissions from a freshly fetched chat, leaving mute, silence and version as + /// they are. Permissions are computed by the server and do not advance the viewer-state version, so + /// ``applyViewerStateChanged(_:in:)`` drops a fetch whose version has not moved. That left a chat + /// stored before the moderation permissions existed with `canSilence` and `canBan` false for good. + /// Returns whether anything changed, so the caller knows to persist. + @discardableResult + public mutating func refreshViewerPermissions(from fetched: ConversationViewerState, in conversationID: ConversationID) -> Bool { + guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return false } + let current = conversations[index].viewerState ?? ConversationViewerState() + guard current.canEdit != fetched.canEdit || current.canSilence != fetched.canSilence || current.canBan != fetched.canBan else { + return false + } + conversations[index].viewerState = ConversationViewerState( + mute: current.mute, + canEdit: fetched.canEdit, + version: current.version, + silence: current.silence, + canSilence: fetched.canSilence, + canBan: fetched.canBan + ) + return true + } + /// Apply a title change delivered via `MetadataUpdate.TitleChanged`. Best-effort and applied as /// received — the update carries no version to compare, unlike ``applyRosterUpdates(_:in:)`` and /// ``applyViewerStateChanged(_:in:)`` — so a delayed or reordered delivery can in principle @@ -613,7 +636,7 @@ public struct ConversationStore: Sendable { } private mutating func upsert(_ conversation: Conversation) { - var conversation = keepingCoverPicture(keepingNewerActivity(keepingSelfReadPointer(seated(conversation)))) + var conversation = keepingViewerState(keepingCoverPicture(keepingNewerActivity(keepingSelfReadPointer(seated(conversation))))) if let index = conversations.firstIndex(where: { $0.id == conversation.id }) { if conversation.type == .group { conversation.members = mergedMembers(conversation.members, over: conversations[index].members) @@ -678,6 +701,37 @@ public struct ConversationStore: Sendable { return conversation } + /// `conversation` with the stored viewer state kept where the incoming copy is not newer. A feed or + /// metadata response is applied as a whole, so an older one would roll back a mute or silence a + /// stream event just set, and a feed row that omits the permissions would reset `canSilence` and + /// `canBan` to false. At an equal version the incoming mute and silence stand, but a permission the + /// store already holds is not dropped: it is server-computed and does not advance the version, so + /// only ``refreshViewerPermissions(from:in:)`` lowers one. + private func keepingViewerState(_ conversation: Conversation) -> Conversation { + guard let held = conversations.first(where: { $0.id == conversation.id })?.viewerState else { + return conversation + } + guard let incoming = conversation.viewerState else { + var conversation = conversation + conversation.viewerState = held + return conversation + } + var conversation = conversation + if incoming.version < held.version { + conversation.viewerState = held + } else if incoming.version == held.version { + conversation.viewerState = ConversationViewerState( + mute: incoming.mute, + canEdit: incoming.canEdit || held.canEdit, + version: incoming.version, + silence: incoming.silence, + canSilence: incoming.canSilence || held.canSilence, + canBan: incoming.canBan || held.canBan + ) + } + return conversation + } + /// `conversation` with the stored row's cover picture kept when the incoming copy carries none. /// The feed RPCs may omit the cover even when one is set, so an unset cover there is "not /// reported", not "cleared"; `GetChat` and `coverPictureChanged` are what change it. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift index d2c99624b..950ddd642 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift @@ -17,6 +17,10 @@ public enum MessageCapability: String, Hashable, Sendable, Codable, CaseIterable case report /// Send a link to the message into other chats, or out through the system share sheet. case forward + /// Host-only: stop the message's sender from posting in this group. Picks a duration next. + case silence + /// Host-only: put the message's sender out of this group and bar them from rejoining. + case remove } extension MessageCapability { @@ -30,6 +34,9 @@ extension MessageCapability { case .delete: "Delete" case .report: "Report" case .forward: "Forward" + // The transcript menu adds the sender's first name; this is the fallback when none is known. + case .silence: "Restrict" + case .remove: "Remove" } } @@ -38,7 +45,7 @@ extension MessageCapability { switch self { // Report is red to match Report on the profile menus, though nothing is destroyed. case .copy, .reply, .edit, .forward: false - case .delete, .report: true + case .delete, .report, .silence, .remove: true } } } @@ -93,9 +100,29 @@ extension MessageCapability { if !isMember || !canPost { capabilities.remove(.reply) } + capabilities.formUnion(moderationCapabilities(for: message, in: conversation, baseline: capabilities)) return capabilities } + /// Silence and Remove, which follow the viewer's server-computed `canSilence` / `canBan` and + /// never an "is creator" check on the viewer. They ride on Report's rule: another participant's + /// real message. The group's creator is never offered, whoever holds the flags. + private static func moderationCapabilities( + for message: ConversationMessage, + in conversation: Conversation?, + baseline: Set + ) -> Set { + guard baseline.contains(.report), + let viewerState = conversation?.viewerState, + let sender = message.senderID, + sender != conversation?.creator + else { return [] } + var granted: Set = [] + if viewerState.canSilence { granted.insert(.silence) } + if viewerState.canBan { granted.insert(.remove) } + return granted + } + private static func memberCapabilities( for message: ConversationMessage, as selfUserID: UserID, diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessagePolicy.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessagePolicy.swift index 78db3905c..e411b53c3 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessagePolicy.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessagePolicy.swift @@ -93,7 +93,7 @@ public struct MessagePolicy: Hashable, Sendable { switch capability { case .edit: editWindow case .delete: deleteWindow - case .copy, .reply, .report, .forward: nil + case .copy, .reply, .report, .forward, .silence, .remove: nil } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationMuteTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationMuteTests.swift index 1104ba35a..71be5ff8b 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationMuteTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationMuteTests.swift @@ -108,4 +108,58 @@ struct ConversationMuteTests { #expect(store.conversations[0].isMuted(at: noon) == false) } + + @Test("A viewer state stored before the moderation permissions decodes, keeping its mute") + func legacyViewerStateDecodes() throws { + let forever = try JSONEncoder().encode(ConversationViewerState(mute: .forever, canEdit: true, version: 4)) + var object = try #require(JSONSerialization.jsonObject(with: forever) as? [String: Any]) + object.removeValue(forKey: "canSilence") + object.removeValue(forKey: "canBan") + let legacy = try JSONSerialization.data(withJSONObject: object) + + let decoded = try JSONDecoder().decode(ConversationViewerState.self, from: legacy) + + #expect(decoded.isMuted(at: noon)) + #expect(decoded.canEdit) + #expect(decoded.version == 4) + #expect(decoded.canSilence == false) + #expect(decoded.canBan == false) + } + + @Test("Fresh permissions replace stale ones at the same version, and mute is untouched") + func refreshViewerPermissionsIgnoresTheVersionGate() { + var store = ConversationStore() + store.apply(.metadataRefresh(conversation(1, viewerState: ConversationViewerState(mute: .forever, version: 2)))) + + let fetched = ConversationViewerState(mute: nil, version: 2, canSilence: true, canBan: true) + // The versioned path drops an equal version, which is why a stale row never recovered. + store.applyViewerStateChanged(fetched, in: conversationID(1)) + #expect(store.conversations[0].viewerState?.canSilence == false) + + let changed = store.refreshViewerPermissions(from: fetched, in: conversationID(1)) + #expect(changed) + #expect(store.conversations[0].viewerState?.canSilence == true) + #expect(store.conversations[0].viewerState?.canBan == true) + #expect(store.conversations[0].isMuted(at: noon)) + #expect(store.conversations[0].viewerState?.version == 2) + + let again = store.refreshViewerPermissions(from: fetched, in: conversationID(1)) + #expect(again == false) + } + + @Test("A feed row that omits the permissions or is older does not reset the stored viewer state") + func metadataRefreshKeepsStoredPermissions() { + var store = ConversationStore() + store.apply(.metadataRefresh(conversation(1, viewerState: ConversationViewerState(mute: .forever, version: 2, canSilence: true, canBan: true)))) + + store.apply(.metadataRefresh(conversation(1, viewerState: ConversationViewerState(mute: .forever, version: 2)))) + #expect(store.conversations[0].viewerState?.canSilence == true) + #expect(store.conversations[0].viewerState?.canBan == true) + + store.apply(.metadataRefresh(conversation(1, viewerState: ConversationViewerState(mute: nil, version: 1)))) + #expect(store.conversations[0].isMuted(at: noon)) + + store.apply(.metadataRefresh(conversation(1, viewerState: nil))) + #expect(store.conversations[0].viewerState?.canSilence == true) + } } diff --git a/FlipcashTests/Chat/GroupRostersTests.swift b/FlipcashTests/Chat/GroupRostersTests.swift index 0fd685318..69de8578b 100644 --- a/FlipcashTests/Chat/GroupRostersTests.swift +++ b/FlipcashTests/Chat/GroupRostersTests.swift @@ -98,4 +98,57 @@ struct GroupRostersTests { #expect(rosters.members(for: group, creator: nil).map(\.displayName) == ["Rejoined"]) } + + @Test("A removal the stream doesn't name drops the member on the re-read the count change triggers") + func undisclosedRemovalRereads() async { + let host = member("Host") + let banned = member("Banned") + var served = [host, banned] + let rosters = GroupRosters { _ in served } + await rosters.refresh(group) + #expect(rosters.members(for: group, creator: host.userID).count == 2) + + served = [host] + rosters.apply(event([(.membershipChanged, 4)])) + // The re-read is a task the apply starts; yield until it lands. + for _ in 0..<50 where rosters.members(for: group, creator: nil).count != 1 { + await Task.yield() + } + + #expect(rosters.members(for: group, creator: host.userID).map(\.displayName) == ["Host"]) + } + + @Test("Removing a member here drops them at once, and a trailing page can't bring them back") + func localRemoval() async { + let host = member("Host") + let banned = member("Banned", version: 3) + let rosters = GroupRosters { _ in [host, banned] } + await rosters.refresh(group) + + rosters.remove(banned.userID!, from: group) + #expect(rosters.members(for: group, creator: host.userID).map(\.displayName) == ["Host"]) + + await rosters.refresh(group) + #expect(rosters.members(for: group, creator: host.userID).map(\.displayName) == ["Host"]) + } + + @Test("A member the stream delivered while a read was in flight survives that read") + func joinDuringReadSurvives() async { + let host = member("Host") + let newcomer = member("Newcomer", version: 2) + var rosters: GroupRosters! + var firstRead = true + rosters = GroupRosters { [group] _ in + if firstRead { firstRead = false; return [host] } + // The stream delivers the join after the read was pinned, so the page lacks them. + rosters.apply(.rosterChanged(conversationID: group, updates: [ + DecodedRosterUpdate(rosterSummary: ConversationRosterSummary(memberCount: 2, version: 5), change: .joined(member: newcomer, chat: nil)) + ])) + return [host] + } + await rosters.refresh(group) + await rosters.refresh(group) + + #expect(Set(rosters.members(for: group, creator: nil).map(\.displayName)) == ["Host", "Newcomer"]) + } } diff --git a/FlipcashTests/Chat/LobbyRequestsTests.swift b/FlipcashTests/Chat/LobbyRequestsTests.swift index 8eae8e80c..1d8764cea 100644 --- a/FlipcashTests/Chat/LobbyRequestsTests.swift +++ b/FlipcashTests/Chat/LobbyRequestsTests.swift @@ -64,6 +64,7 @@ struct LobbyRequestsTests { @Test("A full lobby throws the sheet's error and records nothing", arguments: [ (ErrorEnterLobby.lobbyFull, LobbyRequests.RequestError.lobbyFull), (ErrorEnterLobby.tooManyLobbies, LobbyRequests.RequestError.tooManyLobbies), + (ErrorEnterLobby.banned, LobbyRequests.RequestError.banned), ]) func fullLobbyThrows(server: ErrorEnterLobby, expected: LobbyRequests.RequestError) async { let id = ConversationID.test(3) diff --git a/FlipcashTests/Chat/MessageCapabilityMenuTests.swift b/FlipcashTests/Chat/MessageCapabilityMenuTests.swift index 00b3fecab..a38c0ae04 100644 --- a/FlipcashTests/Chat/MessageCapabilityMenuTests.swift +++ b/FlipcashTests/Chat/MessageCapabilityMenuTests.swift @@ -139,3 +139,47 @@ struct MessageCapabilityMenuTests { #expect(menu(controller, at: 0) == nil) } } + +@MainActor +@Suite("Long-press lift menu rows") +struct LiftMenuRowsTests { + + private func controller(actions: [MessageCapability]) -> ChatViewController { + let controller = ChatViewController() + controller.loadViewIfNeeded() + controller.update(items: [.message(ChatMessage(id: "1", text: "hi", sender: .other, actions: actions))]) + return controller + } + + @Test("The lift menu carries the moderation rows, which the context menu nests in an inline section") + func liftRowsIncludeModeration() { + let rows = controller(actions: [.copy, .reply, .forward, .report, .silence, .remove]) + .liftMenuActions(for: ChatMessage(id: "1", text: "hi", sender: .other, actions: [.copy, .reply, .forward, .report, .silence, .remove])) + #expect(rows.map(\.title).count == 6) + #expect(rows.map(\.title).first == "Copy") + #expect(rows.map(\.title).contains { $0.hasPrefix("Restrict") }) + #expect(rows.map(\.title).contains { $0.hasPrefix("Remove") }) + } + + @Test("A divider sets moderation apart from Report, and Remove does not add another") + func dividersAroundModeration() { + let report = UIAction(title: "Report", attributes: .destructive) { _ in } + let silence = UIAction(title: "Silence") { _ in } + let remove = UIAction(title: "Remove", attributes: .destructive) { _ in } + let copy = UIAction(title: "Copy") { _ in } + let rows = [copy, report, silence, remove] + let flat = MessageMenuView.size(for: [copy, report]) + // Two dividers (before Report, before Silence) over two extra rows. + #expect(MessageMenuView.size(for: rows).height - flat.height == CGFloat(2 * 44 + 13)) + } +} + +@MainActor +@Suite("Moderation row titles") +struct ModerationRowTitleTests { + @Test("Restrict and Remove name the sender with no ellipsis") + func titles() { + #expect(MessageCapability.silence.menuTitle(naming: "Ada") == "Restrict Ada") + #expect(MessageCapability.remove.menuTitle(naming: "Ada") == "Remove Ada") + } +} diff --git a/FlipcashTests/GroupModerationTests.swift b/FlipcashTests/GroupModerationTests.swift new file mode 100644 index 000000000..d7387a636 --- /dev/null +++ b/FlipcashTests/GroupModerationTests.swift @@ -0,0 +1,134 @@ +// +// GroupModerationTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Group moderation") +struct GroupModerationTests { + + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + private func user(silence: ConversationSilenceState? = nil, bannedAt: Date? = nil) -> ConversationRestrictedUser { + ConversationRestrictedUser(userID: UUID(), displayName: "Grampa", silence: silence, bannedAt: bannedAt) + } + + @Test("A silenced user is listed under Silenced only") + func silenced_onlySilenced() { + let u = user(silence: .forever) + #expect(u.belongs(on: .silenced, at: now)) + #expect(!u.belongs(on: .removed, at: now)) + } + + @Test("A user who is both banned and silenced appears only under Removed, marked also restricted") + func both_onlyRemoved() { + let u = user(silence: .forever, bannedAt: now) + #expect(!u.belongs(on: .silenced, at: now)) + #expect(u.belongs(on: .removed, at: now)) + #expect(u.detail(on: .removed, now: now).hasSuffix(" · also restricted")) + } + + @Test("A lapsed timed silence drops off the Silenced list") + func lapsed_dropsOff() { + let u = user(silence: .until(now.addingTimeInterval(-1))) + #expect(!u.belongs(on: .silenced, at: now)) + } + + @Test("Row details read forever and removed") + func details() { + #expect(user(silence: .forever).detail(on: .silenced, now: now) == "Restricted · forever") + #expect(user(bannedAt: now).detail(on: .removed, now: now).hasPrefix("Removed ")) + #expect(!user(bannedAt: now).detail(on: .removed, now: now).contains("also restricted")) + } + + @Test("Silence durations resolve to an end instant or forever") + func durations() { + #expect(SilenceDuration.oneHour.silence(from: now) == .until(now.addingTimeInterval(3600))) + #expect(SilenceDuration.oneWeek.silence(from: now) == .until(now.addingTimeInterval(7 * 86_400))) + #expect(SilenceDuration.forever.silence(from: now) == .forever) + #expect(SilenceDuration.allCases.map(\.title) == ["1 Hour", "8 Hours", "1 Week", "Forever"]) + } + + @Test("A silenced viewer's open composer becomes the silence pill until it lapses") + func gate_override() { + let silence = ConversationSilenceState.until(now.addingTimeInterval(60)) + #expect(ConversationGatePresentation.open.silenced(by: silence, at: now) == .readOnly(.silenced(silence))) + #expect(ConversationGatePresentation.open.silenced(by: silence, at: now.addingTimeInterval(61)) == .open) + #expect(ConversationGatePresentation.open.silenced(by: nil, at: now) == .open) + } + + @Test("The pill sentence names the end time, or says forever") + func sentence() { + #expect(ConversationGatePanel.silencedSentence(.forever, now: now) == "You can't send messages in this group") + #expect(ConversationGatePanel.silencedSentence(.until(now.addingTimeInterval(3600)), now: now).hasPrefix("You can't send messages until ")) + } + + @Test("Prompts use the member's first name and the group's name") + func copy() { + #expect(GroupModerationCopy.firstName(of: "Grampa Joe") == "Grampa") + #expect(GroupModerationCopy.removeTitle(firstName: "Grampa") == "Remove Grampa?") + #expect(GroupModerationCopy.bannedSubtitle(groupName: "Jeffy") == "The host removed you from Jeffy") + } + + @Test("Toast copy names the member and the duration") + func toastCopy() { + #expect(GroupModerationCopy.silencedToast(firstName: "McAnsh", duration: .oneHour) == "McAnsh can't send messages for 1 hour") + #expect(GroupModerationCopy.silencedToast(firstName: "McAnsh", duration: .eightHours) == "McAnsh can't send messages for 8 hours") + #expect(GroupModerationCopy.silencedToast(firstName: "McAnsh", duration: .oneWeek) == "McAnsh can't send messages for 1 week") + #expect(GroupModerationCopy.silencedToast(firstName: "McAnsh", duration: .forever) == "McAnsh can't send messages in this group") + #expect(GroupModerationCopy.unsilencedToast(firstName: "McAnsh") == "McAnsh can send messages again") + } + + @Test("The lifted toast fires once, on the way out of a silence") + func silenceLiftedOnce() { + let timed = ConversationSilenceState.until(now.addingTimeInterval(60)) + // The host unsilences, or the timer runs out: both end as an active silence becoming none. + #expect(GroupModerationCopy.silenceLifted(from: timed, to: nil)) + #expect(GroupModerationCopy.silenceLifted(from: .forever, to: nil)) + // Opened already unsilenced, and the later no-op re-evaluation that follows a lift. + #expect(!GroupModerationCopy.silenceLifted(from: nil, to: nil)) + // Being silenced, or silenced again for a different time, is not a lift. + #expect(!GroupModerationCopy.silenceLifted(from: nil, to: timed)) + #expect(!GroupModerationCopy.silenceLifted(from: timed, to: .forever)) + #expect(GroupModerationCopy.silenceLiftedToast == "You are now able to chat again") + } + + @Test("A silence change announces only after the server call succeeds") + func toastOnlyOnSuccess() async throws { + let server = FakeModeration() + var announced: [String] = [] + let moderation = GroupModeration( + moderation: server, + owner: .mock, + conversationID: ConversationID(data: Data(repeating: 1, count: 32)), + announce: { announced.append($0) } + ) + + try await moderation.silence(UUID(), firstName: "McAnsh", duration: .oneWeek) + try await moderation.unsilence(UUID(), firstName: "McAnsh") + #expect(announced == ["McAnsh can't send messages for 1 week", "McAnsh can send messages again"]) + + server.fails = true + await #expect(throws: (any Error).self) { try await moderation.silence(UUID(), firstName: "McAnsh", duration: .forever) } + await #expect(throws: (any Error).self) { try await moderation.unsilence(UUID(), firstName: "McAnsh") } + #expect(announced.count == 2) + } +} + +private final class FakeModeration: ConversationModeration, @unchecked Sendable { + struct Failure: Error {} + var fails = false + + func silenceUser(owner: KeyPair, conversationID: ConversationID, userID: UserID, silence: ConversationSilenceState) async throws { try check() } + func unsilenceUser(owner: KeyPair, conversationID: ConversationID, userID: UserID) async throws { try check() } + func banUser(owner: KeyPair, conversationID: ConversationID, userID: UserID) async throws { try check() } + func unbanUser(owner: KeyPair, conversationID: ConversationID, userID: UserID) async throws { try check() } + func getRestrictedUsers(owner: KeyPair, conversationID: ConversationID) async throws -> [ConversationRestrictedUser] { try check(); return [] } + + private func check() throws { if fails { throw Failure() } } +} diff --git a/FlipcashTests/ProfileMenuItemsTests.swift b/FlipcashTests/ProfileMenuItemsTests.swift index f4568773d..ea8c79290 100644 --- a/FlipcashTests/ProfileMenuItemsTests.swift +++ b/FlipcashTests/ProfileMenuItemsTests.swift @@ -38,4 +38,18 @@ struct ProfileMenuItemsTests { #expect(!ProfileMenuItem.unblock.isDestructive) #expect(!ProfileMenuItem.mute.isDestructive) } + + @Test("The host's section follows the viewer's flags") + func moderation_followsFlags() { + #expect(ProfileMenuItems.moderation(canSilence: true, canBan: true, isCreator: false, isSelf: false) == [.silence, .removeFromGroup]) + #expect(ProfileMenuItems.moderation(canSilence: true, canBan: false, isCreator: false, isSelf: false) == [.silence]) + #expect(ProfileMenuItems.moderation(canSilence: false, canBan: true, isCreator: false, isSelf: false) == [.removeFromGroup]) + #expect(ProfileMenuItems.moderation(canSilence: false, canBan: false, isCreator: false, isSelf: false).isEmpty) + } + + @Test("The group's creator and the viewer get no host section") + func moderation_neverOnCreatorOrSelf() { + #expect(ProfileMenuItems.moderation(canSilence: true, canBan: true, isCreator: true, isSelf: false).isEmpty) + #expect(ProfileMenuItems.moderation(canSilence: true, canBan: true, isCreator: false, isSelf: true).isEmpty) + } } diff --git a/FlipcashTests/SendAmountViewModelTests.swift b/FlipcashTests/SendAmountViewModelTests.swift index 99fdbc389..d4344218f 100644 --- a/FlipcashTests/SendAmountViewModelTests.swift +++ b/FlipcashTests/SendAmountViewModelTests.swift @@ -648,7 +648,7 @@ struct SendAmountViewModelTests { @Test("Only a profile reached by scanning a Flipcard sends a tip", arguments: [ (UserProfileOrigin.scan, TipOrigin.tipcard), (.directMessage, .chat), - (.groupMember, .chat), + (.groupMember(.test(1)), .chat), (.mention, .chat), (.deeplink, .chat), (.usernameLookup, .chat), diff --git a/FlipcashTests/UserProfileChatActionsTests.swift b/FlipcashTests/UserProfileChatActionsTests.swift index bd847359b..ddc822da8 100644 --- a/FlipcashTests/UserProfileChatActionsTests.swift +++ b/FlipcashTests/UserProfileChatActionsTests.swift @@ -23,7 +23,7 @@ struct UserProfileChatActionsTests { @Test( "Blocking from a chat-opened profile resets the stack, which can hold the blocked DM", - arguments: [UserProfileOrigin.directMessage, .groupMember, .mention, .scan, .usernameLookup] + arguments: [UserProfileOrigin.directMessage, .groupMember(.test(1)), .mention, .scan, .usernameLookup] ) func chatOrigins_blockResetsStack(_ origin: UserProfileOrigin) { #expect(!origin.blockReturnsToOpener) @@ -35,7 +35,7 @@ struct UserProfileChatActionsTests { #expect(UserProfileOrigin.scan.arrivesFetched) #expect(UserProfileOrigin.usernameLookup.arrivesFetched) #expect(!UserProfileOrigin.directMessage.arrivesFetched) - #expect(!UserProfileOrigin.groupMember.arrivesFetched) + #expect(!UserProfileOrigin.groupMember(.test(1)).arrivesFetched) #expect(!UserProfileOrigin.mention.arrivesFetched) #expect(!UserProfileOrigin.transaction.arrivesFetched) } @@ -43,7 +43,7 @@ struct UserProfileChatActionsTests { @Test("Open Chat returns to the DM only when the profile was opened from that DM") func openChat_returnsToDMOnlyFromDirectMessage() { #expect(UserProfileOrigin.directMessage.returnsToExistingDM) - #expect(!UserProfileOrigin.groupMember.returnsToExistingDM) + #expect(!UserProfileOrigin.groupMember(.test(1)).returnsToExistingDM) #expect(!UserProfileOrigin.mention.returnsToExistingDM) #expect(!UserProfileOrigin.deeplink.returnsToExistingDM) #expect(!UserProfileOrigin.scan.returnsToExistingDM) @@ -57,7 +57,7 @@ struct UserProfileChatActionsTests { func userProfile_originDistinguishesButDoesNotLog() { let userID = UUID() let fromDM = AppRouter.Destination.userProfile(userID, origin: .directMessage) - let fromGroup = AppRouter.Destination.userProfile(userID, origin: .groupMember) + let fromGroup = AppRouter.Destination.userProfile(userID, origin: .groupMember(.test(1))) #expect(fromDM != fromGroup) #expect(fromDM.description == "userProfile") #expect(fromGroup.description == "userProfile") diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 6091f128c..6b1dbd188 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -475,7 +475,7 @@ public final class ChatScreenViewController: UIViewController { private func presentLift(for message: ChatMessage, withMenu: Bool, press: CGPoint? = nil) { guard lift == nil, let scene = view.window?.windowScene else { return } let entries = stripEntries(for: message) - let actions = withMenu ? transcript.contextMenu(for: message)?.children.compactMap { $0 as? UIAction } : nil + let actions = withMenu ? transcript.liftMenuActions(for: message) : nil guard entries != nil || actions?.isEmpty == false, let (copy, frame) = transcript.beginLift(for: message, in: view) else { return } if withMenu { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 36e10cdbe..551c1add9 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -1781,9 +1781,10 @@ extension ChatViewController { let rowID = message.messageID let handler = onMessageAction - let children = message.actions.map { action in + let senderName = message.author?.firstName + func menuAction(_ action: MessageCapability) -> UIAction { UIAction( - title: action.title, + title: action.menuTitle(naming: senderName), image: UIImage(systemName: action.menuSymbol.rawValue), attributes: action.isDestructive ? .destructive : [] ) { _ in @@ -1791,14 +1792,37 @@ extension ChatViewController { case .copy: if let body { UIPasteboard.general.string = body } // The link rows need the chat's id, which this module never holds. - case .reply, .edit, .delete, .report, .forward: + case .reply, .edit, .delete, .report, .forward, .silence, .remove: handler?(rowID, action) } } } + + // Moderation sits apart from what anyone can do to a message, under its own divider. + let (moderation, everyday) = message.actions.reduce(into: ([MessageCapability](), [MessageCapability]())) { + if $1.isModeration { $0.0.append($1) } else { $0.1.append($1) } + } + var children: [UIMenuElement] = everyday.map(menuAction) + if !moderation.isEmpty { + children.append(UIMenu(title: "", options: .displayInline, children: moderation.map(menuAction))) + } return UIMenu(title: "", children: children) } + /// The rows the long-press lift menu draws for `message`. The lift draws a flat list, so the + /// inline section that holds the moderation rows is flattened into it; reading only the top + /// level would drop Silence and Remove. + func liftMenuActions(for message: ChatMessage) -> [UIAction] { + func flatten(_ elements: [UIMenuElement]) -> [UIAction] { + elements.flatMap { element -> [UIAction] in + if let action = element as? UIAction { return [action] } + if let menu = element as? UIMenu { return flatten(menu.children) } + return [] + } + } + return flatten(contextMenu(for: message)?.children ?? []) + } + private func isInReactionRow(_ point: CGPoint) -> Bool { var view = collectionView.hitTest(point, with: nil) while let current = view { @@ -1981,7 +2005,7 @@ protocol BubbleCarrying { } #endif -private extension MessageCapability { +extension MessageCapability { /// The menu row's glyph. Lives here rather than on the action itself because `SystemSymbol` is /// this module's symbol registry, and `MessageCapability` is a core model. @@ -1993,6 +2017,27 @@ private extension MessageCapability { case .delete: .trash case .report: .exclamationBubble case .forward: .forwardArrow + case .silence: .speakerSlash + case .remove: .personBadgeMinus + } + } + + var isModeration: Bool { + switch self { + case .silence, .remove: true + case .copy, .reply, .edit, .delete, .report, .forward: false + } + } + + /// The row's label, naming the sender on the host's two rows: "Silence Grampa…". The ellipsis + /// says a choice follows, the way Silence's duration sheet and Remove's confirm do. + func menuTitle(naming senderName: String?) -> String { + switch self { + case .silence, .remove: + guard let senderName else { return title } + return "\(title) \(senderName)" + case .copy, .reply, .edit, .delete, .report, .forward: + return title } } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/MessageMenuView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/MessageMenuView.swift index fa4ce1aa0..e83b645ed 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/MessageMenuView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/MessageMenuView.swift @@ -7,6 +7,7 @@ #if canImport(UIKit) import UIKit +import SwiftUI /// The action list under a long-pressed bubble, drawn like a system context menu on a Liquid Glass /// platter. Rows come from the message's `UIAction`s, so the menu and its tests share one mapping. @@ -34,7 +35,7 @@ final class MessageMenuView: UIView { /// The menu's size for `actions`, with room for `header` above them when there is one. static func size(for actions: [UIAction], header: String? = nil) -> CGSize { - let dividers: CGFloat = dividerIndex(in: actions) == nil ? 0 : 1 + let dividers = CGFloat(dividerIndices(in: actions).count) let headerRow: CGFloat = header == nil ? 0 : headerHeight return CGSize( width: width, @@ -42,13 +43,21 @@ final class MessageMenuView: UIView { ) } - /// The index of the first destructive action when a non-destructive one comes before it, which - /// is where the divider goes. - private static func dividerIndex(in actions: [UIAction]) -> Int? { - guard let index = actions.firstIndex(where: { $0.attributes.contains(.destructive) }), index > 0 else { - return nil + /// Where the dividers go: before the first destructive action when a non-destructive one comes + /// before it, and before any non-destructive action that follows a destructive one. That second + /// case is the moderation rows, which trail Report; the destructive Remove that follows Silence + /// stays in their group. + private static func dividerIndices(in actions: [UIAction]) -> Set { + var indices: Set = [] + var seenDestructive = actions.first?.attributes.contains(.destructive) ?? false + for (index, action) in actions.enumerated() where index > 0 { + let destructive = action.attributes.contains(.destructive) + let previous = actions[index - 1].attributes.contains(.destructive) + if destructive, !seenDestructive, !previous { indices.insert(index) } + if !destructive, previous { indices.insert(index) } + seenDestructive = seenDestructive || destructive } - return index + return indices } /// - Parameter header: the message's full send time, drawn muted above the actions; nil draws @@ -72,7 +81,7 @@ final class MessageMenuView: UIView { addSubview(surface) let content = (surface as? UIVisualEffectView)?.contentView ?? surface - let dividerIndex = Self.dividerIndex(in: actions) + let dividerIndices = Self.dividerIndices(in: actions) var y = Self.verticalInset if let header { let label = UILabel(frame: CGRect( @@ -91,7 +100,7 @@ final class MessageMenuView: UIView { y += Self.headerHeight } for (index, action) in actions.enumerated() { - if index == dividerIndex { + if dividerIndices.contains(index) { let hairline: CGFloat = 0.5 let divider = UIView(frame: CGRect( x: Self.horizontalInset, @@ -120,7 +129,7 @@ final class MessageMenuView: UIView { private static func row(for action: UIAction) -> UIControl { let destructive = action.attributes.contains(.destructive) - let tint: UIColor = destructive ? .systemRed : .label + let tint: UIColor = destructive ? UIColor(Color.textError) : .label let row = MenuRow() row.accessibilityLabel = action.title row.accessibilityTraits = .button diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift index 3a96362bc..932d4e67d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift @@ -89,6 +89,8 @@ public enum SystemSymbol: String { case checkmark = "checkmark" case speakerSlash = "speaker.slash.fill" + /// A person with a minus, for taking someone out of a group. + case personBadgeMinus = "person.badge.minus" case lockDashed = "lock.app.dashed" } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/EmptyStateMessage.swift b/FlipcashUI/Sources/FlipcashUI/Views/EmptyStateMessage.swift new file mode 100644 index 000000000..ed75bf98a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Views/EmptyStateMessage.swift @@ -0,0 +1,38 @@ +// +// EmptyStateMessage.swift +// FlipcashUI +// + +import SwiftUI + +/// The centered title and explanation a settings-style list shows when it has nothing to list, as +/// on Blocked Users and Trusted Websites. +public struct EmptyStateMessage: View { + + private let title: String + private let message: String? + + // MARK: - Init - + + public init(_ title: String, message: String? = nil) { + self.title = title + self.message = message + } + + // MARK: - Body - + + public var body: some View { + VStack(spacing: 8) { + Text(title) + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + if let message { + Text(message) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + } + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } +} diff --git a/docs/specs/group-moderation/design-note.md b/docs/specs/group-moderation/design-note.md new file mode 100644 index 000000000..97786dd50 --- /dev/null +++ b/docs/specs/group-moderation/design-note.md @@ -0,0 +1,120 @@ +# Group moderation (silence, remove, block) — cross-platform exploration + +Status: exploration — design only, not scheduled +Platforms: iOS (`code-ios-app`), Android (`code-android-app`) +Also touches: (read only: both apps pin `flipcash2-client-protocol` 0.21.0 and wrap every RPC + used here, Android in #1840 and iOS in #1125; no contract change) +Spec: `docs/specs/group-moderation.md` +Figma: file `Jxf2wD9QyfKaONbhHSdU0K`, page `(Brandon) Mute / Unmute Group Chat Participants [Oct 22]` (node `12419:77643`) + Sections: real-app references `12758:56`, concepts `12766:63`, spec `12770:202`, + components `12759:56`, Mobbin `12758:74`, decided against `12770:205` + +A group host can't stop one member from posting or put them out of the group. The server can +do both: 0.21.0 added `SilenceUser`/`UnsilenceUser`, `BanUser`/`UnbanUser` and +`GetRestrictedUsers` to `chat.v1.Chat`. Android wraps them in `ChatController` (#1840), iOS in +`FlipClient+Chat` (#1125), and nothing on either platform calls them. This note maps Telegram's model onto that contract and +proposes where the actions live in the app. + +## What other apps do + +- **Telegram:** admins restrict a member per permission (send messages, media, links) for a + set time or forever, and Remove puts them on a Removed Users list that admins manage from + group settings. The transcript shows "X removed Y". +- **WhatsApp:** group permissions are group-wide toggles (who can send, who can edit info), not + per member. Admins remove a member, who can be re-added. +- **Believe:** a holder gate on the composer explains why a member can't post. +- **Kraken:** blocking a contact explains what happens to payments from them. +- **Airtasker:** a blocked conversation replaces the composer with a one-line reason. + +## Words + +Flipcash already uses "Mute" for notifications: `MuteChat`, the Mute Notifications row in the +group and profile menus, and `MuteChatSheet`. Telegram makes the same split: notification +mute is per viewer, and restricting a member is a moderation action. The per-member action +here is **Silence**, the contract's word, so "Mute" keeps one meaning. + +| Telegram | Flipcash contract | Shown as | +|---|---|---| +| Mute notifications | `MuteChat` / `ViewerState.Settings.mute` | Mute Notifications (shipped) | +| Restrict "send messages" | `SilenceUser`, `SilenceState` until or forever | Silence | +| Remove from group | none. `BanUser` is the only removal | Remove from Group | +| Ban | `BanUser` / `UnbanUser` | Remove from Group, Unban | +| Block | `BlockUser`, global blocklist | Block (shipped) | +| Admins and custom rights | `ModerationPermissions{can_silence, can_ban}`, creator only | not exposed | + +Remove and ban are one action, because the contract has no kick-without-ban. The copy has to +say the person can't rejoin. + +## What each side sees + +| | Host | Silenced member | Removed member | Everyone else | +|---|---|---|---|---| +| Event | RPC result | `ViewerStateChanged` with `Restrictions.silence` | plain `RosterUpdate.MemberLeft` | `MemberLeft` for a removal, nothing for a silence | +| Composer | normal | gate pill, C5 | chat leaves the list | normal | +| Learns why | n/a | from the pill | from `JoinChat` `BANNED` or `EnterLobby` `BANNED`, C6 | never | +| Messages | stay | stay | stay | stay | + +A timed silence ends with no event. The client starts a timer from `SilenceState.until` to swap +the pill back. `SendMessage` from a silenced member returns plain `DENIED`, so the pill has to +come from `ViewerState` and not from a failed send. + +Block does not filter group transcripts. On iOS, the conversation rendering doesn't read the +blocklist. On Android, `Sender.isBlocked` is read only by the profile screen. The contract lists block's effects as `Metadata.is_hidden` on the DM plus +exclusion from `SampleChatters` and mention suggestions. A member who blocks someone in a group +still sees their messages there. The July Block page copy ("you won't see messages from them") +was written for a 1:1 chat, and reads as a promise in a group. + +## Concepts + +Each frame is built from layers, not a screenshot: an instance of a base screen (group +transcript, member profile, group profile) plus the change, using components already in the file. +The bases were drawn from captures of build 272 (2026.10.5), which stay in the reference rows. +The Android frames (AC1–AC8b, section `12782:224`) use the same bases at 412×915 with Android +chrome: the selection top bar, the Material dropdown, and the bottom-bar alert. In the captures, +the test account isn't the host of Jeffy | All Holders. The frames show it as the host. + +| Frame | Node | What changes | Seam | +|---|---|---|---| +| C1 Long-press a member's message | `12766:64` | Silence Grampa… and Remove Grampa… after Report | `MessageCapability` gains two cases at the end of its fixed order | +| C2 Member profile menu | `12766:139` | An "In " section with Silence… and Remove from Group | `ProfileMenuItems`, origin `.groupMember` | +| C3 Silence duration sheet | `12766:201` | `MuteChatSheet` layout: 1 Hour, 8 Hours, 1 Week, Forever | `SilenceState` is the same until-or-forever oneof as `MuteState` | +| C4 Remove confirm | `12766:238` | `DialogItem.alert`, destructive Remove plus Cancel | same call as `UserProfileScreen.blockDialog()` | +| C5 Silenced member's composer | `12766:250` | "You're silenced until 6:02 PM", or "You're silenced in this group" | a new sentence for `ConversationGatePanel.readOnlyPill` | +| C6 Removed member rejoins | `12766:256` | `DialogItem.alert`: "You Can't Join This Group", "The host removed you from ", OK | the `JoinChat` `BANNED` result | +| C7a Group overflow | `12766:268` | Host-only Moderation row, opening a submenu | `GroupProfileMenuItems` / `ChatProfileScreen` overflow | +| C7b Moderation submenu | `12768:192` | Silenced and Removed | iOS: a nested `Menu`, drawn in place. Android: Material 3 has no submenu, so a `DropdownMenu` that swaps its items in place | +| C8a Silenced list | `12766:319` | Silenced members, with Unsilence | `GetRestrictedUsers`, filtered to `silence` set and `ban` unset | +| C8b Removed list | `12768:245` | Removed members, with Unban | `GetRestrictedUsers`, filtered to `ban` set | + +One Moderation entry keeps the group overflow menu short, since a member never sees it and a +host rarely opens it. The submenu splits the two lists so each row has a single action. A person +who is both silenced and removed appears only under Removed, and their row says so. + +C6 names the host as the reason. The server already tells the client through a dedicated +`BANNED` result, so hiding the reason would protect nothing. + +Every action is gated on `ViewerState.permissions.moderation` and hidden on the creator's own +row, since the server answers `CANNOT_RESTRICT_CREATOR`. A silence picked from C3 applies +immediately with no confirm, matching Mute Notifications. Remove always confirms. + +## Not in the contract + +These need a server change. The concepts avoid depending on them. + +- **No system message.** `SystemContent` has only `UserJoined`, so the rest of the group sees + someone disappear from the roster with no line in the transcript. Telegram shows "X removed Y". +- **No `SILENCED` send result.** A client that misses `ViewerStateChanged` shows a generic error. +- **No kick-without-ban.** "Remove" can't mean "leave, but you may come back". +- **No roles.** `Member.version` comments name roles as a future field. Until then, only the + creator moderates, and only the creator can admit to a private group. +- **No moderator delete.** `DeleteMessage` is sender only. A host can remove a spammer but can't + take down what they posted. +- **No rekey on removal.** A private group's chat key never changes, so a removed member keeps + the key for anything already fetched. The server stops serving them new content. + +## Open questions + +- Should Unban also offer to re-admit the person? `UnbanUser` publishes nothing and doesn't + rejoin them, so today they have to find the group and join again. +- Which pickers drop silenced and removed members? `group-send.md` and `group-requests.md` + don't say whether a silenced member can be sent cash or asked to pay. diff --git a/docs/specs/group-moderation/frames/C1.context.md b/docs/specs/group-moderation/frames/C1.context.md new file mode 100644 index 000000000..b69ab1e2e --- /dev/null +++ b/docs/specs/group-moderation/frames/C1.context.md @@ -0,0 +1,31 @@ +# C1 · C1 · Host long-presses a member's message + +- Frame: `C1 · Host long-presses a member's message` +- Node id: `12766:64` +- Size: 402x874 +- Screenshot: `C1.png` +- Caption: Host long-presses Grampa's message. Silence and Remove follow Report at the end of the MessageCapability order, shown only when ViewerState.permissions.moderation allows it and the sender isn't the creator. + +Coordinates are `@x,y WxH` in frame pixels (points on iOS, dp on Android) relative to the frame's top-left. `AL=V|H` is auto-layout direction; `pad` is top/right/bottom/left. `{name}` is a bound Figma variable (see tokens.md). Text rows list style name, or weight+size when no style is bound. The base screen is an instance of a shared base component and is not described beyond its name. + +## Layers +``` +Base: Transcript (blurred) [Base / iOS · Group transcript] @0,0 402x874, LAYER_BLUR +Dim @0,0 402x874 fill=#000000@0.35 +Reactions bar [12791:127] @45,372 243x45 fill=#38383b r=28 AL=H gap=14 pad=8/14/8/14; six emoji TEXT, App/DisplaySmall 24, at x=59,98,136,174,212,250 (heart thumbsup joy open_mouth cry fire) +Lifted message · Grampa [12791:134] @45,427 280x94 fill={bubble/other} r=18 AL=V gap=8 pad=10/14/10/14 + Reply quote @59,437 252x47 fill={bubble/other} r=8 AL=V gap=2 pad=6/8/6/10 + "Jeff Yanta" App/TextSmall fill={badge/focus} + "A bunch of new features in the pipeline for private groups are all server ready as of today" App/Default13Medium fill={textSecondary} + "Flipcash on fire" App/TextMessage fill={textMain} +UIMenu "System UIMenu (4 actions)" [12766:66] @45,531 248x292 fill=#3d3d40@0.96 r=26 AL=V gap=0 pad=6/0/6/0 fx=BACKGROUND_BLUR,DROP_SHADOW + Row Copy @45,537 248x44 icon doc.on.doc "Copy" fill={textMain} + Row Reply @45,581 248x44 icon arrowshape.turn.up.left.fill "Reply" fill={textMain} + Row Forward @45,625 248x44 icon arrowshape.turn.up.right "Forward" fill={textMain} + Separator @45,669 248x8 (1px line #ffffff@0.12) + Row Report @45,677 248x44 icon exclamationmark.bubble "Report" fill={Red} + Separator @45,721 248x8 + Row Silence (NEW) [12766:124] @45,729 248x44 icon speaker.slash "Silence Grampa…" fill={textMain} + Row Remove (NEW) [12766:131] @45,773 248x44 icon person.badge.minus "Remove Grampa…" fill={Red} +Row anatomy: icon @x+20, 24x24, vertically centered; title @x+60; Title text Inter Regular 17. +``` diff --git a/docs/specs/group-moderation/frames/C1.png b/docs/specs/group-moderation/frames/C1.png new file mode 100644 index 000000000..868a731dd Binary files /dev/null and b/docs/specs/group-moderation/frames/C1.png differ diff --git a/docs/specs/group-moderation/frames/C2.context.md b/docs/specs/group-moderation/frames/C2.context.md new file mode 100644 index 000000000..d9d0b470a --- /dev/null +++ b/docs/specs/group-moderation/frames/C2.context.md @@ -0,0 +1,25 @@ +# C2 · C2 · Member profile menu (host) + +- Frame: `C2 · Member profile menu (host)` +- Node id: `12766:139` +- Size: 402x874 +- Screenshot: `C2.png` +- Caption: Profile opened from a group member (origin .groupMember). A host section names the group, since Silence and Remove act on that group only. Block stays global. Hidden when the profile is the group's creator. + +Coordinates are `@x,y WxH` in frame pixels (points on iOS, dp on Android) relative to the frame's top-left. `AL=V|H` is auto-layout direction; `pad` is top/right/bottom/left. `{name}` is a bound Figma variable (see tokens.md). Text rows list style name, or weight+size when no style is bound. The base screen is an instance of a shared base component and is not described beyond its name. + +## Layers +``` +Base: [Base / iOS · Member profile (Grampa)] @0,0 402x874 +UIMenu [12766:141] @140,60 248x280 fill=#3d3d40@0.96 r=26 AL=V gap=0 pad=6/0/6/0 fx=BACKGROUND_BLUR,DROP_SHADOW + Row Mute Notifications @140,66 248x44 icon bell.slash "Mute Notifications" fill={textMain} + Separator @140,110 248x8 + Row Report @140,118 248x44 icon exclamationmark.bubble "Report" fill={Red} + Row Block @140,162 248x44 icon nosign "Block" fill={Red} + Separator @140,206 248x8 + Section title (NEW) [System Menu / Section title] @140,214 248x32 pad=10/20/6/20 + "In Jeffy | All Holders" App/Default13Medium fill=#ffffff@0.50 (text @160,224) + Row Silence… (NEW) @140,246 248x44 icon speaker.slash "Silence…" fill={textMain} + Row Remove from Group (NEW) @140,290 248x44 icon person.badge.minus "Remove from Group" fill={Red} +The section title text is the group name. +``` diff --git a/docs/specs/group-moderation/frames/C2.png b/docs/specs/group-moderation/frames/C2.png new file mode 100644 index 000000000..0a3a633b3 Binary files /dev/null and b/docs/specs/group-moderation/frames/C2.png differ diff --git a/docs/specs/group-moderation/frames/C3.context.md b/docs/specs/group-moderation/frames/C3.context.md new file mode 100644 index 000000000..c1289eda2 --- /dev/null +++ b/docs/specs/group-moderation/frames/C3.context.md @@ -0,0 +1,25 @@ +# C3 · C3 · Silence duration sheet + +- Frame: `C3 · Silence duration sheet` +- Node id: `12766:201` +- Size: 402x874 +- Screenshot: `C3.png` +- Caption: "Silence…" opens MuteChatSheet's layout with silence copy. SilenceState is the same until|forever oneof as MuteState, so the durations carry over; Always becomes Forever. + +Coordinates are `@x,y WxH` in frame pixels (points on iOS, dp on Android) relative to the frame's top-left. `AL=V|H` is auto-layout direction; `pad` is top/right/bottom/left. `{name}` is a bound Figma variable (see tokens.md). Text rows list style name, or weight+size when no style is bound. The base screen is an instance of a shared base component and is not described beyond its name. + +## Layers +``` +Base: [Base / iOS · Group transcript] @0,0 402x874 +Scrim @0,0 402x874 fill=#000000@0.50 +Silence sheet [12766:203] @8,440 386x426 fill={backgroundMain} r=36 AL=V gap=12 pad=56/16/24/16 + Header @24,496 354x44 + "Silence Grampa" Semi Bold 16 (no style) fill={textMain}, centered @140,508 122x19 + Close: Ellipse @342,500 36x36 fill=#ffffff@0.15 + icon xmark 16x16 @352,510 + Body "They can still read and react in Jeffy | All Holders, but can't send messages. Only they're told" App/TextSmall fill={textSecondary} @24,552 354x34 + Sheet / Duration row ×4 (each 354x52 fill={backgroundRow} r=12 AL=H gap=12 pad=0/16/0/16; icon 24x24 @x+17; label @x+53, App/TextMedium fill={textMain}); row gap 12 + @24,598 icon clock "1 Hour" + @24,662 icon clock "8 Hours" + @24,726 icon clock "1 Week" + @24,790 icon nosign "Forever" (last row) +``` diff --git a/docs/specs/group-moderation/frames/C3.png b/docs/specs/group-moderation/frames/C3.png new file mode 100644 index 000000000..4b0fc7fd5 Binary files /dev/null and b/docs/specs/group-moderation/frames/C3.png differ diff --git a/docs/specs/group-moderation/frames/C4.context.md b/docs/specs/group-moderation/frames/C4.context.md new file mode 100644 index 000000000..c6717de09 --- /dev/null +++ b/docs/specs/group-moderation/frames/C4.context.md @@ -0,0 +1,22 @@ +# C4 · C4 · Remove confirm + +- Frame: `C4 · Remove confirm` +- Node id: `12766:238` +- Size: 402x874 +- Screenshot: `C4.png` +- Caption: DialogItem.alert, the same call as blockDialog(). Copy says they can't rejoin, since the contract has no kick-without-ban. + +Coordinates are `@x,y WxH` in frame pixels (points on iOS, dp on Android) relative to the frame's top-left. `AL=V|H` is auto-layout direction; `pad` is top/right/bottom/left. `{name}` is a bound Figma variable (see tokens.md). Text rows list style name, or weight+size when no style is bound. The base screen is an instance of a shared base component and is not described beyond its name. + +## Layers +``` +Base: [Base / iOS · Member profile (Grampa)] @0,0 402x874 +Scrim @0,0 402x874 fill=#000000@0.45 +Dialog · Remove [12766:241] @8,608 386x258 fill=#bc3434 AL=V gap=20 pad=30/16/16/16 + Text group @24,638 354x64 AL=V gap=6 + "Remove Grampa?" App/TextLarge fill=#ffffff @24,638 354x24 + "They'll leave Jeffy | All Holders and can't rejoin until you unban them. Their messages stay in the chat" App/TextSmall fill=#ffffff @24,668 354x34 + Button group @24,722 354x128 AL=V gap=8 + Primary button (normal-button) @24,722 354x60 fill=#ffffff r=6; label "Remove" App/TextMedium fill=#bc3434 + Secondary button @24,790 354x60 no fill; label "Cancel" App/TextMedium fill=#ffffff@0.50 +``` diff --git a/docs/specs/group-moderation/frames/C4.png b/docs/specs/group-moderation/frames/C4.png new file mode 100644 index 000000000..690ab55f5 Binary files /dev/null and b/docs/specs/group-moderation/frames/C4.png differ diff --git a/docs/specs/group-moderation/frames/C5.context.md b/docs/specs/group-moderation/frames/C5.context.md new file mode 100644 index 000000000..73b3f302b --- /dev/null +++ b/docs/specs/group-moderation/frames/C5.context.md @@ -0,0 +1,18 @@ +# C5 · C5 · Silenced member's composer + +- Frame: `C5 · Silenced member's composer` +- Node id: `12766:250` +- Size: 402x874 +- Screenshot: `C5.png` +- Caption: Driven by ViewerState.restrictions.silence, not a send failure (SendMessage returns plain DENIED). A timed silence lapses with no event, so the client swaps the pill back at `until`. + +Coordinates are `@x,y WxH` in frame pixels (points on iOS, dp on Android) relative to the frame's top-left. `AL=V|H` is auto-layout direction; `pad` is top/right/bottom/left. `{name}` is a bound Figma variable (see tokens.md). Text rows list style name, or weight+size when no style is bound. The base screen is an instance of a shared base component and is not described beyond its name. + +## Layers +``` +Base: [Base / iOS · Group transcript] @0,0 402x874 (composer replaced by the pill) +Gate pill · Silenced until [12766:253] @36,788 330x50 AL=H + Glass button "Xmark Button" @36,788 330x50 fill=#1e1e1e@0.46 r=14 fx=GLASS pad=8/8/8/16 + Label "You're silenced until 6:02 PM" App/TextMedium fill=#ffffff, layer opacity 0.40 (disabled look), centered, text box 224x19 @93,804 +Untimed form should read "You're silenced" (copy not drawn in this frame). +``` diff --git a/docs/specs/group-moderation/frames/C5.png b/docs/specs/group-moderation/frames/C5.png new file mode 100644 index 000000000..f235c7967 Binary files /dev/null and b/docs/specs/group-moderation/frames/C5.png differ diff --git a/docs/specs/group-moderation/frames/C6.context.md b/docs/specs/group-moderation/frames/C6.context.md new file mode 100644 index 000000000..8fb49bb01 --- /dev/null +++ b/docs/specs/group-moderation/frames/C6.context.md @@ -0,0 +1,21 @@ +# C6 · C6 · Removed member tries to rejoin + +- Frame: `C6 · Removed member tries to rejoin` +- Node id: `12766:256` +- Size: 402x874 +- Screenshot: `C6.png` +- Caption: The removed member only got a plain MemberLeft. They learn why when JoinChat returns BANNED (EnterLobby BANNED for private groups). + +Coordinates are `@x,y WxH` in frame pixels (points on iOS, dp on Android) relative to the frame's top-left. `AL=V|H` is auto-layout direction; `pad` is top/right/bottom/left. `{name}` is a bound Figma variable (see tokens.md). Text rows list style name, or weight+size when no style is bound. The base screen is an instance of a shared base component and is not described beyond its name. + +## Layers +``` +Base: [Base / iOS · Group profile (Jeffy)] @0,0 402x874 +Scrim @0,0 402x874 fill=#000000@0.45 +Dialog [12766:259] @8,679 386x187 fill=#bc3434 AL=V gap=20 pad=30/16/30/16 + Text group @24,709 354x47 AL=V gap=6 + "You Can't Join This Group" App/TextLarge fill=#ffffff + "The host removed you from Jeffy | All Holders" App/TextSmall fill=#ffffff + Primary button @24,776 354x60 fill=#ffffff r=6; label "OK" App/TextMedium fill=#bc3434 +Single button, no cancel. +``` diff --git a/docs/specs/group-moderation/frames/C6.png b/docs/specs/group-moderation/frames/C6.png new file mode 100644 index 000000000..be10b36d9 Binary files /dev/null and b/docs/specs/group-moderation/frames/C6.png differ diff --git a/docs/specs/group-moderation/frames/C7a.context.md b/docs/specs/group-moderation/frames/C7a.context.md new file mode 100644 index 000000000..662ae37f0 --- /dev/null +++ b/docs/specs/group-moderation/frames/C7a.context.md @@ -0,0 +1,21 @@ +# C7a · C7a · Group overflow (host) + +- Frame: `C7a · Group overflow (host)` +- Node id: `12766:268` +- Size: 402x874 +- Screenshot: `C7a.png` +- Caption: Host-only Moderation submenu in the group overflow. Members never see it. iOS: a nested Menu, drawn natively. Android: Material 3 has no submenu, so the DropdownMenu swaps its items in place, with a back row. + +Coordinates are `@x,y WxH` in frame pixels (points on iOS, dp on Android) relative to the frame's top-left. `AL=V|H` is auto-layout direction; `pad` is top/right/bottom/left. `{name}` is a bound Figma variable (see tokens.md). Text rows list style name, or weight+size when no style is bound. The base screen is an instance of a shared base component and is not described beyond its name. + +## Layers +``` +Base: [Base / iOS · Group profile (Jeffy)] @0,0 402x874 +UIMenu [12766:270] @140,60 248x196 fill=#3d3d40@0.96 r=26 AL=V pad=6/0/6/0 fx=BACKGROUND_BLUR,DROP_SHADOW + Row Encryption @140,66 248x44 icon lock.open "Encryption" fill={textMain} + Row Mute Notifications @140,110 248x44 icon bell.slash "Mute Notifications" fill={textMain} + Row Moderation (NEW) [12768:186] @140,154 248x44 AL=H gap=16 pad=0/20/0/20 + icon shield 24x24 @160,164; "Moderation" Regular 17 fill={textMain} @200,166; trailing chevron.right 14x14 @354,169 (glyph 4x8, opacity 0.60) + Separator @140,198 248x8 + Row Report @140,206 248x44 icon exclamationmark.bubble "Report" fill={Red} +``` diff --git a/docs/specs/group-moderation/frames/C7a.png b/docs/specs/group-moderation/frames/C7a.png new file mode 100644 index 000000000..b66547b49 Binary files /dev/null and b/docs/specs/group-moderation/frames/C7a.png differ diff --git a/docs/specs/group-moderation/frames/C7b.context.md b/docs/specs/group-moderation/frames/C7b.context.md new file mode 100644 index 000000000..c15e28eaa --- /dev/null +++ b/docs/specs/group-moderation/frames/C7b.context.md @@ -0,0 +1,19 @@ +# C7b · C7b · Moderation submenu open + +- Frame: `C7b · Moderation submenu open` +- Node id: `12768:192` +- Size: 402x874 +- Screenshot: `C7b.png` +- Caption: The submenu opens in place. Silenced and Removed each open their own list, filtered client-side from one GetRestrictedUsers page. + +Coordinates are `@x,y WxH` in frame pixels (points on iOS, dp on Android) relative to the frame's top-left. `AL=V|H` is auto-layout direction; `pad` is top/right/bottom/left. `{name}` is a bound Figma variable (see tokens.md). Text rows list style name, or weight+size when no style is bound. The base screen is an instance of a shared base component and is not described beyond its name. + +## Layers +``` +Base: [Base / iOS · Group profile (Jeffy)] @0,0 402x874 +UIMenu [12768:193] @140,60 248x152 fill=#3d3d40@0.96 r=26 AL=V pad=6/0/6/0 fx=BACKGROUND_BLUR,DROP_SHADOW + Header row "Moderation" [12768:221] @140,66 248x44: icon shield @160,76; title Semi Bold 17 fill={textMain} @200,78; trailing chevron.down 14x14 @354,81 (glyph 8x4, opacity 0.60) -- expanded state; the chevron points down instead of right + Separator @140,110 248x8 + Row Silenced @140,118 248x44 icon speaker.slash "Silenced" Regular 17 fill={textMain} + Row Removed @140,162 248x44 icon person.badge.minus "Removed" Regular 17 fill={textMain} +``` diff --git a/docs/specs/group-moderation/frames/C7b.png b/docs/specs/group-moderation/frames/C7b.png new file mode 100644 index 000000000..6ef0ddb78 Binary files /dev/null and b/docs/specs/group-moderation/frames/C7b.png differ diff --git a/docs/specs/group-moderation/frames/C8a.context.md b/docs/specs/group-moderation/frames/C8a.context.md new file mode 100644 index 000000000..994abb127 --- /dev/null +++ b/docs/specs/group-moderation/frames/C8a.context.md @@ -0,0 +1,22 @@ +# C8a · C8a · Silenced list + +- Frame: `C8a · Silenced list` +- Node id: `12766:319` +- Size: 402x874 +- Screenshot: `C8a.png` +- Caption: Silenced members from GetRestrictedUsers. A timed row counts down on the client, because a silence ends with no event. + +Coordinates are `@x,y WxH` in frame pixels (points on iOS, dp on Android) relative to the frame's top-left. `AL=V|H` is auto-layout direction; `pad` is top/right/bottom/left. `{name}` is a bound Figma variable (see tokens.md). Text rows list style name, or weight+size when no style is bound. The base screen is an instance of a shared base component and is not described beyond its name. + +## Layers +``` +Screen bg {backgroundMain}; no base instance (full screen) +Nav: back Ellipse @16,60 40x40 fill=#ffffff@0.12 + chevron.left 20x20 @25,70; title "Silenced" Semi Bold 16 (no style) fill={textMain} @168,70 67x19 (centered) +Status Bar - iPhone @0,0 402x50 +Restricted member row (component set property Kind) 370x60 AL=H gap=12 pad=10/16/10/16 + Row 1 [Kind=Silenced] @16,124 + Row 2 [Kind=Silenced forever] @16,188 (rows stack at 64pt pitch) + Anatomy: Alert/Avatar 40x40 r=20 fill=#e8996b @x+16; text column AL=V gap=2 @x+68 (name App/TextMedium fill={textMain}; status App/Default13Medium fill={textSecondary}); trailing pill AL=H pad=8/14/8/14 fill=#ffffff@0.12 r=16 95x33 with "Unsilence" App/TextSmall fill={textMain}, right edge at x+354 + Row 1 text: "Grampa" / "Silenced · until 6:02 PM" + Row 2 text: "Web3theo" / "Silenced" (untimed = forever) +``` diff --git a/docs/specs/group-moderation/frames/C8a.png b/docs/specs/group-moderation/frames/C8a.png new file mode 100644 index 000000000..2ea5a4f3d Binary files /dev/null and b/docs/specs/group-moderation/frames/C8a.png differ diff --git a/docs/specs/group-moderation/frames/C8b.context.md b/docs/specs/group-moderation/frames/C8b.context.md new file mode 100644 index 000000000..9f56c2f9e --- /dev/null +++ b/docs/specs/group-moderation/frames/C8b.context.md @@ -0,0 +1,20 @@ +# C8b · C8b · Removed list + +- Frame: `C8b · Removed list` +- Node id: `12768:245` +- Size: 402x874 +- Screenshot: `C8b.png` +- Caption: Removed members. Ban wins the row when someone is both. Unban publishes nothing and doesn't rejoin them. + +Coordinates are `@x,y WxH` in frame pixels (points on iOS, dp on Android) relative to the frame's top-left. `AL=V|H` is auto-layout direction; `pad` is top/right/bottom/left. `{name}` is a bound Figma variable (see tokens.md). Text rows list style name, or weight+size when no style is bound. The base screen is an instance of a shared base component and is not described beyond its name. + +## Layers +``` +Screen bg {backgroundMain}; no base instance (full screen) +Nav: back Ellipse @16,60 40x40 fill=#ffffff@0.12 + chevron.left 20x20 @25,70; title "Removed" Semi Bold 16 (no style) fill={textMain} @165,70 73x19 (centered) +Status Bar - iPhone @0,0 402x50 +Restricted member row [Kind=Removed] 370x60 AL=H gap=12 pad=10/16/10/16 + Row 1 @16,124: "Kin's Mom" / "Removed Oct 27" + Row 2 @16,188: "Web3theo" / "Removed Oct 24 · also silenced" + Anatomy as C8a, but trailing pill is 73x33 with "Unban" (App/TextSmall) at right edge x+354; text column width 201. +``` diff --git a/docs/specs/group-moderation/frames/C8b.png b/docs/specs/group-moderation/frames/C8b.png new file mode 100644 index 000000000..e7c1a4fc6 Binary files /dev/null and b/docs/specs/group-moderation/frames/C8b.png differ diff --git a/docs/specs/group-moderation/frames/tokens.md b/docs/specs/group-moderation/frames/tokens.md new file mode 100644 index 000000000..8f571d0d0 --- /dev/null +++ b/docs/specs/group-moderation/frames/tokens.md @@ -0,0 +1,40 @@ +# iOS tokens (get_variable_defs, section 12766:63) + +Source: Figma file Jxf2wD9QyfKaONbhHSdU0K. Colors are variables; `#rrggbbaa` has alpha last. + +## Colors +| Variable | Value | +|---|---| +| backgroundMain | #19191a | +| backgroundRow | #ffffff0d | +| textMain | #ffffff | +| textSecondary | #7b7b7b | +| text/dim | #ffffff80 | +| Red | #ff383c | +| badge/focus | #0a84ff | +| bubble/other | #ffffff0d | +| bubble/self | #ffffff1a | +| chip/fill | #ffffff1f | +| card/border | #ffffff1a | +| action | #ffffff | +| textAction | #000000 | +| Black | #000000 | +| Labels/Primary | #000000 (status bar) | + +## Text styles (all Inter) +| Style | Weight | Size | Line height | +|---|---|---|---| +| App/DisplaySmall | Semi Bold 600 | 24 | 100% | +| App/TextLarge | Semi Bold 600 | 20 | 100% | +| App/TextMedium | Semi Bold 600 | 16 | 100% | +| App/TextMessage | Medium 500 | 16 | 100% | +| App/Default15Medium | Medium 500 | 15 | 100% | +| App/TextSmall | Semi Bold 600 | 14 | 100% | +| App/Default13Medium | Medium 500 | 13 | 100% | +| App/TextCaption | Medium 500 | 12 | 100% | + +## Not variable-bound (raw values seen in frames) +- UIMenu surface #3d3d40 at 0.96, radius 26, background blur + drop shadow; row title text 17 Regular (no text style); separator 1px #ffffff at 0.12. +- Glass pill fill #1e1e1e at 0.46 with GLASS effect. +- Remove/Banned alert surface #bc3434 (alert button text uses the same #bc3434 on a white button). +- Scrim #000000 at 0.45 (alerts) or 0.50 (sheet). diff --git a/docs/specs/group-moderation/spec.md b/docs/specs/group-moderation/spec.md new file mode 100644 index 000000000..f40fd474a --- /dev/null +++ b/docs/specs/group-moderation/spec.md @@ -0,0 +1,248 @@ +# Group moderation: silencing and removing members + +Status: decided, not started +Platforms: Android (`code-android-app`, base `code/cash`), iOS (`code-ios-app`, base `main`) +Contract: flipcash2 0.21.0, which both apps already pin: `SilenceUser`, `UnsilenceUser`, +`BanUser`, `UnbanUser`, `GetRestrictedUsers` on `chat.v1.Chat`, and +`ViewerState.permissions.moderation` / `ViewerState.restrictions.silence`. No contract change. +Design: Figma page node 12419:77643, concepts sections 12766:63 (iOS, C1–C8b) and 12782:224 (Android, AC1–AC8b). Exploration and the +Telegram comparison: `docs/superpowers/specs/2026-10-27-group-moderation-design.md`. + +A group's host can't stop one member from posting or put them out of the group. This spec adds +two host actions, Silence and Remove from Group, the screens where the host undoes them, and what +the silenced or removed person sees. Both apps already wrap all five RPCs (Android #1840, iOS +#1125), and nothing in either app calls them. + +The contract is the spec for validity. Read the comments on `SilenceUser` through +`GetRestrictedUsers` in `proto/chat/v1/chat_service.proto`, and on `ViewerState`, +`ModerationPermissions`, `Restrictions`, `SilenceState`, `BanState` and `RestrictedUser` in +`proto/chat/v1/model.proto`. This document does not restate them. + +Design values (spacing, type, color) are a guide. Each app's existing tokens win; list differences +in the PR rather than copying hard-coded values. On Android, every spacing, padding and gap comes +from the app's grid spacing tokens; no literal `dp` values for spacing. + +## Decisions + +| Question | Answer | +|---|---| +| Name of the per-member action | **Silence**. "Mute" stays the notification setting (`MuteChat`), so it keeps one meaning. | +| Remove vs ban | One action, "Remove from Group", calling `BanUser`. The contract has no kick-without-ban, so the confirm says they can't rejoin. | +| Who sees the actions | Silence when `moderation.can_silence`; Remove when `moderation.can_ban`; the Moderation row when either. Never on the group's creator (`Metadata.creator`) or the viewer's own messages and profile. Today only the creator holds these, but the client reads the flags and never checks "is creator". | +| Silence confirm | None. Picking a duration applies it, as Mute Notifications does. Picking again replaces the duration (the contract allows it). | +| Remove confirm | Always, with the bottom-bar alert both apps use for Block. | +| Silenced member's composer | Replaced by the read-only gate pill with a silence sentence, driven by `restrictions.silence`, not by a failed send. | +| Removed member | Learns why only when they try to come back: `JoinChat` or `EnterLobby` returns `BANNED`, and the app says "The host removed you from ". | +| Host's lists | One host-only **Moderation** row in the group overflow menu, opening a submenu with **Silenced** and **Removed**. Each opens its own list with one action per row. | +| Feature flag | None on either platform. The server's permission flags gate everything. | + +## Host: Silence and Remove + +### C1. Long-press a member's message (node 12766:64) + +Two actions after Report: **Silence …** and **Remove …**, both destructive +(red). Shown under the rules in Decisions. + +- iOS: two new `MessageCapability` cases, appended to the fixed render order in + `ChatItem+Conversation.swift` after `.report`, granted in `MessageCapability.resolve`, handled in + `ConversationScreen.handleMessageAction`. +- Android: a long-press puts `ChatTopBar` into selection mode. Add two `MessageCapability` entries + and two `MessageAction`s after Report in the `ChatTopBar` builder. They always go in the + `MessageOverflow` dropdown, never inline, whatever `inlineActionCapacity` allows. + +Silence opens C3 for that sender. Remove opens C4. + +### C2. Member profile opened from a group (node 12766:139) + +The profile overflow menu gains a section titled **In ** with **Silence…** and +**Remove from Group**, after Mute Notifications, Report and Block. Block stays and stays global. +The section only appears when the profile was opened from a group. + +The profile needs to know which group: +- iOS: `UserProfileOrigin.groupMember` has no associated value. Change it to + `.groupMember(ConversationID)` and pass the ID from `GroupMembersScreen` and the three + `ConversationScreen` call sites. Update its four `switch` statements. +- Android: the profile runs inside the chat flow, so the group is the flow's chat. Gate on that + chat being a group, not on `ProfileOrigin`: the members list passes `GroupMember`, but an avatar + tap in a group transcript passes `Chat`. `profileMenuItems(isBlocked, hasDm)` gains the group's + viewer state and creator, and `ChatProfileAction` gains `Silence` and `Remove`. + +### C3. Silence duration sheet (node 12766:201) + +Same layout as Mute Notifications: title "Silence ", subtitle "They can still read and +react in , but can't send messages. Only they're told", rows 1 Hour, 8 Hours, 1 Week, +Forever. Both apps say Forever, including Android, whose Mute Notifications sheet says Always. +Picking calls +`SilenceUser` with `SilenceState.until` (now + duration) or `forever` and dismisses. + +- iOS: `MuteChatSheet` is hard-wired to mute (conversation ID, copy, analytics, the unmute row). + Extract the row list into a shared view and build a `SilenceUserSheet` beside it that takes the + chat ID and user ID. +- Android: `MuteChatSheet` loops `MuteOption.entries`. Pass the options and labels in as + parameters, add a `SilenceOption` enum with the same durations, and give it its own route + (`AppRoute.Messaging.SilenceUser(chatId, userId)`, presented like `MuteChat`). + +The labels are new strings; the mute ones say "mute". + +### C4. Remove confirm (node 12766:238) + +Title "Remove ?", subtitle "They'll leave and can't rejoin until you unban +them. Their messages stay in the chat", actions **Remove** (destructive) and Cancel. + +- iOS: `DialogItem.alert`, the same call as `UserProfileScreen.blockDialog()`. +- Android: `BottomBarManager.showAlert(title, message, actions, showCancel = true)`, as + `ChatProfileViewModel` does for Block. + +On success, nothing else to do: the roster drops them through the `RosterUpdate.MemberLeft` the +other members already receive. + +### Results + +| Result | What the host sees | +|---|---| +| `OK` | Nothing beyond the sheet or dialog closing. | +| `CANNOT_RESTRICT_CREATOR` | Can't happen if the actions are hidden correctly. Show the generic error. | +| `DENIED` | The host lost the permission between render and tap. Show the generic error and re-read viewer state. | +| `NOT_FOUND` | The chat is gone. Generic error. | + +## Silenced member + +### C5. Composer (node 12766:250) + +When `restrictions.silence` is active, the composer is replaced by the read-only gate pill: +"You're silenced until