diff --git a/Threaded.xcodeproj/project.pbxproj b/Threaded.xcodeproj/project.pbxproj index 6c4ccfa..34d65a0 100644 --- a/Threaded.xcodeproj/project.pbxproj +++ b/Threaded.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ B95ED2332B8707D60055F5BD /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = B95ED2322B8707D60055F5BD /* RevenueCat */; }; B95ED2352B8707D60055F5BD /* RevenueCatUI in Frameworks */ = {isa = PBXBuildFile; productRef = B95ED2342B8707D60055F5BD /* RevenueCatUI */; }; B95ED2372B87C9550055F5BD /* StoreKitTestCertificate.cer in Resources */ = {isa = PBXBuildFile; fileRef = B95ED2362B87C9550055F5BD /* StoreKitTestCertificate.cer */; }; + B964F8062B9B78F4005C193D /* PostPoll.swift in Sources */ = {isa = PBXBuildFile; fileRef = B964F8052B9B78F4005C193D /* PostPoll.swift */; }; B97491E32B6E96700098BC48 /* SymbolWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97491E22B6E96700098BC48 /* SymbolWidth.swift */; }; B97798892B853E6600DC869F /* UpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97798882B853E6600DC869F /* UpdateView.swift */; }; B97BCE242B3DD8400044756D /* HapticManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97BCE232B3DD8400044756D /* HapticManager.swift */; }; @@ -195,6 +196,7 @@ B93B67792B42EC51000892E9 /* MetaPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetaPicker.swift; sourceTree = ""; }; B93B677B2B433A6E000892E9 /* PostingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostingView.swift; sourceTree = ""; }; B95ED2362B87C9550055F5BD /* StoreKitTestCertificate.cer */ = {isa = PBXFileReference; lastKnownFileType = file; name = StoreKitTestCertificate.cer; path = ../../../../../Downloads/StoreKitTestCertificate.cer; sourceTree = ""; }; + B964F8052B9B78F4005C193D /* PostPoll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostPoll.swift; sourceTree = ""; }; B97491E22B6E96700098BC48 /* SymbolWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymbolWidth.swift; sourceTree = ""; }; B97798882B853E6600DC869F /* UpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateView.swift; sourceTree = ""; }; B97BCE232B3DD8400044756D /* HapticManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticManager.swift; sourceTree = ""; }; @@ -397,6 +399,7 @@ B9B63B242B44997400BBC82D /* QuotePostView.swift */, B9BED5152B5D5E6500C9B715 /* PostInteractor.swift */, B9BED5172B5D649C00C9B715 /* PostMenu.swift */, + B964F8052B9B78F4005C193D /* PostPoll.swift */, B98F47952B645DF40092000F /* EmojiSelector.swift */, ); path = Post; @@ -875,6 +878,7 @@ B9FB94BC2B2F035500D81C07 /* Tag.swift in Sources */, B9029FC42B8125CE00AA9B68 /* HuggingFace.swift in Sources */, B9BED51A2B5D662D00C9B715 /* ShareSheetController.swift in Sources */, + B964F8062B9B78F4005C193D /* PostPoll.swift in Sources */, B93757112B7FB8D400652F91 /* AltClients.swift in Sources */, B9BED5162B5D5E6500C9B715 /* PostInteractor.swift in Sources */, B98BC74B2B46CF0400595441 /* ThreadedStyle.swift in Sources */, @@ -954,7 +958,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = "ThreadedWatch Watch App.entitlements"; + CODE_SIGN_ENTITLEMENTS = "ThreadedWatch/ThreadedWatch Watch App.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"ThreadedWatch/Preview Content\""; @@ -984,7 +988,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = "ThreadedWatch Watch App.entitlements"; + CODE_SIGN_ENTITLEMENTS = "ThreadedWatch/ThreadedWatch Watch App.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"ThreadedWatch/Preview Content\""; diff --git a/Threaded/Components/Post/CompactPostView.swift b/Threaded/Components/Post/CompactPostView.swift index 619bd4c..3d49dc1 100644 --- a/Threaded/Components/Post/CompactPostView.swift +++ b/Threaded/Components/Post/CompactPostView.swift @@ -127,6 +127,10 @@ struct CompactPostView: View { } } + if status.poll != nil { + PostPoll(poll: status.poll!) + } + if status.card != nil && status.mediaAttachments.isEmpty && !hasQuote { PostCardView(card: status.card!) } @@ -270,3 +274,11 @@ struct CompactPostView: View { } } } + +#Preview { + CompactPostView(status: Status.placeholder(forSettings: true, language: "fr")) + .environment(AccountManager()) + .environment(UniversalNavigator()) + .environmentObject(UserPreferences.defaultPreferences) + .environmentObject(Navigator()) +} diff --git a/Threaded/Components/Post/PostPoll.swift b/Threaded/Components/Post/PostPoll.swift new file mode 100644 index 0000000..b64fc95 --- /dev/null +++ b/Threaded/Components/Post/PostPoll.swift @@ -0,0 +1,205 @@ +//Made by Lumaa + +import SwiftUI + +struct PostPoll: View { + @Environment(AccountManager.self) private var accountManager: AccountManager + + @State var poll: Poll + @State private var selectedOption: [Int] = [] + + @State private var submitted: Bool = false + @State private var showResults: Bool = false + + init(poll: Poll) { + self.poll = poll + } + + var body: some View { + VStack(alignment: .leading) { + ForEach(poll.options) { option in + let index = poll.options.firstIndex(where: { $0.id == option.id }) + let isMostVoted: Bool = self.isMostVoted(option: option) + + let clamped: Double = Double(option.votesCount ?? 0) / Double(poll.safeVotersCount) + + Button { + if !submitted && !poll.expired { + withAnimation(.spring(duration: 0.25)) { + selectVote(index ?? 0) + + if !poll.multiple { + Task { + await vote() + } + } + } + } + } label: { + ZStack { + RoundedRectangle(cornerRadius: 15) + .stroke(Color.gray.opacity(0.4), lineWidth: 2.5) + .frame(width: 300, height: 50) + .overlay { + HStack { + Text(option.title) + .font(.subheadline) + .padding(.leading, 25) + .foregroundStyle(Color(uiColor: UIColor.label)) + + if selectedOption.contains(index ?? 0) { + Image(systemName: "checkmark.circle.fill") + .font(.subheadline) + .foregroundStyle(Color(uiColor: UIColor.label)) + } + + Spacer() + + if showResults { + Text(String("\(Int(clamped * 100))%")) + .font(.subheadline) + .padding(.horizontal, 25) + .foregroundStyle(Color(uiColor: UIColor.label)) + } + } + } + + RoundedRectangle(cornerRadius: 15) + .fill(Color(uiColor: UIColor.label)) + .frame(width: 300, height: 50) + .overlay { + HStack { + Text(option.title) + .foregroundStyle(Color(uiColor: UIColor.systemBackground)) + .font(.subheadline) + .padding(.leading, 25) + + if selectedOption.contains(index ?? 0) { + Image(systemName: "checkmark.circle.fill") + .font(.subheadline) + .foregroundStyle(Color(uiColor: UIColor.systemBackground)) + } + + Spacer() + + if showResults { + Text(String("\(Int(clamped * 100))%")) + .font(.subheadline) + .padding(.horizontal, 25) + .foregroundStyle(Color(uiColor: UIColor.systemBackground)) + } + } + } + .mask(alignment: .leading) { + RoundedRectangle(cornerRadius: 15) + .frame(width: showResults ? CGFloat(clamped * 300) : 0, height: 50) + } + } + .opacity((poll.expired || submitted) && !isMostVoted ? 0.5 : 1.0) + } + .buttonStyle(NoTapAnimationStyle()) + } + + if !poll.expired && !submitted { + HStack { + if poll.multiple { + Button { + Task { + await vote() + } + } label: { + Text("status.poll.submit") + } + .buttonStyle(LargeButton(filled: true, height: 7.5, disabled: selectedOption.count == 0)) + .disabled(selectedOption.count == 0) + } + + Button { + withAnimation(.spring) { + showResults.toggle() + } + } label: { + Text(showResults ? LocalizedStringKey("status.poll.hide-results") : LocalizedStringKey("status.poll.show-results")) + } + .buttonStyle(LargeButton(filled: false, height: 7.5)) + } + } + + HStack { + if !poll.expired { + Text("status.poll.expires-in.\(poll.expiresAt.value?.relativeFormatted ?? "unknown")") + .contentTransition(.numericText()) + .font(.caption) + .multilineTextAlignment(.leading) + .foregroundStyle(Color.gray) + } else { + Text("status.poll.expired") + .font(.caption) + .multilineTextAlignment(.leading) + .foregroundStyle(Color.gray) + } + + Spacer() + + Text("status.poll.voters.\(poll.safeVotersCount)") + .font(.caption) + .multilineTextAlignment(.trailing) + .foregroundStyle(Color.gray) + } + .frame(width: 300) + } + .task { + await getPoll() + } + } + + private func isMostVoted(option: Poll.Option) -> Bool { + let sortedOptions: [Poll.Option] = poll.options.sorted(by: { $0.votesCount ?? 0 > $1.votesCount ?? 0 }) + let checkEquality: [Poll.Option] = sortedOptions.filter({ ($0.votesCount ?? 0) == (sortedOptions[0].votesCount ?? 0) }) + let isMostVoted: Bool = checkEquality.contains(where: { $0.id == option.id }) + return isMostVoted + } + + private func getPoll() async { + guard let client = accountManager.getClient() else { return } + if let p: Poll = try? await client.get(endpoint: Polls.poll(id: poll.id)) { + poll = p + selectedOption = p.ownVotes ?? [] + submitted = p.voted ?? true + showResults = submitted || p.expired + } + } + + private func selectVote(_ index: Int) { + guard !poll.expired else { return } + + if poll.multiple { + let remove: Bool = selectedOption.contains(index) + if remove { + selectedOption.removeAll(where: { $0 == index }) + } else { + selectedOption.append(index) + } + } else { + selectedOption = [index] + } + } + + private func vote() async { + guard let client = accountManager.getClient(), !poll.expired else { return } + + _ = try? await client.post(endpoint: Polls.vote(id: poll.id, votes: selectedOption)) + withAnimation(.spring) { + showResults = true + submitted = true + } + } +} + +#Preview { + CompactPostView(status: Status.placeholder(forSettings: true, language: "fr")) + .environment(AccountManager()) + .environment(UniversalNavigator()) + .environmentObject(UserPreferences.defaultPreferences) + .environmentObject(Navigator()) +} diff --git a/Threaded/Data/Content/Status.swift b/Threaded/Data/Content/Status.swift index 3c85015..dd9ed64 100644 --- a/Threaded/Data/Content/Status.swift +++ b/Threaded/Data/Content/Status.swift @@ -71,7 +71,7 @@ public final class Status: AnyStatus, Codable, Identifiable, Equatable, Hashable public static func placeholder(forSettings: Bool = false, language: String? = nil) -> Status { .init(id: UUID().uuidString, - content: .init(stringValue: "Here's to the [#crazy](#) ones", + content: .init(stringValue: "Have you ever tried [#Threaded](#)?", parseMarkdown: forSettings), account: .placeholder(), @@ -94,7 +94,7 @@ public final class Status: AnyStatus, Codable, Identifiable, Equatable, Hashable inReplyToId: nil, inReplyToAccountId: nil, visibility: .pub, - poll: nil, + poll: Poll.placeholder, spoilerText: .init(stringValue: ""), filtered: [], sensitive: false, @@ -412,11 +412,20 @@ public struct Poll: Codable, Equatable, Hashable { public var safeVotersCount: Int { votersCount ?? votesCount } + + static let placeholder: Poll = Poll(id: "ABC", expiresAt: NullableString(), expired: false, multiple: true, votesCount: 3, votersCount: 3, voted: false, ownVotes: nil, options: [ + Poll.Option.init(id: "ABC", title: "Option 1", votesCount: 1), + Poll.Option.init(id: "DEF", title: "Option 2", votesCount: 2) + ]) } public struct NullableString: Codable, Equatable, Hashable { public let value: ServerDate? + init() { + self.value = ServerDate() + } + public init(from decoder: Decoder) throws { do { let container = try decoder.singleValueContainer() diff --git a/Threaded/Data/MastodonRequest.swift b/Threaded/Data/MastodonRequest.swift index 5156e8a..f0ac917 100644 --- a/Threaded/Data/MastodonRequest.swift +++ b/Threaded/Data/MastodonRequest.swift @@ -680,3 +680,31 @@ public enum Conversations: Endpoint { } } } + +public enum Polls: Endpoint { + case poll(id: String) + case vote(id: String, votes: [Int]) + + public func path() -> String { + switch self { + case let .poll(id): + "polls/\(id)" + case let .vote(id, _): + "polls/\(id)/votes" + } + } + + public func queryItems() -> [URLQueryItem]? { + switch self { + case let .vote(_, votes): + var params: [URLQueryItem] = [] + for vote in votes { + params.append(.init(name: "choices[]", value: "\(vote)")) + } + return params + + default: + return nil + } + } +} diff --git a/Threaded/Localizable.xcstrings b/Threaded/Localizable.xcstrings index b9fb269..7e45999 100644 --- a/Threaded/Localizable.xcstrings +++ b/Threaded/Localizable.xcstrings @@ -2335,6 +2335,84 @@ } } }, + "status.poll.expired" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expired" + } + } + } + }, + "status.poll.expires-in.%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expires %@" + } + } + } + }, + "status.poll.hide-results" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hide Results" + } + } + } + }, + "status.poll.show-results" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Results" + } + } + } + }, + "status.poll.submit" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Submit" + } + } + } + }, + "status.poll.voters.%lld" : { + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld vote" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld votes" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "No votes" + } + } + } + } + } + } + }, "status.posting" : { "localizations" : { "en" : { diff --git a/Threaded/Views/PostDetailsView.swift b/Threaded/Views/PostDetailsView.swift index 4e16c11..5f6acba 100644 --- a/Threaded/Views/PostDetailsView.swift +++ b/Threaded/Views/PostDetailsView.swift @@ -90,6 +90,10 @@ struct PostDetailsView: View { .id("\(detailedStatus.id)@\(detailedStatus.account.id)") } + if status.poll != nil { + PostPoll(poll: status.poll!) + } + if status.card != nil && status.mediaAttachments.isEmpty { PostCardView(card: status.card!) }