Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e353d4c
docs: add group moderation spec and design frames
bmc08gt Oct 27, 2026
9efba53
refactor(profile): carry the group in UserProfileOrigin.groupMember
bmc08gt Oct 27, 2026
4a96e36
feat(chat): Silence and Remove rows in the message menu
bmc08gt Oct 27, 2026
08c69ab
feat(chat): silence sheet, remove confirm and silenced composer pill
bmc08gt Oct 27, 2026
844c104
feat(profile): host's Silence and Remove from Group in the member pro…
bmc08gt Oct 27, 2026
1ffee57
feat(chat): tell a removed member why they can't join
bmc08gt Oct 27, 2026
dd96094
feat(chat): Moderation menu and the Silenced and Removed lists
bmc08gt Oct 27, 2026
40ef808
test(chat): cover moderation lists, durations and the silenced gate
bmc08gt Oct 27, 2026
fd009e2
fix(chat): refresh a stored group's moderation permissions on open
bmc08gt Oct 27, 2026
dfa35de
fix(chat): keep stored viewer state across feed refreshes, refresh on…
bmc08gt Oct 27, 2026
1169a23
feat(chat): empty-state view for moderation lists, toasts for silence…
bmc08gt Oct 27, 2026
6bd6f05
feat(chat): toast when a silence on the viewer lifts
bmc08gt Oct 27, 2026
b717c86
fix(chat): drop a removed member from the roster list
bmc08gt Oct 27, 2026
8bff2c7
fix(chat): show Silence and Remove in the long-press menu
bmc08gt Oct 27, 2026
5f6a6d4
feat(chat): say Restrict where the UI said Silence
bmc08gt Oct 27, 2026
48851f7
fix(chat): drop the ellipsis from Remove
bmc08gt Oct 29, 2026
ef9a326
Merge branch 'main' into feat/group-moderation-restrict-remove
bmc08gt Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<Void, Never>?
/// The feed load in flight, joined by every caller that arrives before it finishes.
@ObservationIgnored private var feedLoadTask: Task<Void, Never>?
Expand Down Expand Up @@ -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"
}
}
}
Expand Down
13 changes: 11 additions & 2 deletions Flipcash/Core/Navigation/AppRouter+Destination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}
Expand Down Expand Up @@ -269,6 +274,8 @@ extension AppRouter {
case .inviteLinks: "inviteLinks"
case .groupMembers: "groupMembers"
case .groupInvite: "groupInvite"
case .silencedMembers: "silencedMembers"
case .removedMembers: "removedMembers"
}
}

Expand Down Expand Up @@ -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)"
Expand Down
8 changes: 8 additions & 0 deletions Flipcash/Core/Navigation/AppRouter+DestinationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>) -> [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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
25 changes: 23 additions & 2 deletions Flipcash/Core/Screens/Conversation/ConversationGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
32 changes: 29 additions & 3 deletions Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
}
}
Loading
Loading