mirror of
https://github.com/yattee/yattee.git
synced 2026-11-01 17:37:18 +00:00
Let Subscribe On know its memberships and refuse a doomed destination
MAC5: membership lives in an observable loader started from ChannelView's task, outside the NSMenu content SwiftUI never runs lifecycle for; rows are Toggles so the mark renders on macOS, and writes toast Subscribed or Unsubscribed with the destination name. MAC13: a destination whose context fails validation is disabled, the same gate the plain Subscribe button uses.
This commit is contained in:
@@ -34,6 +34,10 @@ struct ChannelView: View {
|
|||||||
/// previous one is dropped instead of answering for the channel on screen.
|
/// previous one is dropped instead of answering for the channel on screen.
|
||||||
@State private var subscriptionGeneration = 0
|
@State private var subscriptionGeneration = 0
|
||||||
@State private var showingUnsubscribeConfirmation = false
|
@State private var showingUnsubscribeConfirmation = false
|
||||||
|
/// What the destination rows know, kept here rather than inside the menu:
|
||||||
|
/// nothing in a platform menu's content is ever given a lifecycle to load
|
||||||
|
/// from.
|
||||||
|
@State private var destinationMembership = SubscriptionDestinationMembershipLoader()
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
@State private var showingSubscriptionDestinations = false
|
@State private var showingSubscriptionDestinations = false
|
||||||
#endif
|
#endif
|
||||||
@@ -192,6 +196,15 @@ struct ChannelView: View {
|
|||||||
.task {
|
.task {
|
||||||
await loadChannel()
|
await loadChannel()
|
||||||
}
|
}
|
||||||
|
// Asked for here, where the answers arrive before the menu is built:
|
||||||
|
// the rows themselves live inside a platform menu, which runs no
|
||||||
|
// lifecycle of its own, and a mark that is not known by the time the
|
||||||
|
// menu opens is a mark the user never sees.
|
||||||
|
.task(id: channelToSubscribe?.id) {
|
||||||
|
guard let channelToSubscribe,
|
||||||
|
SubscriptionDestinations.areOffered(in: appEnvironment) else { return }
|
||||||
|
await destinationMembership.load(for: channelToSubscribe, in: appEnvironment)
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Update Handoff activity for this specific channel
|
// Update Handoff activity for this specific channel
|
||||||
appEnvironment?.handoffManager.updateActivity(for: .channel(channelID, source))
|
appEnvironment?.handoffManager.updateActivity(for: .channel(channelID, source))
|
||||||
@@ -825,7 +838,10 @@ struct ChannelView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.fullScreenCover(isPresented: $showingSubscriptionDestinations) {
|
.fullScreenCover(isPresented: $showingSubscriptionDestinations) {
|
||||||
SubscriptionDestinationPicker(channel: channelToSubscribe) { context, subscribed in
|
SubscriptionDestinationPicker(
|
||||||
|
channel: channelToSubscribe,
|
||||||
|
membership: destinationMembership
|
||||||
|
) { context, subscribed in
|
||||||
destinationDidToggle(context, subscribed: subscribed)
|
destinationDidToggle(context, subscribed: subscribed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1249,7 +1265,10 @@ struct ChannelView: View {
|
|||||||
// never presented there.
|
// never presented there.
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
if let channelToSubscribe {
|
if let channelToSubscribe {
|
||||||
SubscriptionDestinationMenu(channel: channelToSubscribe) { context, subscribed in
|
SubscriptionDestinationMenu(
|
||||||
|
channel: channelToSubscribe,
|
||||||
|
membership: destinationMembership
|
||||||
|
) { context, subscribed in
|
||||||
destinationDidToggle(context, subscribed: subscribed)
|
destinationDidToggle(context, subscribed: subscribed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1275,6 +1294,7 @@ struct ChannelView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: channelMenuIcon)
|
Image(systemName: channelMenuIcon)
|
||||||
}
|
}
|
||||||
|
.accessibilityLabel(Text(String(localized: "channel.menu.title")))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Subscription Actions
|
// MARK: - Subscription Actions
|
||||||
|
|||||||
@@ -40,6 +40,158 @@ struct SubscriptionDestinationMembership: Equatable {
|
|||||||
func isSubscribed(on context: DataContext) -> Bool? { known[context] }
|
func isSubscribed(on context: DataContext) -> Bool? { known[context] }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading
|
||||||
|
|
||||||
|
/// Asks every destination whether the channel is subscribed there, and holds
|
||||||
|
/// the answers the rows render.
|
||||||
|
///
|
||||||
|
/// Deliberately owned by the view that *presents* the rows rather than by the
|
||||||
|
/// rows themselves. A `Menu`'s content is handed to the platform's own menu —
|
||||||
|
/// an `NSMenu` on macOS — and SwiftUI runs no lifecycle inside one: `.task`,
|
||||||
|
/// `.onAppear` and `.onReceive` attached to rows there are never called. A
|
||||||
|
/// round asked for from inside the menu is a round that never happens, leaving
|
||||||
|
/// every row unknown; unknown reads as "not subscribed", so each row would
|
||||||
|
/// subscribe again instead of doing the unsubscribe it promises.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class SubscriptionDestinationMembershipLoader {
|
||||||
|
private(set) var membership = SubscriptionDestinationMembership()
|
||||||
|
|
||||||
|
/// Destinations with a write in flight.
|
||||||
|
private(set) var busy: Set<DataContext> = []
|
||||||
|
|
||||||
|
@ObservationIgnored private weak var appEnvironment: AppEnvironment?
|
||||||
|
@ObservationIgnored private var channel: Channel?
|
||||||
|
@ObservationIgnored private var reloadTask: Task<Void, Never>?
|
||||||
|
@ObservationIgnored private var observers: [any NSObjectProtocol] = []
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// The credential behind an account was replaced, the accounts
|
||||||
|
// themselves changed, or an instance was switched back on and added a
|
||||||
|
// destination on the spot: every answer held here describes the logins
|
||||||
|
// that were there before.
|
||||||
|
for name: Notification.Name in [.accountCredentialDidChange, .accountsDidChange, .instancesDidChange] {
|
||||||
|
observers.append(
|
||||||
|
NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
||||||
|
Task { @MainActor in self?.reload() }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSubscribed(on context: DataContext) -> Bool? { membership.isSubscribed(on: context) }
|
||||||
|
|
||||||
|
func isBusy(_ context: DataContext) -> Bool { busy.contains(context) }
|
||||||
|
|
||||||
|
/// Asks about `channel`, from a place the SwiftUI lifecycle reaches.
|
||||||
|
///
|
||||||
|
/// Answered before the menu is opened rather than while it is open: a
|
||||||
|
/// platform menu is built from the values it is handed, so a mark that
|
||||||
|
/// arrives afterwards is a mark the user does not see.
|
||||||
|
func load(for channel: Channel, in appEnvironment: AppEnvironment?) async {
|
||||||
|
self.channel = channel
|
||||||
|
self.appEnvironment = appEnvironment
|
||||||
|
|
||||||
|
reloadTask?.cancel()
|
||||||
|
await ask()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forgets what is known and asks again.
|
||||||
|
///
|
||||||
|
/// Forgetting first, rather than waiting for the replacement answers: a row
|
||||||
|
/// toggled in between would otherwise toggle against the previous login's
|
||||||
|
/// membership and do the opposite of what its mark promises.
|
||||||
|
private func reload() {
|
||||||
|
guard channel != nil else { return }
|
||||||
|
|
||||||
|
membership.invalidate()
|
||||||
|
reloadTask?.cancel()
|
||||||
|
reloadTask = Task { [weak self] in await self?.ask() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ask() async {
|
||||||
|
guard let channel, let service = appEnvironment?.subscriptionService else { return }
|
||||||
|
|
||||||
|
membership.invalidate()
|
||||||
|
let generation = membership.generation
|
||||||
|
let channelID = channel.id.channelID
|
||||||
|
|
||||||
|
// Serially rather than in a group: each cold destination answers by
|
||||||
|
// fetching its account's whole list, and firing every instance at once
|
||||||
|
// is a burst the user never asked for.
|
||||||
|
for context in SubscriptionDestinations.available(in: appEnvironment) {
|
||||||
|
let subscribed = await service.isSubscribed(to: channelID, in: context)
|
||||||
|
membership.record(subscribed, for: context, generation: generation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toggle
|
||||||
|
|
||||||
|
func toggle(
|
||||||
|
_ context: DataContext,
|
||||||
|
of channel: Channel,
|
||||||
|
in appEnvironment: AppEnvironment?,
|
||||||
|
onToggled: ((DataContext, Bool) -> Void)?
|
||||||
|
) {
|
||||||
|
guard let appEnvironment else { return }
|
||||||
|
|
||||||
|
self.channel = channel
|
||||||
|
self.appEnvironment = appEnvironment
|
||||||
|
|
||||||
|
// Unknown counts as not subscribed: the row carries no mark, so
|
||||||
|
// subscribing is what the user just asked for. Asking the server again
|
||||||
|
// at press time would be the other way round — it could unsubscribe
|
||||||
|
// under a row that showed no mark at all.
|
||||||
|
let wasSubscribed = membership.isSubscribed(on: context) == true
|
||||||
|
let generation = membership.generation
|
||||||
|
let channelID = channel.id.channelID
|
||||||
|
|
||||||
|
busy.insert(context)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
if wasSubscribed {
|
||||||
|
try await appEnvironment.subscriptionService.unsubscribe(from: channelID, in: context)
|
||||||
|
} else {
|
||||||
|
try await appEnvironment.subscriptionService.subscribe(to: channel, in: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
membership.record(!wasSubscribed, for: context, generation: generation)
|
||||||
|
onToggled?(context, !wasSubscribed)
|
||||||
|
|
||||||
|
// A menu closes on the press that made the write, so the mark
|
||||||
|
// the row will carry on its next open is not something the user
|
||||||
|
// is looking at. The toast is what tells them it landed, and
|
||||||
|
// where — the main button speaks for the account being browsed,
|
||||||
|
// which a destination write usually is not.
|
||||||
|
appEnvironment.toastManager.showSuccess(
|
||||||
|
String(
|
||||||
|
localized: wasSubscribed
|
||||||
|
? "channel.subscribe.destination.unsubscribed"
|
||||||
|
: "channel.subscribe.destination.subscribed"
|
||||||
|
),
|
||||||
|
subtitle: DataContextSwitcherLabels.title(for: context, in: appEnvironment)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
appEnvironment.toastManager.showError(
|
||||||
|
String(
|
||||||
|
localized: wasSubscribed
|
||||||
|
? "channel.unsubscribe.error.title"
|
||||||
|
: "channel.subscribe.error.title"
|
||||||
|
),
|
||||||
|
subtitle: error.localizedDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
busy.remove(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Destinations
|
// MARK: - Destinations
|
||||||
|
|
||||||
/// Where a channel can be subscribed.
|
/// Where a channel can be subscribed.
|
||||||
@@ -95,6 +247,9 @@ struct SubscriptionDestinationRows: View {
|
|||||||
/// The channel as it must be stored, with the id the calling view resolved.
|
/// The channel as it must be stored, with the id the calling view resolved.
|
||||||
let channel: Channel
|
let channel: Channel
|
||||||
|
|
||||||
|
/// What is known about the destinations, asked for outside the menu.
|
||||||
|
let membership: SubscriptionDestinationMembershipLoader
|
||||||
|
|
||||||
/// The destination that was toggled and whether it is now subscribed, for
|
/// The destination that was toggled and whether it is now subscribed, for
|
||||||
/// the view that owns the main subscribe button.
|
/// the view that owns the main subscribe button.
|
||||||
var onToggled: ((DataContext, Bool) -> Void)?
|
var onToggled: ((DataContext, Bool) -> Void)?
|
||||||
@@ -102,138 +257,69 @@ struct SubscriptionDestinationRows: View {
|
|||||||
/// Called after any tap, for presentations that have to close themselves.
|
/// Called after any tap, for presentations that have to close themselves.
|
||||||
var onSelect: (() -> Void)?
|
var onSelect: (() -> Void)?
|
||||||
|
|
||||||
@State private var membership = SubscriptionDestinationMembership()
|
|
||||||
@State private var busy: Set<DataContext> = []
|
|
||||||
/// Bumped when the logins change, so the answers are asked for again.
|
|
||||||
@State private var membershipRound = 0
|
|
||||||
|
|
||||||
/// What a round of membership answers belongs to.
|
|
||||||
///
|
|
||||||
/// The channel alone is not enough: the same channel on the same rows means
|
|
||||||
/// something different once a credential has been replaced, and the picker
|
|
||||||
/// deliberately stays open across such a change on tvOS.
|
|
||||||
private struct MembershipRequest: Equatable {
|
|
||||||
let channel: Channel.ID
|
|
||||||
let round: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ForEach(destinations, id: \.self) { context in
|
ForEach(destinations, id: \.self) { context in
|
||||||
Button {
|
row(for: context)
|
||||||
toggle(context)
|
|
||||||
onSelect?()
|
|
||||||
} label: {
|
|
||||||
rowLabel(for: context)
|
|
||||||
}
|
|
||||||
.disabled(busy.contains(context))
|
|
||||||
#if os(tvOS)
|
|
||||||
.buttonStyle(TVSettingsButtonStyle())
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
// Membership is only asked for once the rows are on screen: an unwarmed
|
|
||||||
// provider answers by fetching that account's whole subscription list,
|
|
||||||
// which is far too much to spend on a menu nobody opened.
|
|
||||||
.task(id: MembershipRequest(channel: channel.id, round: membershipRound)) {
|
|
||||||
await loadMembership()
|
|
||||||
}
|
|
||||||
// The credential behind an account was replaced, or the accounts
|
|
||||||
// themselves changed: every checkmark on screen describes the logins
|
|
||||||
// that were there before.
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .accountCredentialDidChange)) { _ in
|
|
||||||
invalidateMembership()
|
|
||||||
}
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .accountsDidChange)) { _ in
|
|
||||||
invalidateMembership()
|
|
||||||
}
|
|
||||||
// An instance switched on adds a row to `destinations` on the spot, and
|
|
||||||
// the round that would have asked about it has already run. Unknown
|
|
||||||
// reads as unsubscribed, so an account the channel is already on gets a
|
|
||||||
// checkmark-less row whose tap subscribes again instead of doing the
|
|
||||||
// unsubscribe it promises.
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .instancesDidChange)) { _ in
|
|
||||||
invalidateMembership()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Forgets what is known and asks again.
|
|
||||||
///
|
|
||||||
/// Forgetting first, rather than waiting for the replacement answers: a row
|
|
||||||
/// toggled in between would otherwise toggle against the previous login's
|
|
||||||
/// membership and do the opposite of what its checkmark promises.
|
|
||||||
private func invalidateMembership() {
|
|
||||||
membership.invalidate()
|
|
||||||
membershipRound += 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func rowLabel(for context: DataContext) -> some View {
|
private func row(for context: DataContext) -> some View {
|
||||||
let title = DataContextSwitcherLabels.title(for: context, in: appEnvironment)
|
let title = DataContextSwitcherLabels.title(for: context, in: appEnvironment)
|
||||||
|
|
||||||
if membership.isSubscribed(on: context) == true {
|
#if os(tvOS)
|
||||||
Label(title, systemImage: "checkmark")
|
Button {
|
||||||
} else {
|
toggle(context)
|
||||||
|
onSelect?()
|
||||||
|
} label: {
|
||||||
|
if membership.isSubscribed(on: context) == true {
|
||||||
|
Label(title, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(TVSettingsButtonStyle())
|
||||||
|
.disabled(isRefusing(context))
|
||||||
|
#else
|
||||||
|
// The mark is the menu's own, not a drawn glyph: an image inside a
|
||||||
|
// macOS menu row is dropped, and a toggle is what both platforms render
|
||||||
|
// — and report to VoiceOver — as a checked row.
|
||||||
|
Toggle(isOn: subscribed(on: context)) {
|
||||||
Text(title)
|
Text(title)
|
||||||
}
|
}
|
||||||
|
.disabled(isRefusing(context))
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subscribed(on context: DataContext) -> Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { membership.isSubscribed(on: context) == true },
|
||||||
|
set: { _ in
|
||||||
|
toggle(context)
|
||||||
|
onSelect?()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var destinations: [DataContext] {
|
private var destinations: [DataContext] {
|
||||||
SubscriptionDestinations.available(in: appEnvironment)
|
SubscriptionDestinations.available(in: appEnvironment)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Membership
|
/// Whether the destination would refuse the write it is offering.
|
||||||
|
///
|
||||||
private func loadMembership() async {
|
/// The same gate the main subscribe button uses, asked about the row's own
|
||||||
guard let service = appEnvironment?.subscriptionService else { return }
|
/// target rather than the account being browsed. A rejected or unreachable
|
||||||
|
/// account is listed — it names its state, and hiding it would read as a
|
||||||
membership.invalidate()
|
/// login that had been deleted — but pressing it can only cost a doomed
|
||||||
let generation = membership.generation
|
/// request and an error toast, so it is not pressable.
|
||||||
let channelID = channel.id.channelID
|
private func isRefusing(_ context: DataContext) -> Bool {
|
||||||
|
if membership.isBusy(context) { return true }
|
||||||
// Serially rather than in a group: each cold destination answers by
|
guard let appEnvironment else { return !context.isLocal }
|
||||||
// fetching its account's whole list, and firing every instance at once
|
return !appEnvironment.dataContextValidator.validity(of: context).isValid
|
||||||
// is a burst the user never asked for.
|
|
||||||
for context in destinations {
|
|
||||||
let subscribed = await service.isSubscribed(to: channelID, in: context)
|
|
||||||
membership.record(subscribed, for: context, generation: generation)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Toggle
|
|
||||||
|
|
||||||
private func toggle(_ context: DataContext) {
|
private func toggle(_ context: DataContext) {
|
||||||
guard let appEnvironment else { return }
|
membership.toggle(context, of: channel, in: appEnvironment, onToggled: onToggled)
|
||||||
|
|
||||||
// Unknown counts as not subscribed: the row shows no checkmark, so
|
|
||||||
// subscribing is what the user just asked for.
|
|
||||||
let wasSubscribed = membership.isSubscribed(on: context) == true
|
|
||||||
let generation = membership.generation
|
|
||||||
let channelID = channel.id.channelID
|
|
||||||
|
|
||||||
busy.insert(context)
|
|
||||||
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
if wasSubscribed {
|
|
||||||
try await appEnvironment.subscriptionService.unsubscribe(from: channelID, in: context)
|
|
||||||
} else {
|
|
||||||
try await appEnvironment.subscriptionService.subscribe(to: channel, in: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
membership.record(!wasSubscribed, for: context, generation: generation)
|
|
||||||
onToggled?(context, !wasSubscribed)
|
|
||||||
} catch {
|
|
||||||
appEnvironment.toastManager.showError(
|
|
||||||
String(
|
|
||||||
localized: wasSubscribed
|
|
||||||
? "channel.unsubscribe.error.title"
|
|
||||||
: "channel.subscribe.error.title"
|
|
||||||
),
|
|
||||||
subtitle: error.localizedDescription
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
busy.remove(context)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,12 +331,17 @@ struct SubscriptionDestinationMenu: View {
|
|||||||
@Environment(\.appEnvironment) private var appEnvironment
|
@Environment(\.appEnvironment) private var appEnvironment
|
||||||
|
|
||||||
let channel: Channel
|
let channel: Channel
|
||||||
|
let membership: SubscriptionDestinationMembershipLoader
|
||||||
var onToggled: ((DataContext, Bool) -> Void)?
|
var onToggled: ((DataContext, Bool) -> Void)?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if SubscriptionDestinations.areOffered(in: appEnvironment) {
|
if SubscriptionDestinations.areOffered(in: appEnvironment) {
|
||||||
Menu {
|
Menu {
|
||||||
SubscriptionDestinationRows(channel: channel, onToggled: onToggled)
|
SubscriptionDestinationRows(
|
||||||
|
channel: channel,
|
||||||
|
membership: membership,
|
||||||
|
onToggled: onToggled
|
||||||
|
)
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
Label(
|
||||||
String(localized: "channel.menu.subscribeOn"),
|
String(localized: "channel.menu.subscribeOn"),
|
||||||
@@ -271,6 +362,7 @@ struct SubscriptionDestinationPicker: View {
|
|||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
let channel: Channel
|
let channel: Channel
|
||||||
|
let membership: SubscriptionDestinationMembershipLoader
|
||||||
var onToggled: ((DataContext, Bool) -> Void)?
|
var onToggled: ((DataContext, Bool) -> Void)?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -286,7 +378,11 @@ struct SubscriptionDestinationPicker: View {
|
|||||||
// The picker stays open across a toggle: checkmarks are
|
// The picker stays open across a toggle: checkmarks are
|
||||||
// the whole point of the list, and closing it would hide
|
// the whole point of the list, and closing it would hide
|
||||||
// the change the user just made.
|
// the change the user just made.
|
||||||
SubscriptionDestinationRows(channel: channel, onToggled: onToggled)
|
SubscriptionDestinationRows(
|
||||||
|
channel: channel,
|
||||||
|
membership: membership,
|
||||||
|
onToggled: onToggled
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user