Mute one contact's stories, without blocking them

The options were see everything or block the person. Muting one
contact's stories while the conversation carries on is the ordinary
middle, and it needs no protocol: nothing is sent, and the author is
never told.

Their stories still arrive and are still stored. Discarding them on
receipt would be cheaper, but then unmuting would show an empty strip
until their next post — so muting would quietly mean something other
than what the button says.

The list lives in the sealed snapshot rather than UserDefaults. Who
somebody has muted is a fact about their social graph, and a plist in
the app container is readable by anything that reaches the file, which
is the threat the sealed store exists for. It also means the choice
survives a restore, like the audience list beside it.

Two traps. The field is optional, because Swift's synthesised decoding
throws on a missing key rather than applying a default — a non-optional
addition would make every existing snapshot fail to decode and take the
user's history with it. And the debounced history writer had to carry
the list even though it does not trigger on it: omitting it there would
have written nil on the next ordinary message and silently unmuted
everybody.

Your own stories are never hidden, even if your id reaches the muted set
from a restored backup. Hiding somebody's own posts from them reads as
the posts having failed to send.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 17:27:26 -04:00
co-authored by Claude Opus 4.8
parent feb43e3a45
commit 429c0c687d
5 changed files with 180 additions and 6 deletions
+16 -1
View File
@@ -47,6 +47,17 @@ struct E2EHistoryStore {
/// memory before this — every relaunch silently wiped the feed, which
/// read as "my stories disappeared".
var stories: [Int: [E2EStory]]? = nil
/// Contacts whose stories this device hides (ios#33).
///
/// 🔴 **In the sealed snapshot rather than `UserDefaults`, deliberately.**
/// Who somebody has muted is a fact about their social graph, and a
/// plist in the app container is readable by anything that reaches the
/// file — which is the threat this whole store exists for. It also
/// means the choice survives a restore, like the audience list beside
/// it.
///
/// Optional for the same decode-compatibility reason as `calls`.
var mutedStoryAuthors: [Int]? = nil
/// The contact list, once it lives here rather than on the relay (P1).
/// Optional for the same decode-compatibility reason as `calls`, and
/// deliberately inside the sealed snapshot: it rides the D4 export, so
@@ -157,6 +168,7 @@ struct E2EHistoryStore {
calls: [E2ECallEntry] = [], outbox: [E2EOutboxEntry] = [],
audience: E2EStoryAudience = E2EStoryAudience(),
stories: [Int: [E2EStory]] = [:],
mutedStoryAuthors: Set<Int> = [],
roster: [RosterEntry] = [], rosterOffServer: Bool = false,
groupNames: [String: String] = [:],
groupSecrets: [String: KeychainE2EStore.GroupSecrets] = [:],
@@ -165,7 +177,10 @@ struct E2EHistoryStore {
guard let url = fileURL, let key = keyProvider() else { return }
let snapshot = Snapshot(threads: threads, groupThreads: groupThreads,
calls: calls, outbox: outbox, storyAudience: audience,
stories: stories, roster: roster,
stories: stories,
mutedStoryAuthors: mutedStoryAuthors.isEmpty
? nil : mutedStoryAuthors.sorted(),
roster: roster,
rosterOffServer: rosterOffServer,
groupNames: groupNames.isEmpty ? nil : groupNames,
groupSecrets: groupSecrets.isEmpty ? nil : groupSecrets,
+54 -5
View File
@@ -651,6 +651,18 @@ final class E2EInbox: ObservableObject {
/// screenshotted", which is precisely the inference §6.27 forbids.
@Published private(set) var screenshotNotices: [ScreenshotNotice] = []
/// Contacts whose stories are hidden from the strip (ios#33).
///
/// 🔴 **Local, and the author is never told.** There is no envelope and no
/// relay call: muting somebody's stories is a decision about what this
/// device draws, and making it visible to them would turn a quiet act into
/// a social one — which is the reason people want it instead of blocking.
///
/// ⚠️ Their stories still ARRIVE and are still stored. Discarding them on
/// receipt would make unmuting show an empty strip until the next post,
/// so muting would quietly mean something different from what it says.
@Published private(set) var mutedStoryAuthors: Set<Int> = []
/// Story blob ids the user has actually watched this session. Session-only
/// like the threads themselves — a relaunch resets the rings, not the data.
@Published private(set) var seenStoryIDs: Set<String> = []
@@ -861,6 +873,7 @@ final class E2EInbox: ObservableObject {
self.callLog = restored.calls ?? []
self.outbox = restored.outbox ?? []
self.storyAudience = restored.storyAudience ?? E2EStoryAudience()
self.mutedStoryAuthors = Set(restored.mutedStoryAuthors ?? [])
// Expired stories are dropped on the way IN, so the bytes leave
// the device on schedule even if the app slept past the deadline.
self.roster = restored.roster ?? []
@@ -936,11 +949,16 @@ final class E2EInbox: ObservableObject {
let (threads, groupThreads, calls) = head
let (outbox, audience, stories, drafts) = tail
guard let self else { return }
// ⚠️ Carried, though it does not trigger this writer. Combine
// tops out and the mute list changes rarely, so its own setter
// flushes instead — but omitting it HERE would write nil on the
// next ordinary message and silently unmute everybody.
self.history.save(threads: threads, groupThreads: groupThreads,
calls: calls, outbox: outbox, audience: audience,
stories: stories, roster: self.roster,
rosterOffServer: self.rosterOffServer,
drafts: drafts)
stories: stories,
mutedStoryAuthors: self.mutedStoryAuthors,
roster: self.roster,
rosterOffServer: self.rosterOffServer, drafts: drafts)
}
}
@@ -951,7 +969,8 @@ final class E2EInbox: ObservableObject {
func flushHistory() {
history.save(threads: threads, groupThreads: groupThreads,
calls: callLog, outbox: outbox, audience: storyAudience,
stories: stories, roster: roster,
stories: stories, mutedStoryAuthors: mutedStoryAuthors,
roster: roster,
rosterOffServer: rosterOffServer,
groupNames: store.allGroupNames(),
groupSecrets: store.allGroupSecrets(),
@@ -1016,6 +1035,8 @@ final class E2EInbox: ObservableObject {
E2EHistoryStore.Snapshot(
threads: threads, groupThreads: groupThreads, calls: callLog,
outbox: [], storyAudience: storyAudience, stories: stories,
mutedStoryAuthors: mutedStoryAuthors.isEmpty
? nil : mutedStoryAuthors.sorted(),
roster: roster, rosterOffServer: rosterOffServer)
}
@@ -5194,7 +5215,7 @@ final class E2EInbox: ObservableObject {
/// Authors with at least one unexpired story, own story first, then by
/// most recent post.
func activeStoryAuthors() -> [Int] {
stories
let ordered = stories
.compactMap { authorID, list -> (Int, Date)? in
let active = list.filter(\.isActive)
guard let latest = active.map(\.postedAt).max() else { return nil }
@@ -5206,6 +5227,7 @@ final class E2EInbox: ObservableObject {
return a.1 > b.1
}
.map(\.0)
return Self.visibleStoryAuthors(ordered, muted: mutedStoryAuthors, me: myUserID)
}
/// Whether this story may be saved off the device (ios#34).
@@ -5296,6 +5318,33 @@ final class E2EInbox: ObservableObject {
note: note)
}
/// Drop muted authors from the strip order (ios#33).
///
/// ⚠️ `me` is never dropped. Muting yourself is not offered, but a muted id
/// that reached the set some other way — a restored backup from a build
/// where the guard was missing — must not hide the user's own stories from
/// them, which would read as their posts having failed.
nonisolated static func visibleStoryAuthors(_ ordered: [Int],
muted: Set<Int>,
me: Int) -> [Int] {
ordered.filter { $0 == me || !muted.contains($0) }
}
/// Hide or restore one contact's stories. Local only; nothing is sent.
///
/// ⚠️ Refuses to mute the signed-in user. See `visibleStoryAuthors`.
func setStoryMuted(_ muted: Bool, authorID: Int) {
guard authorID != myUserID else { return }
let before = mutedStoryAuthors
if muted { mutedStoryAuthors.insert(authorID) }
else { mutedStoryAuthors.remove(authorID) }
guard mutedStoryAuthors != before else { return }
flushHistory()
}
/// Whether this contact's stories are hidden.
func isStoryMuted(_ authorID: Int) -> Bool { mutedStoryAuthors.contains(authorID) }
/// Unexpired stories for one author, oldest first (viewer order).
func activeStories(for authorID: Int) -> [E2EStory] {
(stories[authorID] ?? []).filter(\.isActive).sorted { $0.postedAt < $1.postedAt }
+19
View File
@@ -18,6 +18,8 @@ struct BuddyProfileView: View {
@State private var isPinned = false
@State private var isMuted = false
@State private var isBlocked = false
/// ios#33 — mirrored into view state so the label redraws on tap.
@State private var isStoryMuted = false
/// 🔴 Why a block or unblock did not take.
///
@@ -48,6 +50,23 @@ struct BuddyProfileView: View {
row("Status", buddy.presence.label)
}
encryptionSection
// ios#33 — the ordinary middle between seeing everything and
// blocking. Above the block button and not styled destructive,
// because it is not a severing: the conversation, the calls and
// the notifications are all untouched.
if let inbox = environment.e2eInbox {
Button {
inbox.setStoryMuted(!inbox.isStoryMuted(buddy.id), authorID: buddy.id)
isStoryMuted = inbox.isStoryMuted(buddy.id)
} label: {
Label(isStoryMuted ? "Show their stories" : "Hide their stories",
systemImage: isStoryMuted ? "eye" : "eye.slash")
.frame(maxWidth: .infinity, minHeight: 48)
}
.buttonStyle(.bordered)
.accessibilityIdentifier("profile.storyMute")
.onAppear { isStoryMuted = inbox.isStoryMuted(buddy.id) }
}
Button(role: .destructive) {
if isBlocked { unblock() } else { showingBlockConfirm = true }
} label: {
@@ -149,6 +149,22 @@ struct StoriesStrip: View {
.frame(width: 70)
}
.buttonStyle(.plain)
// Long press to hide somebody's stories (ios#33). On the bubble as
// well as the profile, because the moment somebody wants this is the
// moment they are looking at the bubble.
.contextMenu {
if authorID != myUserID {
Button {
inbox.setStoryMuted(!inbox.isStoryMuted(authorID), authorID: authorID)
} label: {
Label(inbox.isStoryMuted(authorID)
? "Show \(name)'s stories"
: "Hide \(name)'s stories",
systemImage: inbox.isStoryMuted(authorID) ? "eye" : "eye.slash")
}
.accessibilityIdentifier("story.mute.\(authorID)")
}
}
}
private func displayName(for authorID: Int) -> String {
+75
View File
@@ -0,0 +1,75 @@
import XCTest
@testable import FlamenetMessenger
/// Muting one contact's stories (ios#33).
///
/// The ordinary middle between seeing everything and blocking somebody. It is
/// entirely local: no envelope, no relay call, and the author is never told.
final class StoryMuteTests: XCTestCase {
// -- What the strip shows -------------------------------------------------
func testAMutedAuthorIsDroppedFromTheStrip() {
let visible = E2EInbox.visibleStoryAuthors([7, 8, 9], muted: [8], me: 1)
XCTAssertEqual(visible, [7, 9])
}
/// ⚠️ Order is the caller's — most recent first, with the user pinned to
/// the front. Filtering must not quietly re-sort it.
func testFilteringPreservesTheOrderItWasGiven() {
let visible = E2EInbox.visibleStoryAuthors([9, 7, 8], muted: [], me: 1)
XCTAssertEqual(visible, [9, 7, 8])
}
/// 🔴 **The user's own stories survive their own id being in the set.**
/// `setStoryMuted` refuses to add it, but a restored backup from a build
/// without that guard could carry one. Hiding somebody's own posts from
/// them reads as the posts having failed to send.
func testYourOwnStoriesAreNeverHiddenEvenIfYourIDIsMuted() {
let visible = E2EInbox.visibleStoryAuthors([1, 7], muted: [1, 7], me: 1)
XCTAssertEqual(visible, [1])
}
func testMutingNobodyChangesNothing() {
XCTAssertEqual(E2EInbox.visibleStoryAuthors([7, 8], muted: [], me: 1), [7, 8])
}
/// A muted id for somebody who has posted nothing is not an error and must
/// not invent an entry.
func testAMutedAuthorWithNoStoriesIsSimplyAbsent() {
XCTAssertEqual(E2EInbox.visibleStoryAuthors([7], muted: [99], me: 1), [7])
}
// -- What survives a relaunch --------------------------------------------
/// 🔴 **The field must be optional.** Swift's synthesised decoding does not
/// apply a default for a missing key, it throws — so a non-optional
/// addition here would make every snapshot written before this feature fail
/// to decode, taking the user's entire history with it.
func testASnapshotWrittenBeforeThisFeatureStillDecodes() throws {
let older = """
{"version":1,"threads":{},"groupThreads":{}}
""".data(using: .utf8)!
let snapshot = try JSONDecoder().decode(E2EHistoryStore.Snapshot.self, from: older)
XCTAssertNil(snapshot.mutedStoryAuthors)
}
func testTheMuteListRoundTripsThroughTheSealedSnapshot() throws {
var snapshot = E2EHistoryStore.Snapshot()
snapshot.mutedStoryAuthors = [8, 12]
let again = try JSONDecoder().decode(
E2EHistoryStore.Snapshot.self,
from: try JSONEncoder().encode(snapshot))
XCTAssertEqual(again.mutedStoryAuthors, [8, 12])
}
/// ⚠️ Muting is a fact about somebody's social graph, so it belongs in the
/// sealed snapshot rather than `UserDefaults` — a plist in the app
/// container is readable by anything that reaches the file.
func testTheMuteListIsNotWrittenToUserDefaults() {
let suspects = UserDefaults.standard.dictionaryRepresentation().keys
.filter { $0.lowercased().contains("mute") && $0.lowercased().contains("stor") }
XCTAssertTrue(suspects.isEmpty, "found \(suspects)")
}
}