-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
4c5f8ca
commit c7c3fd4
Showing
21 changed files
with
899 additions
and
353 deletions.
There are no files selected for viewing
54 changes: 54 additions & 0 deletions
54
ios/Sources/Parra/Containers/Views/Reactions/AddReactionButtonView.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
// | ||
// AddReactionButtonView.swift | ||
// Parra | ||
// | ||
// Created by Mick MacCallum on 12/11/24. | ||
// | ||
|
||
import SwiftUI | ||
|
||
struct AddReactionButtonView: View { | ||
// MARK: - Internal | ||
|
||
let onAddReaction: () -> Void | ||
|
||
var body: some View { | ||
Button { | ||
onAddReaction() | ||
} label: { | ||
let image = UIImage( | ||
named: "custom.face.smiling.badge.plus", | ||
in: .module, | ||
with: nil | ||
)! | ||
|
||
Image(uiImage: image) | ||
.resizable() | ||
.renderingMode(.template) | ||
.frame( | ||
width: 17, | ||
height: 17 | ||
) | ||
.tint( | ||
theme.palette.primary.shade600 | ||
) | ||
.aspectRatio(contentMode: .fit) | ||
} | ||
.padding( | ||
.padding( | ||
top: 4.5, | ||
leading: 11.5, | ||
bottom: 3.5, | ||
trailing: 9 | ||
) | ||
) | ||
.background( | ||
theme.palette.primary.shade300 | ||
) | ||
.applyCornerRadii(size: .full, from: theme) | ||
} | ||
|
||
// MARK: - Private | ||
|
||
@Environment(\.parraTheme) private var theme | ||
} |
287 changes: 287 additions & 0 deletions
287
ios/Sources/Parra/Containers/Views/Reactions/FeedItemReactor.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,287 @@ | ||
// | ||
// FeedItemReactor.swift | ||
// Parra | ||
// | ||
// Created by Mick MacCallum on 12/11/24. | ||
// | ||
|
||
import Combine | ||
import SwiftUI | ||
|
||
private let logger = Logger() | ||
|
||
@MainActor | ||
class FeedItemReactor: ObservableObject { | ||
// MARK: - Lifecycle | ||
|
||
init( | ||
feedItemId: String, | ||
reactionOptionGroups: [ParraReactionOptionGroup], | ||
reactions: [ParraReactionSummary] | ||
) { | ||
self.feedItemId = feedItemId | ||
self.reactionOptionGroups = reactionOptionGroups | ||
self.currentReactions = reactions | ||
|
||
$latestReaction | ||
.map(updateReaction) | ||
.debounce( | ||
for: .seconds(2.0), | ||
scheduler: DispatchQueue.main | ||
) | ||
.asyncMap(submitUpdatedReaction) | ||
.sink(receiveValue: { _ in }) | ||
.store(in: &reactionBag) | ||
} | ||
|
||
// MARK: - Internal | ||
|
||
@Published var currentReactions: [ParraReactionSummary] | ||
|
||
let feedItemId: String | ||
let reactionOptionGroups: [ParraReactionOptionGroup] | ||
|
||
func addNewReaction( | ||
option: ParraReactionOption, | ||
api: API | ||
) { | ||
// In case the user selected a reaction from the picker that actually | ||
// already existed in the summary. | ||
if let existing = currentReactions.first(where: { reaction in | ||
reaction.id == option.id | ||
}) { | ||
// Enter the add existing flow, regardless of if this user had made | ||
// this reaction or not. They couldn't see the state from the picker | ||
latestReaction = .addedExisting(existing, api) | ||
} else { | ||
latestReaction = .addedNew(option, api) | ||
} | ||
} | ||
|
||
func addExistingReaction( | ||
option: ParraReactionSummary, | ||
api: API | ||
) { | ||
latestReaction = .addedExisting(option, api) | ||
} | ||
|
||
func removeExistingReaction( | ||
option: ParraReactionSummary, | ||
api: API | ||
) { | ||
latestReaction = .removedExisting(option.reactionId, api) | ||
} | ||
|
||
// MARK: - Private | ||
|
||
private enum ReactionUpdate { | ||
case addedNew(ParraReactionOption, API) | ||
case addedExisting(ParraReactionSummary, API) | ||
case removedExisting(String?, API) | ||
} | ||
|
||
private var reactionBag = Set<AnyCancellable>() | ||
|
||
@Published private var latestReaction: ReactionUpdate? | ||
|
||
@MainActor | ||
private func submitUpdatedReaction( | ||
_ update: ReactionUpdate? | ||
) async -> ReactionUpdate? { | ||
guard let update else { | ||
return nil | ||
} | ||
|
||
switch update { | ||
case .addedNew(let option, let api): | ||
logger.info("Adding new reaction") | ||
|
||
await submitAddedReaction( | ||
reactionOptionId: option.id, | ||
api: api | ||
) | ||
case .addedExisting(let summary, let api): | ||
logger.info("Adding existing reaction") | ||
|
||
await submitAddedReaction( | ||
reactionOptionId: summary.id, | ||
api: api | ||
) | ||
case .removedExisting(let reactionId, let api): | ||
guard let reactionId else { | ||
logger.warn( | ||
"Skipping removal of reaction. No reaction from user." | ||
) | ||
|
||
return nil | ||
} | ||
|
||
logger.info("Removing reaction") | ||
|
||
do { | ||
try await api.removeFeedReaction( | ||
feedItemId: feedItemId, | ||
reactionId: reactionId | ||
) | ||
} catch { | ||
if let idx = currentReactions.firstIndex(where: { reaction in | ||
reaction.reactionId == reactionId | ||
}) { | ||
let matching = currentReactions[idx] | ||
|
||
currentReactions[idx] = ParraReactionSummary( | ||
id: matching.id, | ||
name: matching.name, | ||
type: matching.type, | ||
value: matching.value, | ||
count: matching.count + 1, | ||
reactionId: reactionId | ||
) | ||
} else { | ||
// There isn't a great way to show the reaction again | ||
// if it was removed optimistically, but this might be fine | ||
// for now. | ||
} | ||
} | ||
} | ||
|
||
return nil | ||
} | ||
|
||
private func submitAddedReaction( | ||
reactionOptionId: String, | ||
api: API | ||
) async { | ||
let findAnyRemove = { [self] in | ||
if let idx = currentReactions.firstIndex(where: { reaction in | ||
reaction.id == reactionOptionId | ||
}) { | ||
let match = currentReactions[idx] | ||
|
||
if match.count <= 1 { | ||
currentReactions.remove(at: idx) | ||
} else { | ||
currentReactions[idx] = ParraReactionSummary( | ||
id: match.id, | ||
name: match.name, | ||
type: match.type, | ||
value: match.value, | ||
count: match.count - 1, | ||
reactionId: nil | ||
) | ||
} | ||
} | ||
} | ||
|
||
do { | ||
let response = try await api.addFeedReaction( | ||
feedItemId: feedItemId, | ||
reactionOptionId: reactionOptionId | ||
) | ||
|
||
if let idx = currentReactions.firstIndex(where: { reaction in | ||
reaction.id == reactionOptionId | ||
}) { | ||
let match = currentReactions[idx] | ||
|
||
currentReactions[idx] = ParraReactionSummary( | ||
id: reactionOptionId, | ||
name: match.name, | ||
type: match.type, | ||
value: match.value, | ||
count: 1, | ||
reactionId: response.id | ||
) | ||
} | ||
} catch let error as ParraError { | ||
if case .networkError(_, let response, _) = error, | ||
response.statusCode == 409 | ||
{ | ||
logger.warn("User already had this reaction.") | ||
} else { | ||
logger.error("Error adding new reaction", error) | ||
|
||
findAnyRemove() | ||
} | ||
} catch { | ||
logger.error("Error adding new reaction", error) | ||
|
||
findAnyRemove() | ||
} | ||
} | ||
|
||
/// make the immediate update locally | ||
private func updateReaction(_ update: ReactionUpdate?) -> ReactionUpdate? { | ||
guard let update else { | ||
return nil | ||
} | ||
|
||
switch update { | ||
case .addedNew(let option, _): | ||
currentReactions.append( | ||
ParraReactionSummary( | ||
id: option.id, | ||
name: option.name, | ||
type: option.type, | ||
value: option.value, | ||
count: 1, // it didn't exist previously so assume count is 1 | ||
reactionId: "placeholder" | ||
) | ||
) | ||
|
||
return update | ||
case .addedExisting(let summary, _): | ||
// If this flow is entered, we know the new reaction is one that | ||
// already existed in the summary. The filtering for this happens | ||
// in the previous step. | ||
guard let idx = currentReactions.firstIndex(where: { reaction in | ||
reaction.id == summary.id | ||
}) else { | ||
return nil | ||
} | ||
|
||
let matching = currentReactions[idx] | ||
|
||
if matching.reactionId != nil { | ||
// The user already did this reaction | ||
return nil | ||
} | ||
|
||
// This user hadn't previously reacted | ||
currentReactions[idx] = ParraReactionSummary( | ||
id: matching.id, | ||
name: matching.name, | ||
type: matching.type, | ||
value: matching.value, | ||
count: matching.count + 1, | ||
reactionId: matching.reactionId ?? "placeholder" | ||
) | ||
|
||
return update | ||
case .removedExisting(let reactionId, _): | ||
guard let idx = currentReactions.firstIndex(where: { reaction in | ||
reaction.reactionId == reactionId | ||
}) else { | ||
return nil | ||
} | ||
|
||
let matching = currentReactions[idx] | ||
|
||
if matching.count <= 1 { | ||
currentReactions.remove(at: idx) | ||
} else { | ||
currentReactions[idx] = ParraReactionSummary( | ||
id: matching.id, | ||
name: matching.name, | ||
type: matching.type, | ||
value: matching.value, | ||
count: matching.count - 1, | ||
// user is removing reaction | ||
reactionId: nil | ||
) | ||
} | ||
|
||
return update | ||
} | ||
} | ||
} |
Oops, something went wrong.