Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ struct MembershipCoordinator: View {
style: .error,
buttonData: EmptyStateView.ButtonData(
title: Loc.tryAgain,
action: { model.loadTiers() }
action: { model.retryLoadTiers() }
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,36 @@ import AnytypeCore
@MainActor
final class MembershipCoordinatorModel: ObservableObject {
@Published var userMembership: MembershipStatus = .empty
@Published var tiers: [MembershipTier] = []
@Published private var allTiers: [MembershipTier] = []

@Published var showTiersLoadingError = false
@Published var showTier: MembershipTier?
@Published var showSuccess: MembershipTier?
@Published var fireConfetti = false
@Published var emailUrl: URL?

@Injected(\.membershipService)
private var membershipService: any MembershipServiceProtocol

@Injected(\.membershipStatusStorage)
private var membershipStatusStorage: any MembershipStatusStorageProtocol
@Injected(\.accountManager)
private var accountManager: any AccountManagerProtocol

private let initialTierId: Int?


var tiers: [MembershipTier] {
let currentTierId = userMembership.tier?.type.id ?? 0
return allTiers
.filter { FeatureFlags.membershipTestTiers || !$0.isTest }
.filter { !$0.iosProductID.isEmpty || $0.type.id == currentTierId }
}
Comment on lines +24 to +29
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we use this on the UI level, it will really affect performance. It's better to make this a published property and discard @Published private var allTiers: [MembershipTier] = []
because seems like it's not used anymore.

Also naming is really ambiguous. We have allTiers and tiers with no significant distinction and ability to understand what is what.


init(initialTierId: Int?) {
self.initialTierId = initialTierId
membershipStatusStorage.statusPublisher.receiveOnMain().assign(to: &$userMembership)
membershipStatusStorage.tiersPublisher.receiveOnMain().assign(to: &$allTiers)
}

func onAppear() {
Task {
await loadTiers()

guard let initialTierId else { return }
guard let initialTier = tiers.first(where: { $0.type.id == initialTierId }) else {
anytypeAssertionFailure("Not found initial id for Memberhsip coordinator", info: ["tierId": String(initialTierId)])
Expand All @@ -40,17 +44,11 @@ final class MembershipCoordinatorModel: ObservableObject {
onTierSelected(tier: initialTier)
}
}

func loadTiers(noCache: Bool = false) {
Task { await loadTiers(noCache: noCache) }
}

private func loadTiers(noCache: Bool = false) async {
do {
tiers = try await membershipService.getTiers(noCache: noCache)

func retryLoadTiers() {
Task {
await membershipStatusStorage.refreshMembership()
showTiersLoadingError = false
} catch {
showTiersLoadingError = true
}
}

Expand All @@ -64,14 +62,15 @@ final class MembershipCoordinatorModel: ObservableObject {

private func showSuccessScreen(tier: MembershipTier) {
showTier = nil
loadTiers(noCache: true)


Task {
await membershipStatusStorage.refreshMembership()

// https://linear.app/anytype/issue/IOS-2434/bottom-sheet-nesting
try await Task.sleep(seconds: 0.5)
showSuccess = tier
try await Task.sleep(seconds:0.5)

try await Task.sleep(seconds: 0.5)
UINotificationFeedbackGenerator().notificationOccurred(.success)
fireConfetti = true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ extension MembershipTier {
Loc.Membership.Feature.viewers(3)
],
paymentType: nil,
color: .green
color: .green,
isTest: false,
iosProductID: ""
)
}

static var mockBuilder: MembershipTier {
MembershipTier(
type: .builder,
Expand All @@ -32,10 +34,12 @@ extension MembershipTier {
Loc.Membership.Feature.viewers(999)
],
paymentType: .mockExternal,
color: .blue
color: .blue,
isTest: false,
iosProductID: "io.anytype.membership.builder"
)
}

static var mockCoCreator: MembershipTier {
MembershipTier(
type: .coCreator,
Expand All @@ -49,10 +53,12 @@ extension MembershipTier {
Loc.Membership.Feature.viewers(999)
],
paymentType: .mockExternal,
color: .red
color: .red,
isTest: false,
iosProductID: "io.anytype.membership.cocreator"
)
}

static var mockCustom: MembershipTier {
MembershipTier(
type: .custom(id: 228),
Expand All @@ -66,10 +72,12 @@ extension MembershipTier {
Loc.Membership.Feature.viewers(999)
],
paymentType: .mockExternal,
color: .purple
color: .purple,
isTest: false,
iosProductID: "io.anytype.membership.custom"
)
}

static var mockBuilderTest: MembershipTier {
MembershipTier(
type: .custom(id: 1337),
Expand All @@ -83,7 +91,9 @@ extension MembershipTier {
Loc.Membership.Feature.viewers(999)
],
paymentType: .mockExternal,
color: .blue
color: .blue,
isTest: true,
iosProductID: "io.anytype.membership.builder.test"
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,29 @@ import Combine


final class MembershipStatusStorageMock: MembershipStatusStorageProtocol {

nonisolated static let shared = MembershipStatusStorageMock()

nonisolated init() {}

@Published var _status: MembershipStatus = .empty
var statusPublisher: AnyPublisher<MembershipStatus, Never> { $_status.eraseToAnyPublisher() }
var currentStatus: MembershipStatus { _status }


@Published var _tiers: [MembershipTier] = []
var tiersPublisher: AnyPublisher<[MembershipTier], Never> { $_tiers.eraseToAnyPublisher() }
var currentTiers: [MembershipTier] { _tiers }

func owningState(tier: Services.MembershipTier) -> MembershipTierOwningState {
.owned(.purchasedElsewhere(.desktop))
}

func startSubscription() async {


}

func refreshMembership() async {

}

func stopSubscriptionAndClean() async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@ import Foundation
import ProtobufMessages
import Combine
import Services
import AnytypeCore


@MainActor
protocol MembershipStatusStorageProtocol: Sendable {
var statusPublisher: AnyPublisher<MembershipStatus, Never> { get }
var currentStatus: MembershipStatus { get }

var tiersPublisher: AnyPublisher<[MembershipTier], Never> { get }
var currentTiers: [MembershipTier] { get }

func startSubscription() async
func stopSubscriptionAndClean() async
func refreshMembership() async
}

@MainActor
Expand All @@ -24,46 +28,79 @@ final class MembershipStatusStorage: MembershipStatusStorageProtocol {
var statusPublisher: AnyPublisher<MembershipStatus, Never> { $_status.eraseToAnyPublisher() }
var currentStatus: MembershipStatus { _status }
@Published private var _status: MembershipStatus = .empty


var tiersPublisher: AnyPublisher<[MembershipTier], Never> { $_tiers.eraseToAnyPublisher() }
var currentTiers: [MembershipTier] { _tiers }
@Published private var _tiers: [MembershipTier] = []

private var subscription: AnyCancellable?

nonisolated init() { }

func startSubscription() async {
_status = (try? await membershipService.getMembership(noCache: true)) ?? .empty
_status = (try? await membershipService.getMembership(noCache: false)) ?? .empty
_tiers = (try? await membershipService.getTiers(noCache: false)) ?? []
AnytypeAnalytics.instance().setMembershipTier(tier: _status.tier)

setupSubscription()
}

func stopSubscriptionAndClean() async {
subscription = nil
_status = .empty
_tiers = []
AnytypeAnalytics.instance().setMembershipTier(tier: _status.tier)
}


func refreshMembership() async {
_status = (try? await membershipService.getMembership(noCache: true)) ?? _status
AnytypeAnalytics.instance().setMembershipTier(tier: _status.tier)
}

// MARK: - Private

private func setupSubscription() {
private func setupSubscription() {
subscription = EventBunchSubscribtion.default.addHandler { [weak self] events in
Task { @MainActor [weak self] in
self?.handle(events: events)
}
}
}

private func handle(events: EventsBunch) {
for event in events.middlewareEvents {
switch event.value {
case .membershipUpdate(let update):
Task {
let allTiers = try await membershipService.getTiers()

_status = try builder.buildMembershipStatus(membership: update.data, allTiers: allTiers)
_status.tier.flatMap { AnytypeAnalytics.instance().logChangePlan(tier: $0) }

AnytypeAnalytics.instance().setMembershipTier(tier: _status.tier)
guard !_tiers.isEmpty else {
print("[Membership] Skipping membershipUpdate - no tiers available yet")
return
}

do {
_status = try builder.buildMembershipStatus(
membership: update.data,
allTiers: _tiers
)
_status.tier.flatMap { AnytypeAnalytics.instance().logChangePlan(tier: $0) }
AnytypeAnalytics.instance().setMembershipTier(tier: _status.tier)
print("[Membership] Updated membership status - tier: \(_status.tier?.name ?? "none")")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As claude mentioned - no prints in production code. It should be either assert or remove it

} catch {
print("[Membership] Failed to build status: \(error)")
}
}

case .membershipTiersUpdate(let update):
Task {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Task created inside main actor will still be called on the main actor. If you want to do it in the backgroind you need to call Task.detached

var built: [MembershipTier] = []
for tier in update.tiers {
if let builtTier = await builder.buildMembershipTier(tier: tier) {
built.append(builtTier)
}
}
_tiers = built
}

default:
break
}
Expand Down
3 changes: 2 additions & 1 deletion Libraryfile
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
MIDDLE_VERSION=v0.44.0-nightly.20251016.1
MIDDLE_VERSION=go-6337-make-tiersmembership-fetching-async

Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// DO NOT EDIT.
// swift-format-ignore-file
// swiftlint:disable all
//
// Generated by the Swift generator plugin for the protocol buffer compiler.
// Source: pb/protos/events.proto
//
// For information on using the generated types, please see the documentation:
// https://github.com/apple/swift-protobuf/

import SwiftProtobuf

extension Anytype_Event.Membership {
public struct TiersUpdate: Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages.

public var tiers: [Anytype_Model_MembershipTierData] = []

public var unknownFields = SwiftProtobuf.UnknownStorage()

public init() {}
}
}

extension Anytype_Event.Membership.TiersUpdate: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
public static let protoMessageName: String = Anytype_Event.Membership.protoMessageName + ".TiersUpdate"
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .same(proto: "tiers"),
]

public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeRepeatedMessageField(value: &self.tiers) }()
default: break
}
}
}

public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
if !self.tiers.isEmpty {
try visitor.visitRepeatedMessageField(value: self.tiers, fieldNumber: 1)
}
try unknownFields.traverse(visitor: &visitor)
}

public static func ==(lhs: Anytype_Event.Membership.TiersUpdate, rhs: Anytype_Event.Membership.TiersUpdate) -> Bool {
if lhs.tiers != rhs.tiers {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}

// If the compiler emits an error on this type, it is because this file
// was generated by a version of the `protoc` Swift plug-in that is
// incompatible with the version of SwiftProtobuf to which you are linking.
// Please ensure that you are building against the same version of the API
// that was used to generate this file.
fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck {
struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {}
typealias Version = _2
}

// MARK: - Code below here is support for the SwiftProtobuf runtime.

fileprivate let _protobuf_package = "anytype"
Loading