diff --git a/ios/Sources/Parra/Containers/Views/Reactions/AddReactionButtonView.swift b/ios/Sources/Parra/Containers/Views/Reactions/AddReactionButtonView.swift new file mode 100644 index 00000000..ba7270b4 --- /dev/null +++ b/ios/Sources/Parra/Containers/Views/Reactions/AddReactionButtonView.swift @@ -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 +} diff --git a/ios/Sources/Parra/Containers/Views/Reactions/FeedItemReactor.swift b/ios/Sources/Parra/Containers/Views/Reactions/FeedItemReactor.swift new file mode 100644 index 00000000..ee393e0b --- /dev/null +++ b/ios/Sources/Parra/Containers/Views/Reactions/FeedItemReactor.swift @@ -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() + + @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 + } + } +} diff --git a/ios/Sources/Parra/Containers/Views/Reactions/FeedReactionView.swift b/ios/Sources/Parra/Containers/Views/Reactions/FeedReactionView.swift new file mode 100644 index 00000000..4910b484 --- /dev/null +++ b/ios/Sources/Parra/Containers/Views/Reactions/FeedReactionView.swift @@ -0,0 +1,100 @@ +// +// FeedReactionView.swift +// Parra +// +// Created by Mick MacCallum on 12/9/24. +// + +import SwiftUI + +struct FeedReactionView: View { + // MARK: - Lifecycle + + init?( + feedItemId: String, + reactionOptionGroups: [ParraReactionOptionGroup]?, + reactions: [ParraReactionSummary]? + ) { + guard let reactionOptionGroups, !reactionOptionGroups.isEmpty else { + return nil + } + + self.feedItemId = feedItemId + self._reactor = StateObject( + wrappedValue: FeedItemReactor( + feedItemId: feedItemId, + reactionOptionGroups: reactionOptionGroups, + reactions: reactions ?? [] + ) + ) + } + + // MARK: - Internal + + let feedItemId: String + + var body: some View { + HStack { + ForEach(reactor.currentReactions) { reaction in + ReactionButtonView(reaction: reaction) { reacted, summary in + if reacted { + reactor.addExistingReaction( + option: summary, + api: parra.parraInternal.api + ) + } else { + reactor.removeExistingReaction( + option: summary, + api: parra.parraInternal.api + ) + } + } + } + + AddReactionButtonView { + isReactionPickerPresented = true + } + } + .onChange(of: pickerSelectedReaction) { oldValue, newValue in + if let newValue, oldValue == nil { + reactor.addNewReaction( + option: newValue, + api: parra.parraInternal.api + ) + pickerSelectedReaction = nil + } + } + .sheet( + isPresented: $isReactionPickerPresented + ) { + NavigationStack { + ReactionPickerView( + selectedOption: $pickerSelectedReaction, + optionGroups: reactor.reactionOptionGroups, + showLabels: false, + searchEnabled: false + ) + .presentationDetents([.large, .fraction(0.33)]) + .presentationDragIndicator(.visible) + } + } + } + + // MARK: - Private + + @Environment(\.parra) private var parra + + @State private var isReactionPickerPresented: Bool = false + @State private var pickerSelectedReaction: ParraReactionOption? + @StateObject private var reactor: FeedItemReactor +} + +#Preview { + ParraAppPreview { + FeedReactionView( + feedItemId: .uuid, + reactionOptionGroups: ParraReactionOptionGroup.validStates(), + reactions: ParraReactionSummary.validStates() + ) + } +} diff --git a/ios/Sources/Parra/Containers/Widgets/Reactions/Fixtures/ParraReactionOptionGroup+Fixtures.swift b/ios/Sources/Parra/Containers/Views/Reactions/Fixtures/ParraReactionOptionGroup+Fixtures.swift similarity index 100% rename from ios/Sources/Parra/Containers/Widgets/Reactions/Fixtures/ParraReactionOptionGroup+Fixtures.swift rename to ios/Sources/Parra/Containers/Views/Reactions/Fixtures/ParraReactionOptionGroup+Fixtures.swift diff --git a/ios/Sources/Parra/Containers/Widgets/Reactions/Fixtures/ParraReactionSummary+Fixtures.swift b/ios/Sources/Parra/Containers/Views/Reactions/Fixtures/ParraReactionSummary+Fixtures.swift similarity index 100% rename from ios/Sources/Parra/Containers/Widgets/Reactions/Fixtures/ParraReactionSummary+Fixtures.swift rename to ios/Sources/Parra/Containers/Views/Reactions/Fixtures/ParraReactionSummary+Fixtures.swift diff --git a/ios/Sources/Parra/Containers/Views/Reactions/GifImageView.swift b/ios/Sources/Parra/Containers/Views/Reactions/GifImageView.swift new file mode 100644 index 00000000..6c19fc56 --- /dev/null +++ b/ios/Sources/Parra/Containers/Views/Reactions/GifImageView.swift @@ -0,0 +1,48 @@ +// +// GifImageView.swift +// Parra +// +// Created by Mick MacCallum on 12/11/24. +// + +import SwiftUI +import UIKit +import WebKit + +struct GifImageView: UIViewRepresentable { + // MARK: - Lifecycle + + init(url: URL) { + self.url = url + } + + // MARK: - Internal + + func makeUIView(context: Context) -> WKWebView { + let webview = WKWebView() + + webview.allowsLinkPreview = false + webview.allowsBackForwardNavigationGestures = false + + webview.load(URLRequest(url: url)) + + return webview + } + + func updateUIView(_ uiView: WKWebView, context: Context) { + uiView.reload() + } + + // MARK: - Private + + private let url: URL +} + +#Preview { + GifImageView( + url: URL( + string: + "https://i.giphy.com/media/v1.Y2lkPTc5MGI3NjExZ29pdGtnN2hoNTVwa3prM2ZjZzRrNXptOW5tc3pmMGJ5MzVybm9kNiZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/UTSxCoPWRbhD3Xn4rt/giphy.gif" + )! + ) +} diff --git a/ios/Sources/Parra/Containers/Views/Reactions/ReactionButtonView.swift b/ios/Sources/Parra/Containers/Views/Reactions/ReactionButtonView.swift new file mode 100644 index 00000000..319edc93 --- /dev/null +++ b/ios/Sources/Parra/Containers/Views/Reactions/ReactionButtonView.swift @@ -0,0 +1,75 @@ +// +// ReactionButtonView.swift +// Parra +// +// Created by Mick MacCallum on 12/11/24. +// + +import SwiftUI + +struct ReactionButtonView: View { + // MARK: - Internal + + let reaction: ParraReactionSummary + let onToggleReaction: (Bool, ParraReactionSummary) -> Void + + var currentUserReacted: Bool { + return reaction.reactionId != nil + } + + var body: some View { + Button { + onToggleReaction(!currentUserReacted, reaction) + } label: { + HStack(alignment: .center, spacing: 3) { + switch reaction.type { + case .emoji: + Text(reaction.value) + .font(.system(size: 500)) + .minimumScaleFactor(0.01) + .frame( + width: 17.0, + height: 17.0 + ) + case .custom: + if let url = URL(string: reaction.value) { + componentFactory.buildAsyncImage( + config: ParraImageConfig( + aspectRatio: 1.0, + contentMode: .fit + ), + content: ParraAsyncImageContent(url: url), + localAttributes: ParraAttributes.AsyncImage( + size: CGSize(width: 17.0, height: 17.0) + ) + ) + } + } + + Text(reaction.count.formatted( + .number + )) + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(theme.palette.primary.shade600) + } + } + .padding( + .padding( + vertical: 4, + horizontal: 8 + ) + ) + .background( + currentUserReacted + ? theme.palette.primary.shade300 + : theme.palette.primary.shade400 + ) + .applyCornerRadii(size: .full, from: theme) + } + + // MARK: - Private + + @Environment(\.parraTheme) private var theme + @Environment(\.parraComponentFactory) private var componentFactory +} diff --git a/ios/Sources/Parra/Containers/Views/Reactions/ReactionPickerView.swift b/ios/Sources/Parra/Containers/Views/Reactions/ReactionPickerView.swift new file mode 100644 index 00000000..c364801c --- /dev/null +++ b/ios/Sources/Parra/Containers/Views/Reactions/ReactionPickerView.swift @@ -0,0 +1,227 @@ +// +// ReactionPickerView.swift +// Parra +// +// Created by Mick MacCallum on 12/9/24. +// + +import SwiftUI + +struct ReactionPickerView: View { + // MARK: - Lifecycle + + init( + selectedOption: Binding, + optionGroups: [ParraReactionOptionGroup], + showLabels: Bool = true, + searchEnabled: Bool = false + ) { + self._selectedOption = selectedOption + self.searchEnabled = searchEnabled + self.showLabels = showLabels + self.optionGroups = optionGroups + } + + // MARK: - Internal + + @Environment(\.dismiss) var dismiss + + @Binding var selectedOption: ParraReactionOption? + + let columns = [ + GridItem(.adaptive(minimum: 80)) + ] + + let optionGroups: [ParraReactionOptionGroup] + let showLabels: Bool + + var body: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 20) { + ForEach(searchResults, id: \.self) { group in + Section { + ForEach(group.options, id: \.self) { reactionOption in + Button { + selectedOption = reactionOption + dismiss() + } label: { + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(( + selectedOption == reactionOption ? .blue : + Color + .gray + ).opacity(0.4)) + .frame(width: 64, height: 64) + .overlay { + reactionContent(for: reactionOption) + } + + if showLabels { + Text(reactionOption.name) + .font(.caption2) + .multilineTextAlignment(.center) + .lineLimit(2, reservesSpace: true) + .backgroundStyle(.red) + } + } + } + // Required to prevent highlighting the button then dragging the scroll + // view from causing the button to be pressed. + .simultaneousGesture(TapGesture()) + .buttonStyle(.plain) + } + } header: { + VStack { + Text(group.name) + .font(.headline) + + if let description = group.description { + Text(description) + .font(.caption) + .foregroundColor(.secondary) + } + } + .frame( + maxWidth: .infinity, + alignment: .center + ) + } + } + } + .safeAreaPadding(.top) + .padding(.horizontal) + } + .frame(maxHeight: .infinity) + .if(searchEnabled) { ctx in + ctx.searchable(text: $search) + } + } + + // MARK: - Private + + @State private var search: String = "" + @Environment(\.parraComponentFactory) private var componentFactory + + private var searchEnabled: Bool + + private var searchResults: [ParraReactionOptionGroup] { + if search.isEmpty { + return optionGroups + } else { + return optionGroups.compactMap { group in + let filtered = group.options.filter { option in + option.name.lowercased().contains(search.lowercased()) + } + + if filtered.isEmpty { + return nil + } + + return ParraReactionOptionGroup( + id: group.id, + name: group.name, + description: group.description, + options: filtered + ) + } + } + } + + @ViewBuilder + private func reactionContent( + for reactionOption: ParraReactionOption + ) -> some View { + switch reactionOption.type { + case .custom: + if let url = URL(string: reactionOption.value) { + if url.lastPathComponent.hasSuffix(".gif") { + GifImageView(url: url) + .frame( + width: 40.0, + height: 40.0 + ) + } else { + componentFactory.buildAsyncImage( + config: ParraImageConfig( + aspectRatio: 1.0, + contentMode: .fit + ), + content: ParraAsyncImageContent( + url: url + ), + localAttributes: ParraAttributes.AsyncImage( + size: CGSize( + width: 40.0, + height: 40.0 + ) + ) + ) + } + } + case .emoji: + Text(reactionOption.value) + .font(.system(size: 500)) + .minimumScaleFactor(0.01) + .frame( + width: 40.0, + height: 40.0 + ) + } + } +} + +#Preview { + NavigationView { + ReactionPickerView( + selectedOption: .constant(nil), + optionGroups: [ + ParraReactionOptionGroup( + id: .uuid, + name: "Emojis", + description: "Standard system emojis", + options: [ + ParraReactionOption( + id: .uuid, + name: "Thumbs Up", + type: .emoji, + value: "👍" + ), + ParraReactionOption( + id: .uuid, + name: "Thumbs Down", + type: .emoji, + value: "👎" + ) + ] + ), + ParraReactionOptionGroup( + id: .uuid, + name: "Custom Emojis", + description: "Special emojis we made just for you", + options: [ + ParraReactionOption( + id: .uuid, + name: "Take My Money", + type: .custom, + value: "https://emojis.slackmojis.com/emojis/images/1643514048/65/take_my_money.png?1643514048" + ), + ParraReactionOption( + id: .uuid, + name: "Beachball", + type: .custom, + value: "https://emojis.slackmojis.com/emojis/images/1643514710/7127/beach-ball.gif?1643514710" + ), + ParraReactionOption( + id: .uuid, + name: "Cool Doge", + type: .custom, + value: "https://emojis.slackmojis.com/emojis/images/1643514389/3643/cool-doge.gif?1643514389" + ) + ] + ) + ], + searchEnabled: true + ) + } +} diff --git a/ios/Sources/Parra/Containers/Widgets/Feed/Public/ParraFeedListView.swift b/ios/Sources/Parra/Containers/Widgets/Feed/Public/ParraFeedListView.swift index b19548d5..9bcedd71 100644 --- a/ios/Sources/Parra/Containers/Widgets/Feed/Public/ParraFeedListView.swift +++ b/ios/Sources/Parra/Containers/Widgets/Feed/Public/ParraFeedListView.swift @@ -54,6 +54,9 @@ public struct ParraFeedListView: View { if case .feedItemYoutubeVideo(let data) = item.data { FeedYouTubeVideoView( youtubeVideo: data, + feedItemId: item.id, + reactionOptions: item.reactionOptions?.elements, + reactions: item.reactions?.elements, containerGeometry: containerGeometry, spacing: spacing, performActionForFeedItemData: performActionForFeedItemData @@ -76,6 +79,9 @@ public struct ParraFeedListView: View { } else if case .creatorUpdate(let data) = item.data { FeedCreatorUpdateView( creatorUpdate: data, + feedItemId: item.id, + reactionOptions: item.reactionOptions?.elements, + reactions: item.reactions?.elements, containerGeometry: containerGeometry, spacing: spacing, performActionForFeedItemData: performActionForFeedItemData diff --git a/ios/Sources/Parra/Containers/Widgets/Feed/Public/View+Feed.swift b/ios/Sources/Parra/Containers/Widgets/Feed/Public/View+Feed.swift index 9fbe71cd..3dd5e203 100644 --- a/ios/Sources/Parra/Containers/Widgets/Feed/Public/View+Feed.swift +++ b/ios/Sources/Parra/Containers/Widgets/Feed/Public/View+Feed.swift @@ -24,13 +24,23 @@ public extension View { FeedParams, FeedWidget >.Transformer = { parra, _ in - let response = try await parra.parraInternal.api - .paginateFeed(feedId: feedId, limit: 15, offset: 0) + do { + let response = try await parra.parraInternal.api + .paginateFeed(feedId: feedId, limit: 15, offset: 0) - return FeedParams( - feedId: feedId, - feedCollectionResponse: response - ) + return FeedParams( + feedId: feedId, + feedCollectionResponse: response + ) + } catch let error as ParraError { + if case .networkError(_, let response, _) = error { + if response.statusCode == 404 { + Logger.error("Can not find feed with id \(feedId)") + } + } + + throw error + } } return loadAndPresentSheet( diff --git a/ios/Sources/Parra/Containers/Widgets/Feed/Views/FeedContentCardView.swift b/ios/Sources/Parra/Containers/Widgets/Feed/Views/FeedContentCardView.swift index 7aa16102..753e7aed 100644 --- a/ios/Sources/Parra/Containers/Widgets/Feed/Views/FeedContentCardView.swift +++ b/ios/Sources/Parra/Containers/Widgets/Feed/Views/FeedContentCardView.swift @@ -42,7 +42,7 @@ struct FeedContentCardView: View { } } .background(parraTheme.palette.secondaryBackground) - .applyCornerRadii(size: .xxxl, from: parraTheme) + .applyCornerRadii(size: .xl, from: parraTheme) .buttonStyle(ContentCardButtonStyle()) // Required to prevent highlighting the button then dragging the scroll // view from causing the button to be pressed. diff --git a/ios/Sources/Parra/Containers/Widgets/Feed/Views/FeedCreatorUpdateView.swift b/ios/Sources/Parra/Containers/Widgets/Feed/Views/FeedCreatorUpdateView.swift index 77860fd0..b8eab9d1 100644 --- a/ios/Sources/Parra/Containers/Widgets/Feed/Views/FeedCreatorUpdateView.swift +++ b/ios/Sources/Parra/Containers/Widgets/Feed/Views/FeedCreatorUpdateView.swift @@ -11,6 +11,9 @@ struct FeedCreatorUpdateView: View { // MARK: - Internal let creatorUpdate: ParraCreatorUpdateAppStub + let feedItemId: String + let reactionOptions: [ParraReactionOptionGroup]? + let reactions: [ParraReactionSummary]? let containerGeometry: GeometryProxy let spacing: CGFloat let performActionForFeedItemData: (_ feedItemData: ParraFeedItemData) -> Void @@ -70,7 +73,6 @@ struct FeedCreatorUpdateView: View { alignment: .leading ) .padding([.horizontal, .top], 16) - .padding(.bottom, 8) if let attachments = creatorUpdate.attachments?.elements, !attachments.isEmpty @@ -96,10 +98,24 @@ struct FeedCreatorUpdateView: View { containerGeometry: containerGeometry ) } + .padding(.top, 8) } + + VStack { + FeedReactionView( + feedItemId: feedItemId, + reactionOptionGroups: reactionOptions, + reactions: reactions + ) + } + .padding() + .frame( + maxWidth: .infinity, + alignment: .leading + ) } .background(parraTheme.palette.secondaryBackground) - .applyCornerRadii(size: .xxxl, from: parraTheme) + .applyCornerRadii(size: .xl, from: parraTheme) .padding(.vertical, spacing) .safeAreaPadding(.horizontal) } @@ -240,10 +256,12 @@ public struct TestModel: Codable, Equatable, Hashable, Identifiable { VStack { FeedCreatorUpdateView( creatorUpdate: ParraCreatorUpdateAppStub.validStates()[0], + feedItemId: .uuid, + reactionOptions: ParraReactionOptionGroup.validStates(), + reactions: ParraReactionSummary.validStates(), containerGeometry: proxy, spacing: 18, - performActionForFeedItemData: { _ in - } + performActionForFeedItemData: { _ in } ) } } diff --git a/ios/Sources/Parra/Containers/Widgets/Feed/Views/FeedYouTubeVideoDetailView.swift b/ios/Sources/Parra/Containers/Widgets/Feed/Views/FeedYouTubeVideoDetailView.swift index 00b6d043..eaf48c23 100644 --- a/ios/Sources/Parra/Containers/Widgets/Feed/Views/FeedYouTubeVideoDetailView.swift +++ b/ios/Sources/Parra/Containers/Widgets/Feed/Views/FeedYouTubeVideoDetailView.swift @@ -11,6 +11,17 @@ struct FeedYouTubeVideoDetailView: View { // MARK: - Internal let youtubeVideo: ParraFeedItemYoutubeVideoData + let feedItemId: String + let reactionOptions: [ParraReactionOptionGroup]? + let reactions: [ParraReactionSummary]? + + var showReactions: Bool { + if let reactionOptions { + return !reactionOptions.isEmpty + } + + return false + } var body: some View { ScrollView { @@ -98,6 +109,23 @@ struct FeedYouTubeVideoDetailView: View { } } + if showReactions { + VStack { + FeedReactionView( + feedItemId: feedItemId, + reactionOptionGroups: reactionOptions, + reactions: reactions + ) + } + .padding( + .padding(top: 8, leading: 0, bottom: 8, trailing: 0) + ) + .frame( + maxWidth: .infinity, + alignment: .leading + ) + } + withContent(content: youtubeVideo.description) { content in Text( content.attributedStringWithHighlightedLinks( diff --git a/ios/Sources/Parra/Containers/Widgets/Feed/Views/FeedYouTubeVideoView.swift b/ios/Sources/Parra/Containers/Widgets/Feed/Views/FeedYouTubeVideoView.swift index 6b495710..27a976fd 100644 --- a/ios/Sources/Parra/Containers/Widgets/Feed/Views/FeedYouTubeVideoView.swift +++ b/ios/Sources/Parra/Containers/Widgets/Feed/Views/FeedYouTubeVideoView.swift @@ -11,10 +11,21 @@ struct FeedYouTubeVideoView: View { // MARK: - Internal let youtubeVideo: ParraFeedItemYoutubeVideoData + let feedItemId: String + let reactionOptions: [ParraReactionOptionGroup]? + let reactions: [ParraReactionSummary]? let containerGeometry: GeometryProxy let spacing: CGFloat let performActionForFeedItemData: (_ feedItemData: ParraFeedItemData) -> Void + var showReactions: Bool { + if let reactionOptions { + return !reactionOptions.isEmpty + } + + return false + } + var body: some View { Button(action: { isPresentingModal = true @@ -105,7 +116,24 @@ struct FeedYouTubeVideoView: View { ) .padding(.top, 6) .padding(.horizontal, 12) - .padding(.bottom, 16) + .padding(.bottom, showReactions ? 0 : 16) + + if showReactions { + VStack { + FeedReactionView( + feedItemId: feedItemId, + reactionOptionGroups: reactionOptions, + reactions: reactions + ) + } + .padding( + .padding(top: 8, leading: 16, bottom: 16, trailing: 16) + ) + .frame( + maxWidth: .infinity, + alignment: .leading + ) + } } .contentShape(.rect) } @@ -114,14 +142,17 @@ struct FeedYouTubeVideoView: View { .simultaneousGesture(TapGesture()) .disabled(!redactionReasons.isEmpty) .background(parraTheme.palette.secondaryBackground) - .applyCornerRadii(size: .xxxl, from: parraTheme) + .applyCornerRadii(size: .xl, from: parraTheme) .buttonStyle(.plain) .safeAreaPadding(.horizontal) .padding(.vertical, spacing) .sheet(isPresented: $isPresentingModal) {} content: { NavigationStack { FeedYouTubeVideoDetailView( - youtubeVideo: youtubeVideo + youtubeVideo: youtubeVideo, + feedItemId: feedItemId, + reactionOptions: reactionOptions, + reactions: reactions ) } } diff --git a/ios/Sources/Parra/Containers/Widgets/Reactions/ReactionWidget+ContentObserver+Content.swift b/ios/Sources/Parra/Containers/Widgets/Reactions/ReactionWidget+ContentObserver+Content.swift deleted file mode 100644 index 017344e3..00000000 --- a/ios/Sources/Parra/Containers/Widgets/Reactions/ReactionWidget+ContentObserver+Content.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// ReactionWidget+ContentObserver+Content.swift -// Parra -// -// Created by Mick MacCallum on 12/11/24. -// - -import Foundation - -extension ReactionWidget.ContentObserver { - struct Content: ParraContainerContent { - init() {} - } -} diff --git a/ios/Sources/Parra/Containers/Widgets/Reactions/ReactionWidget+ContentObserver+InitialParams.swift b/ios/Sources/Parra/Containers/Widgets/Reactions/ReactionWidget+ContentObserver+InitialParams.swift deleted file mode 100644 index a46669b1..00000000 --- a/ios/Sources/Parra/Containers/Widgets/Reactions/ReactionWidget+ContentObserver+InitialParams.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// ReactionWidget+ContentObserver+InitialParams.swift -// Parra -// -// Created by Mick MacCallum on 12/11/24. -// - -import Foundation - -import Foundation - -extension ReactionWidget.ContentObserver { - struct InitialParams { -// let roadmapConfig: ParraAppRoadmapConfiguration -// let selectedTab: ParraRoadmapConfigurationTab -// let ticketResponse: ParraUserTicketCollectionResponse - let api: API - } -} diff --git a/ios/Sources/Parra/Containers/Widgets/Reactions/ReactionWidget+ContentObserver.swift b/ios/Sources/Parra/Containers/Widgets/Reactions/ReactionWidget+ContentObserver.swift deleted file mode 100644 index c87a8678..00000000 --- a/ios/Sources/Parra/Containers/Widgets/Reactions/ReactionWidget+ContentObserver.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// ReactionWidget+ContentObserver.swift -// Parra -// -// Created by Mick MacCallum on 12/11/24. -// - -import Combine -import SwiftUI - -extension ReactionWidget { - class ContentObserver: ParraContainerContentObserver { - // MARK: - Lifecycle - - required init( - initialParams: InitialParams - ) { - self.content = Content() - } - - // MARK: - Internal - - @Published private(set) var content: Content - } -} diff --git a/ios/Sources/Parra/Containers/Widgets/Reactions/ReactionWidget.swift b/ios/Sources/Parra/Containers/Widgets/Reactions/ReactionWidget.swift deleted file mode 100644 index 9248e72b..00000000 --- a/ios/Sources/Parra/Containers/Widgets/Reactions/ReactionWidget.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// ReactionWidget.swift -// Parra -// -// Created by Mick MacCallum on 12/11/24. -// - -import SwiftUI - -struct ReactionWidget: ParraContainer { - // MARK: - Lifecycle - - init( - config: ParraRoadmapWidgetConfig, - contentObserver: ContentObserver - ) { - self.config = config - self._contentObserver = StateObject(wrappedValue: contentObserver) - } - - // MARK: - Internal - - @StateObject var contentObserver: ContentObserver - let config: ParraRoadmapWidgetConfig - -// var items: Binding<[TicketUserContent]> { -// return $contentObserver.ticketPaginator.items -// } - - var body: some View { - EmptyView() - } - - // MARK: - Private - - @Environment(\.parraComponentFactory) private var componentFactory - @Environment(\.parraConfiguration) private var parraConfiguration - @Environment(\.parraTheme) private var parraTheme -} - -#Preview { - ParraContainerPreview( - config: .default - ) { parra, _, config in - ReactionWidget( - config: config, - contentObserver: .init( - initialParams: ReactionWidget.ContentObserver.InitialParams( - // roadmapConfig: ParraAppRoadmapConfiguration.validStates()[0], -// selectedTab: ParraAppRoadmapConfiguration.validStates()[0] -// .tabs.elements[0], -// ticketResponse: ParraUserTicketCollectionResponse -// .validStates()[0], - api: parra.parraInternal.api - ) - ) - ) - } -} diff --git a/ios/Sources/Parra/Containers/Widgets/Reactions/Views/EmojiPickerView.swift b/ios/Sources/Parra/Containers/Widgets/Reactions/Views/EmojiPickerView.swift deleted file mode 100644 index 9cb7092f..00000000 --- a/ios/Sources/Parra/Containers/Widgets/Reactions/Views/EmojiPickerView.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// EmojiPickerView.swift -// Parra -// -// Created by Mick MacCallum on 12/9/24. -// - -import SwiftUI - -struct EmojiPickerView: View { - // MARK: - Lifecycle - - init( - selectedEmoji: Binding, - searchEnabled: Bool = false, - selectedColor: Color = .blue - ) { - self._selectedEmoji = selectedEmoji - self.selectedColor = selectedColor - self.searchEnabled = searchEnabled - self.emojis = EmojiUtils.allEmojis - } - - // MARK: - Internal - - @Environment(\.dismiss) var dismiss - - @Binding var selectedEmoji: EmojiUtils.Emoji? - - let columns = [ - GridItem(.adaptive(minimum: 80)) - ] - - let emojis: [EmojiUtils.Emoji] - - var body: some View { - ScrollView { - LazyVGrid(columns: columns, spacing: 20) { - ForEach(searchResults, id: \.self) { emoji in - Button { - selectedEmoji = emoji - dismiss() - } label: { - VStack { - RoundedRectangle(cornerRadius: 16) - .fill(( - selectedEmoji == emoji ? selectedColor : Color - .gray - ).opacity(0.4)) - .frame(width: 64, height: 64) - .overlay { - Text(emoji.value) - .font(.largeTitle) - } - - Text(emoji.name) - .font(.caption2) - .multilineTextAlignment(.center) - .lineLimit(2, reservesSpace: true) - .backgroundStyle(.red) - } - } - .buttonStyle(.plain) - } - } - .padding(.horizontal) - } - .frame(maxHeight: .infinity) - .searchable(text: $search) - } - - // MARK: - Private - - @State private var search: String = "" - - private var selectedColor: Color - private var searchEnabled: Bool - - private var searchResults: [EmojiUtils.Emoji] { - if search.isEmpty { - return emojis - } else { - return emojis - .filter { $0.name.lowercased().contains(search.lowercased()) } - } - } -} - -#Preview { - EmojiPickerView( - selectedEmoji: .constant(nil) - ) -} diff --git a/ios/Sources/Parra/Containers/Widgets/Reactions/Views/ReactionView.swift b/ios/Sources/Parra/Containers/Widgets/Reactions/Views/ReactionView.swift deleted file mode 100644 index 81800b0b..00000000 --- a/ios/Sources/Parra/Containers/Widgets/Reactions/Views/ReactionView.swift +++ /dev/null @@ -1,128 +0,0 @@ -// -// ReactionView.swift -// Parra -// -// Created by Mick MacCallum on 12/9/24. -// - -import SwiftUI - -struct ReactionButtonView: View { - // MARK: - Internal - - let reaction: ParraReactionSummary - let onToggleReaction: (Bool, ParraReactionSummary) -> Void - - var currentUserReacted: Bool { - return reaction.reactionId != nil - } - - var body: some View { - switch reaction.type { - case .emoji: - Button { - onToggleReaction(!currentUserReacted, reaction) - } label: { - HStack(alignment: .center, spacing: 3) { - Text(reaction.value) - .font(.callout) - - Text(reaction.count.formatted( - .number - )) - .font(.caption) - } - } - .padding( - .padding( - vertical: 4, - horizontal: 8 - ) - ) - .background( - currentUserReacted - ? theme.palette.secondaryBackground.withLuminosity(0.8) - : theme.palette.secondaryBackground - ) - .applyCornerRadii(size: .full, from: theme) - .frame( - height: 20 - ) - case .custom: - EmptyView() - } - } - - // MARK: - Private - - @Environment(\.parraTheme) private var theme -} - -struct AddReactionButtonView: View { - // MARK: - Internal - - var body: some View { - Button {} label: { - let image = UIImage( - named: "custom.face.smiling.badge.plus", - in: .module, - with: nil - )! - - Image(uiImage: image) - .resizable() - .renderingMode(.template) - .frame( - width: 19, - height: 19 - ) - .tint( - theme.palette.secondaryText - ) - .aspectRatio(contentMode: .fit) - } - .padding( - .padding( - top: 5, - leading: 11, - bottom: 3, - trailing: 8 - ) - ) - .background( - theme.palette.secondaryBackground - ) - .applyCornerRadii(size: .full, from: theme) - .frame( - height: 20 - ) - } - - // MARK: - Private - - @Environment(\.parraTheme) private var theme -} - -struct ReactionView: View { - let reactionOptions: [ParraReactionOptionGroup] - let reactions: [ParraReactionSummary] - - var body: some View { - LazyHStack { - ForEach(reactions) { reaction in - ReactionButtonView(reaction: reaction) { _, _ in } - } - - AddReactionButtonView() - } - } -} - -#Preview { - ParraAppPreview { - ReactionView( - reactionOptions: ParraReactionOptionGroup.validStates(), - reactions: ParraReactionSummary.validStates() - ) - } -} diff --git a/ios/Sources/Parra/Types/Network/Endpoints/EndpointResolver.swift b/ios/Sources/Parra/Types/Network/Endpoints/EndpointResolver.swift index 2d6653bd..b6c7a5e2 100644 --- a/ios/Sources/Parra/Types/Network/Endpoints/EndpointResolver.swift +++ b/ios/Sources/Parra/Types/Network/Endpoints/EndpointResolver.swift @@ -199,7 +199,7 @@ enum EndpointResolver { case .getFaqs: return "tenants//applications/\(applicationId)/faqs" case .postFeedReaction(let feedItemId): - return "tenants/\(tenantId)/feed/items/\(feedItemId)/reactions/:reactionId" + return "tenants/\(tenantId)/feed/items/\(feedItemId)/reactions" case .deleteFeedReaction(let feedItemId, let reactionId): return "tenants/\(tenantId)/feed/items/\(feedItemId)/reactions/\(reactionId)" }