Compare commits

...

22 Commits

Author SHA1 Message Date
Shadowfacts 9ab95dfc43
Bump build number and update changelog 2021-05-06 22:28:34 -04:00
Shadowfacts c34ce758dd
Fix Home sidebar item getting deselected immediately on load 2021-05-06 22:02:27 -04:00
Shadowfacts 2c9f00d19f
Fix compose poll durations not being set/persisted 2021-05-06 21:52:16 -04:00
Shadowfacts f7127b84d8
Show vote percentages on completed polls 2021-05-06 21:41:41 -04:00
Shadowfacts fdb21cd1fb
Add Refresh Poll option 2021-05-05 17:51:11 -04:00
Shadowfacts 9f0c1eece8
Add haptic feedback to poll voting 2021-05-05 17:46:41 -04:00
Shadowfacts e18a09f4ac
Don't show Voted button for polls that you authored 2021-05-03 23:18:15 -04:00
Shadowfacts 005001b081
Add authoring polls
Closes #48
2021-05-03 23:12:59 -04:00
Shadowfacts 90f17693f1
Fix compose autocomplete suggestions not displaying
c737354ed3 was overzealous
2021-05-01 19:18:00 -04:00
Shadowfacts 698b045f86
Add poll finished notifications 2021-04-28 21:47:38 -04:00
Shadowfacts 654f84363a
Fix polls displaying incorrectly in dark mode 2021-04-28 20:52:57 -04:00
Shadowfacts 4dd510f3af
Only attach profile context menu interaction to correct views in statuses 2021-04-28 19:11:41 -04:00
Shadowfacts 1c36dfcc5f
Add displaying and voting on polls in statuses 2021-04-28 19:00:17 -04:00
Shadowfacts b0bd27db31
Fix crash when tapping non-HTTP(S) links with In-App Safari enabled 2021-04-25 12:58:51 -04:00
Shadowfacts daa1a9eef7
Fix potential crash when collapsing w/o selected sidebar item 2021-04-25 12:39:45 -04:00
Shadowfacts c737354ed3 Fix cursor movement not working in compose text fields when emoji added
Removes workaround introduced in 8c4ef3caa6. This is no longer necessary
and autocorrect works fine without it since at least iOS 14.4.

Closes #118
2021-04-05 18:31:03 -04:00
Shadowfacts 8ea15d3bab Add preference for requiring confirmation before reblogging 2021-04-05 18:31:00 -04:00
Shadowfacts 13a4221fce Add own-instance API request retrying 2021-04-04 15:11:29 -04:00
Shadowfacts a896573a5e Show assets immediately after granting permissions 2021-04-04 15:04:32 -04:00
Shadowfacts edd89450aa Fail gracefully when fetching statuses in timeline controller 2021-04-04 14:43:51 -04:00
Shadowfacts 5f5ef8fcea Fix potential crash when large image loading fails 2021-04-04 14:05:00 -04:00
Shadowfacts a3b59c990b Fix compile issue on Xcode 12.4 2021-04-04 14:04:44 -04:00
45 changed files with 1525 additions and 165 deletions

View File

@ -1,5 +1,23 @@
# Changelog
## 2021.1 (18)
Polls! They're finally here. There will likely be another build in the next several weeks to polish some things off before WWDC, so if you've encountered any issues, now's the time to let me know :)
Features/Improvements:
- Show polls on posts
- Add authoring polls to Compose screen
- Add poll completed notifications
- Add preference for requiring confirmation before reblogging
Bugfixes:
- Fix cursor movement not working in Compse text field when an emoji was entered
- Fix several crashes related to network requests failing
- Show assets in attachment picker immediately after permissions is initially granted
- Fix crash when tapping non-HTTP(S) link with the In-App Safari preference enabled
Known Issues:
- Polls with between 23h30m and 24h left show as "0 weeks remaining"
## 2021.1 (17)
The main improvement this build is a complete overhaul of the Conversation screen, along with fixes for a few different crashes.

View File

@ -298,7 +298,10 @@ public class Client {
sensitive: Bool? = nil,
spoilerText: String? = nil,
visibility: Status.Visibility? = nil,
language: String? = nil) -> Request<Status> {
language: String? = nil,
pollOptions: [String]? = nil,
pollExpiresIn: Int? = nil,
pollMultiple: Bool? = nil) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
"status" => text,
"content_type" => contentType.mimeType,
@ -306,8 +309,10 @@ public class Client {
"sensitive" => sensitive,
"spoiler_text" => spoilerText,
"visibility" => visibility?.rawValue,
"language" => language
] + "media_ids" => media?.map { $0.id }))
"language" => language,
"poll[expires_in]" => pollExpiresIn,
"poll[multiple]" => pollMultiple,
] + "media_ids" => media?.map { $0.id } + "poll[options]" => pollOptions))
}
// MARK: - Timelines

View File

@ -55,6 +55,7 @@ extension Notification {
case favourite
case follow
case followRequest = "follow_request"
case poll
case unknown
}
}

View File

@ -0,0 +1,55 @@
//
// Poll.swift
// Pachyderm
//
// Created by Shadowfacts on 4/25/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import Foundation
public final class Poll: Codable {
public let id: String
public let expiresAt: Date?
public let expired: Bool
public let multiple: Bool
public let votesCount: Int
public let votersCount: Int?
public let voted: Bool?
public let ownVotes: [Int]?
public let options: [Option]
public let emojis: [Emoji]
public var effectiveExpired: Bool {
expired || (expiresAt != nil && expiresAt! < Date())
}
public static func vote(_ pollID: String, choices: [Int]) -> Request<Poll> {
return Request<Poll>(method: .post, path: "/api/v1/polls/\(pollID)/votes", body: FormDataBody("choices" => choices, nil))
}
private enum CodingKeys: String, CodingKey {
case id
case expiresAt = "expires_at"
case expired
case multiple
case votesCount = "votes_count"
case votersCount = "voters_count"
case voted
case ownVotes = "own_votes"
case options
case emojis
}
}
extension Poll {
public final class Option: Codable {
public let title: String
public let votesCount: Int?
private enum CodingKeys: String, CodingKey {
case title
case votesCount = "votes_count"
}
}
}

View File

@ -37,6 +37,7 @@ public final class Status: /*StatusProtocol,*/ Decodable {
public let pinned: Bool?
public let bookmarked: Bool?
public let card: Card?
public let poll: Poll?
public var applicationName: String? { application?.name }
@ -132,6 +133,7 @@ public final class Status: /*StatusProtocol,*/ Decodable {
case pinned
case bookmarked
case card
case poll
}
}

View File

@ -52,6 +52,10 @@ extension String {
let name = "\(name)[]"
return values.map { Parameter(name: name, value: $0) }
}
static func =>(name: String, values: [Int]) -> [Parameter] {
return name => values.map { $0.description }
}
}
extension Parameter: CustomStringConvertible {

View File

@ -85,6 +85,10 @@
D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A524F1C81800B82A16 /* ComposeReplyView.swift */; };
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */; };
D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A924F1E01C00B82A16 /* ComposeTextView.swift */; };
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A53C2635F5590095BD04 /* StatusPollView.swift */; };
D623A53F2635F6910095BD04 /* Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A53E2635F6910095BD04 /* Poll.swift */; };
D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A5402635FB3C0095BD04 /* PollOptionView.swift */; };
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A542263634100095BD04 /* PollOptionCheckboxView.swift */; };
D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */; };
D626493323BD751600612E6E /* ShowCameraCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D626493123BD751600612E6E /* ShowCameraCollectionViewCell.xib */; };
D626493523BD94CE00612E6E /* CompositionAttachmentData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */; };
@ -154,6 +158,9 @@
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */; };
D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; };
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; };
D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */; };
D662AEF0263A3B880082A153 /* PollFinishedTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */; };
D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D662AEF1263A4BE10082A153 /* ComposePollView.swift */; };
D663625D2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D663625C2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib */; };
D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663625E2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift */; };
D663626221360B1900C9CBA2 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626121360B1900C9CBA2 /* Preferences.swift */; };
@ -213,8 +220,8 @@
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */; };
D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */; };
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6969E9F240C8384002843CE /* EmojiLabel.swift */; };
D6969EA4240DD28D002843CE /* UnknownNotificationTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6969EA2240DD28D002843CE /* UnknownNotificationTableViewCell.xib */; };
D69CCBBF249E6EFD000AF167 /* CrashReporter in Frameworks */ = {isa = PBXBuildFile; productRef = D69CCBBE249E6EFD000AF167 /* CrashReporter */; };
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */; };
D6A3BC7723218E1300FD64D5 /* TimelineSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7323218C6E00FD64D5 /* TimelineSegment.swift */; };
D6A3BC7923218E9200FD64D5 /* NotificationGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7823218E9200FD64D5 /* NotificationGroup.swift */; };
D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7A232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift */; };
@ -269,7 +276,7 @@
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; };
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */; };
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; };
D6C143DA253510F4007DC240 /* ComposeContentWarningTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeContentWarningTextField.swift */; };
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; };
D6C143E025354E34007DC240 /* EmojiPickerCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143DF25354E34007DC240 /* EmojiPickerCollectionViewController.swift */; };
D6C143FD25354FD0007DC240 /* EmojiCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143FB25354FD0007DC240 /* EmojiCollectionViewCell.swift */; };
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; };
@ -454,6 +461,10 @@
D62275A524F1C81800B82A16 /* ComposeReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyView.swift; sourceTree = "<group>"; };
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyContentView.swift; sourceTree = "<group>"; };
D62275A924F1E01C00B82A16 /* ComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTextView.swift; sourceTree = "<group>"; };
D623A53C2635F5590095BD04 /* StatusPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPollView.swift; sourceTree = "<group>"; };
D623A53E2635F6910095BD04 /* Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Poll.swift; sourceTree = "<group>"; };
D623A5402635FB3C0095BD04 /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = "<group>"; };
D623A542263634100095BD04 /* PollOptionCheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionCheckboxView.swift; sourceTree = "<group>"; };
D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableTableViewCell.swift; sourceTree = "<group>"; };
D626493123BD751600612E6E /* ShowCameraCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ShowCameraCollectionViewCell.xib; sourceTree = "<group>"; };
D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachmentData.swift; sourceTree = "<group>"; };
@ -526,6 +537,9 @@
D65F613523AFD65900F3CFD3 /* Ambassador.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Ambassador.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D65F613723AFD65D00F3CFD3 /* Embassy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Embassy.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusStateResolver.swift; sourceTree = "<group>"; };
D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFinishedTableViewCell.swift; sourceTree = "<group>"; };
D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PollFinishedTableViewCell.xib; sourceTree = "<group>"; };
D662AEF1263A4BE10082A153 /* ComposePollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposePollView.swift; sourceTree = "<group>"; };
D663625C2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConversationMainStatusTableViewCell.xib; sourceTree = "<group>"; };
D663625E2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationMainStatusTableViewCell.swift; sourceTree = "<group>"; };
D663626121360B1900C9CBA2 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
@ -585,7 +599,7 @@
D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeSceneDelegate.swift; sourceTree = "<group>"; };
D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSTextAttachment+Emoji.swift"; sourceTree = "<group>"; };
D6969E9F240C8384002843CE /* EmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiLabel.swift; sourceTree = "<group>"; };
D6969EA2240DD28D002843CE /* UnknownNotificationTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = UnknownNotificationTableViewCell.xib; sourceTree = "<group>"; };
D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionsView.swift; sourceTree = "<group>"; };
D6A3BC7323218C6E00FD64D5 /* TimelineSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineSegment.swift; sourceTree = "<group>"; };
D6A3BC7823218E9200FD64D5 /* NotificationGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationGroup.swift; sourceTree = "<group>"; };
D6A3BC7A232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionNotificationGroupTableViewCell.swift; sourceTree = "<group>"; };
@ -636,7 +650,7 @@
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = "<group>"; };
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = "<group>"; };
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; };
D6C143D9253510F4007DC240 /* ComposeContentWarningTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeContentWarningTextField.swift; sourceTree = "<group>"; };
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = "<group>"; };
D6C143DF25354E34007DC240 /* EmojiPickerCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerCollectionViewController.swift; sourceTree = "<group>"; };
D6C143FB25354FD0007DC240 /* EmojiCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCollectionViewCell.swift; sourceTree = "<group>"; };
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; };
@ -837,6 +851,7 @@
D6109A062145756700432DC2 /* LoginSettings.swift */,
D61099F22145688600432DC2 /* Mention.swift */,
D61099F4214568C300432DC2 /* Notification.swift */,
D623A53E2635F6910095BD04 /* Poll.swift */,
D61099F62145693500432DC2 /* PushSubscription.swift */,
D6109A022145722C00432DC2 /* RegisteredApplication.swift */,
D61099F82145698900432DC2 /* Relationship.swift */,
@ -892,6 +907,17 @@
path = "Instance Cell";
sourceTree = "<group>";
};
D623A53B2635F4E20095BD04 /* Poll */ = {
isa = PBXGroup;
children = (
D623A53C2635F5590095BD04 /* StatusPollView.swift */,
D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */,
D623A5402635FB3C0095BD04 /* PollOptionView.swift */,
D623A542263634100095BD04 /* PollOptionCheckboxView.swift */,
);
path = Poll;
sourceTree = "<group>";
};
D626494023C122C800612E6E /* Asset Picker */ = {
isa = PBXGroup;
children = (
@ -1087,13 +1113,14 @@
D6A57407255C53EC00674551 /* ComposeTextViewCaretScrolling.swift */,
D62275A924F1E01C00B82A16 /* ComposeTextView.swift */,
D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */,
D662AEF1263A4BE10082A153 /* ComposePollView.swift */,
D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */,
D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */,
D622757724EE133700B82A16 /* ComposeAssetPicker.swift */,
D62275A524F1C81800B82A16 /* ComposeReplyView.swift */,
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */,
D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */,
D6C143D9253510F4007DC240 /* ComposeContentWarningTextField.swift */,
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */,
D6C143DF25354E34007DC240 /* EmojiPickerCollectionViewController.swift */,
D6C143FB25354FD0007DC240 /* EmojiCollectionViewCell.swift */,
D670F8B52537DC890046588A /* EmojiPickerWrapper.swift */,
@ -1163,7 +1190,8 @@
D6A3BC7F2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib */,
D64BC18D23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift */,
D64BC18E23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib */,
D6969EA2240DD28D002843CE /* UnknownNotificationTableViewCell.xib */,
D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */,
D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */,
);
path = Notifications;
sourceTree = "<group>";
@ -1390,6 +1418,7 @@
D61959D0241E842400A37B8E /* Draft Cell */,
D641C78A213DD926004B4513 /* Status */,
D6C7D27B22B6EBE200071952 /* Attachments */,
D623A53B2635F4E20095BD04 /* Poll */,
D641C78B213DD92F004B4513 /* Profile Header */,
D641C78C213DD937004B4513 /* Notifications */,
D6A3BC872321F78000FD64D5 /* Account Cell */,
@ -1769,7 +1798,6 @@
D6D4DDD7212518A200E1C4BB /* Assets.xcassets in Resources */,
D663625D2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib in Resources */,
D640D76922BAF5E6004FBE69 /* DomainBlocks.plist in Resources */,
D6969EA4240DD28D002843CE /* UnknownNotificationTableViewCell.xib in Resources */,
D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */,
D6093FB125BE0B01004811E6 /* TrendingHashtagTableViewCell.xib in Resources */,
D627FF79217E950100CC0648 /* DraftsTableViewController.xib in Resources */,
@ -1780,6 +1808,7 @@
D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */,
D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */,
D6F2E966249E8BFD005846BB /* CrashReporterViewController.xib in Resources */,
D662AEF0263A3B880082A153 /* PollFinishedTableViewCell.xib in Resources */,
D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -1870,6 +1899,7 @@
D61099F5214568C300432DC2 /* Notification.swift in Sources */,
D61099EF214566C000432DC2 /* Instance.swift in Sources */,
D61099D22144B2E600432DC2 /* Body.swift in Sources */,
D623A53F2635F6910095BD04 /* Poll.swift in Sources */,
D63569E023908A8D003DD353 /* StatusState.swift in Sources */,
D6109A0121456B0800432DC2 /* Hashtag.swift in Sources */,
D61099FD21456A1D00432DC2 /* SearchResults.swift in Sources */,
@ -1896,6 +1926,7 @@
D600613E25D07E170067FAD6 /* ProfileDirectoryFilterView.swift in Sources */,
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */,
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */,
D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */,
D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */,
D60E2F292442372B005F8713 /* AccountMO.swift in Sources */,
@ -1952,6 +1983,7 @@
D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */,
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */,
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */,
D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */,
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */,
D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */,
D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */,
@ -1982,7 +2014,9 @@
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */,
D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */,
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */,
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */,
D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */,
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */,
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
@ -1993,6 +2027,7 @@
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */,
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */,
D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */,
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */,
D6093FB025BE0B01004811E6 /* TrendingHashtagTableViewCell.swift in Sources */,
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */,
D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */,
@ -2010,7 +2045,7 @@
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */,
D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */,
D6C143DA253510F4007DC240 /* ComposeContentWarningTextField.swift in Sources */,
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */,
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */,
D627943923A553B600D38C68 /* UnbookmarkStatusActivity.swift in Sources */,
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */,
@ -2105,6 +2140,7 @@
D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */,
D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */,
D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */,
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */,
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */,
);
@ -2409,7 +2445,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2438,7 +2474,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;

View File

@ -46,6 +46,9 @@ class MastodonController: ObservableObject {
@Published private(set) var instance: Instance!
private(set) var customEmojis: [Emoji]?
private var pendingOwnInstanceRequestCallbacks = [(Instance) -> Void]()
private var ownInstanceRequest: URLSessionTask?
var loggedIn: Bool {
accountInfo != nil
}
@ -115,17 +118,56 @@ class MastodonController: ObservableObject {
}
}
// todo: this should dedup requests
func getOwnInstance(completion: ((Instance) -> Void)? = nil) {
getOwnInstanceInternal(retryAttempt: 0, completion: completion)
}
private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Instance) -> Void)?) {
// this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks
assert(Thread.isMainThread)
if let instance = self.instance {
completion?(instance)
} else {
if let completion = completion {
pendingOwnInstanceRequestCallbacks.append(completion)
}
if ownInstanceRequest == nil {
let request = Client.getInstance()
run(request) { (response) in
guard case let .success(instance, _) = response else { fatalError() }
ownInstanceRequest = run(request) { (response) in
switch response {
case .failure(_):
let delay: DispatchTimeInterval
switch retryAttempt {
case 0:
delay = .seconds(1)
case 1:
delay = .seconds(5)
case 2:
delay = .seconds(30)
case 3:
delay = .seconds(60)
default:
// if we've failed four times, just give up :/
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
// completion is nil because in this invocation of getOwnInstanceInternal we've already added it to the pending callbacks array
self.getOwnInstanceInternal(retryAttempt: retryAttempt + 1, completion: nil)
}
case let .success(instance, _):
DispatchQueue.main.async {
self.ownInstanceRequest = nil
self.instance = instance
completion?(instance)
for completion in self.pendingOwnInstanceRequestCallbacks {
completion(instance)
}
self.pendingOwnInstanceRequestCallbacks = []
}
}
}
}
}

View File

@ -65,15 +65,15 @@ class MastodonCachePersistentStore: NSPersistentContainer {
}
@discardableResult
private func upsert(status: Status, incrementReferenceCount: Bool) -> StatusMO {
if let statusMO = self.status(for: status.id, in: self.backgroundContext) {
private func upsert(status: Status, incrementReferenceCount: Bool, context: NSManagedObjectContext) -> StatusMO {
if let statusMO = self.status(for: status.id, in: context) {
statusMO.updateFrom(apiStatus: status, container: self)
if incrementReferenceCount {
statusMO.incrementReferenceCount()
}
return statusMO
} else {
let statusMO = StatusMO(apiStatus: status, container: self, context: self.backgroundContext)
let statusMO = StatusMO(apiStatus: status, container: self, context: context)
if incrementReferenceCount {
statusMO.incrementReferenceCount()
}
@ -81,11 +81,12 @@ class MastodonCachePersistentStore: NSPersistentContainer {
}
}
func addOrUpdate(status: Status, incrementReferenceCount: Bool, completion: ((StatusMO) -> Void)? = nil) {
backgroundContext.perform {
let statusMO = self.upsert(status: status, incrementReferenceCount: incrementReferenceCount)
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
func addOrUpdate(status: Status, incrementReferenceCount: Bool, context: NSManagedObjectContext? = nil, completion: ((StatusMO) -> Void)? = nil) {
let context = context ?? backgroundContext
context.perform {
let statusMO = self.upsert(status: status, incrementReferenceCount: incrementReferenceCount, context: context)
if context.hasChanges {
try! context.save()
}
completion?(statusMO)
self.statusSubject.send(status.id)
@ -94,7 +95,7 @@ class MastodonCachePersistentStore: NSPersistentContainer {
func addAll(statuses: [Status], completion: (() -> Void)? = nil) {
backgroundContext.perform {
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true) }
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true, context: self.backgroundContext) }
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
@ -194,7 +195,7 @@ class MastodonCachePersistentStore: NSPersistentContainer {
// filter out mentions, otherwise we would double increment the reference count of those accounts
// since the status has the same account as the notification
let accounts = notifications.filter { $0.kind != .mention }.map { $0.account }
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true) }
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true, context: self.backgroundContext) }
accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) }
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
@ -214,7 +215,7 @@ class MastodonCachePersistentStore: NSPersistentContainer {
accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) }
updatedAccounts.append(contentsOf: accounts.map { $0.id })
}, { (statuses) in
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true) }
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true, context: self.backgroundContext) }
updatedStatuses.append(contentsOf: statuses.map { $0.id })
})

View File

@ -42,6 +42,7 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
@NSManaged public var uri: String // todo: are both uri and url necessary?
@NSManaged public var url: URL?
@NSManaged private var visibilityString: String
@NSManaged private var pollData: Data?
@NSManaged public var account: AccountMO
@NSManaged public var reblog: StatusMO?
@ -60,6 +61,9 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
@LazilyDecoding(from: \StatusMO.cardData, fallback: nil)
public var card: Card?
@LazilyDecoding(from: \StatusMO.pollData, fallback: nil)
public var poll: Poll?
public var pinned: Bool? { pinnedInternal }
public var bookmarked: Bool? { bookmarkedInternal }
@ -129,6 +133,8 @@ extension StatusMO {
self.uri = status.uri
self.url = status.url
self.visibility = status.visibility
self.poll = status.poll
if let existing = container.account(for: status.account.id, in: context) {
existing.updateFrom(apiAccount: status.account, container: container)
self.account = existing

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17510.1" systemVersion="19G2021" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="18154" systemVersion="20D91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="AccountMO" syncable="YES">
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" attributeType="URI"/>
@ -57,6 +57,7 @@
<attribute name="mentionsData" attributeType="Binary"/>
<attribute name="muted" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="pinnedInternal" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="pollData" optional="YES" attributeType="Binary"/>
<attribute name="reblogged" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="reblogsCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="referenceCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
@ -74,8 +75,8 @@
</uniquenessConstraints>
</entity>
<elements>
<element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="343"/>
<element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="329"/>
<element name="Relationship" positionX="63" positionY="135" width="128" height="208"/>
<element name="Status" positionX="-63" positionY="-18" width="128" height="433"/>
<element name="Status" positionX="-63" positionY="-18" width="128" height="434"/>
</elements>
</model>

View File

@ -33,6 +33,11 @@
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.social-networking</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>gopher</string>
<string>gemini</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>

View File

@ -20,13 +20,15 @@ class Draft: Codable, ObservableObject {
@Published var attachments: [CompositionAttachment]
@Published var inReplyToID: String?
@Published var visibility: Status.Visibility
@Published var poll: Poll?
var initialText: String
var hasContent: Bool {
(!text.isEmpty && text != initialText) ||
(contentWarningEnabled && !contentWarning.isEmpty) ||
attachments.count > 0
attachments.count > 0 ||
poll?.hasContent == true
}
var textForPosting: String {
@ -46,6 +48,7 @@ class Draft: Codable, ObservableObject {
self.attachments = []
self.inReplyToID = nil
self.visibility = Preferences.shared.defaultPostVisibility
self.poll = nil
self.initialText = ""
}
@ -75,6 +78,7 @@ class Draft: Codable, ObservableObject {
self.attachments = try container.decode([CompositionAttachment].self, forKey: .attachments)
self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID)
self.visibility = try container.decode(Status.Visibility.self, forKey: .visibility)
self.poll = try container.decode(Poll.self, forKey: .poll)
self.initialText = try container.decode(String.self, forKey: .initialText)
}
@ -92,6 +96,7 @@ class Draft: Codable, ObservableObject {
try container.encode(attachments, forKey: .attachments)
try container.encode(inReplyToID, forKey: .inReplyToID)
try container.encode(visibility, forKey: .visibility)
try container.encode(poll, forKey: .poll)
try container.encode(initialText, forKey: .initialText)
}
@ -115,11 +120,68 @@ extension Draft {
case attachments
case inReplyToID
case visibility
case poll
case initialText
}
}
extension Draft {
class Poll: Codable, ObservableObject {
@Published var options: [Option]
@Published var multiple: Bool
@Published var duration: TimeInterval
var hasContent: Bool {
options.contains { !$0.text.isEmpty }
}
init() {
self.options = [Option(""), Option("")]
self.multiple = false
self.duration = 24 * 60 * 60 // 1 day
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.options = try container.decode([Option].self, forKey: .options)
self.multiple = try container.decode(Bool.self, forKey: .multiple)
self.duration = try container.decode(TimeInterval.self, forKey: .duration)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(options, forKey: .options)
try container.encode(multiple, forKey: .multiple)
try container.encode(duration, forKey: .duration)
}
private enum CodingKeys: String, CodingKey {
case options
case multiple
case duration
}
class Option: Identifiable, Codable, ObservableObject {
let id = UUID()
@Published var text: String
init(_ text: String) {
self.text = text
}
required init(from decoder: Decoder) throws {
self.text = try decoder.singleValueContainer().decode(String.self)
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(text)
}
}
}
}
extension MastodonController {
func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil) -> Draft {

View File

@ -58,6 +58,7 @@ class Preferences: Codable, ObservableObject {
self.expandAllContentWarnings = try container.decodeIfPresent(Bool.self, forKey: .expandAllContentWarnings) ?? false
self.collapseLongPosts = try container.decodeIfPresent(Bool.self, forKey: .collapseLongPosts) ?? true
self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? []
self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false
self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
@ -91,6 +92,7 @@ class Preferences: Codable, ObservableObject {
try container.encode(expandAllContentWarnings, forKey: .expandAllContentWarnings)
try container.encode(collapseLongPosts, forKey: .collapseLongPosts)
try container.encode(oppositeCollapseKeywords, forKey: .oppositeCollapseKeywords)
try container.encode(confirmBeforeReblog, forKey: .confirmBeforeReblog)
try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts)
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
@ -125,6 +127,7 @@ class Preferences: Codable, ObservableObject {
@Published var expandAllContentWarnings = false
@Published var collapseLongPosts = true
@Published var oppositeCollapseKeywords: [String] = []
@Published var confirmBeforeReblog = false
// MARK: Digital Wellness
@Published var showFavoriteAndReblogCounts = true
@ -157,6 +160,7 @@ class Preferences: Codable, ObservableObject {
case expandAllContentWarnings
case collapseLongPosts
case oppositeCollapseKeywords
case confirmBeforeReblog
case showFavoriteAndReblogCounts
case defaultNotificationsType

View File

@ -69,6 +69,7 @@ class AssetCollectionViewController: UICollectionViewController {
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(donePressed))
collectionView.alwaysBounceVertical = true
collectionView.allowsMultipleSelection = true
collectionView.register(UINib(nibName: "AssetCollectionViewCell", bundle: .main), forCellWithReuseIdentifier: reuseIdentifier)
collectionView.register(UINib(nibName: "ShowCameraCollectionViewCell", bundle: .main), forCellWithReuseIdentifier: cameraReuseIdentifier)
@ -97,19 +98,6 @@ class AssetCollectionViewController: UICollectionViewController {
}
})
let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
fetchResult = fetchAssets(with: options)
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.assets])
var items: [Item] = [.showCamera]
fetchResult.enumerateObjects { (asset, _, _) in
items.append(.asset(asset))
}
snapshot.appendItems(items)
dataSource.apply(snapshot, animatingDifferences: false)
collectionView.allowsMultipleSelection = true
setEditing(true, animated: false)
updateItemsSelectedCount()
@ -122,6 +110,12 @@ class AssetCollectionViewController: UICollectionViewController {
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
loadAssets()
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
@ -137,6 +131,40 @@ class AssetCollectionViewController: UICollectionViewController {
}
}
private func loadAssets() {
switch PHPhotoLibrary.authorizationStatus(for: .readWrite) {
case .notDetermined:
PHPhotoLibrary.requestAuthorization(for: .readWrite) { (_) in
self.loadAssets()
}
return
case .restricted, .denied:
// todo: better UI for this
return
case .authorized, .limited:
// todo: show "add more" button for limited access
break
@unknown default:
// who knows, just try anyways
break
}
let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
fetchResult = fetchAssets(with: options)
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.assets])
var items: [Item] = [.showCamera]
fetchResult.enumerateObjects { (asset, _, _) in
items.append(.asset(asset))
}
snapshot.appendItems(items)
dataSource.apply(snapshot, animatingDifferences: false)
}
open func fetchAssets(with options: PHFetchOptions) -> PHFetchResult<PHAsset> {
return PHAsset.fetchAssets(with: options)
}

View File

@ -60,6 +60,14 @@ struct ComposeAttachmentsList: View {
.foregroundColor(.blue)
.frame(height: cellHeight / 2)
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
Button(action: self.togglePoll) {
Label(draft.poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal")
}
.disabled(!canAddPoll)
.foregroundColor(.blue)
.frame(height: cellHeight / 2)
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
}
.frame(height: totalListHeight)
.onAppear(perform: self.didAppear)
@ -84,14 +92,25 @@ struct ComposeAttachmentsList: View {
case .pleroma:
return true
case .mastodon:
return draft.attachments.count < 4 && draft.attachments.allSatisfy { $0.data.type == .image }
return draft.attachments.count < 4 && draft.attachments.allSatisfy { $0.data.type == .image } && draft.poll == nil
}
}
private var canAddPoll: Bool {
switch mastodonController.instance?.instanceType {
case nil:
return false
case .pleroma:
return true
case .mastodon:
return draft.attachments.isEmpty
}
}
private var totalListHeight: CGFloat {
let totalRowHeights = rowHeights.values.reduce(0, +)
let totalPadding = CGFloat(draft.attachments.count) * cellPadding
let addButtonHeight = cellHeight + cellPadding * 2
let addButtonHeight = 3 * (cellHeight / 2 + cellPadding)
return totalRowHeights + totalPadding + addButtonHeight
}
@ -155,6 +174,14 @@ struct ComposeAttachmentsList: View {
uiState.composeDrawingMode = .createNew
uiState.delegate?.presentComposeDrawing()
}
private func togglePoll() {
UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil)
withAnimation {
draft.poll = draft.poll == nil ? Draft.Poll() : nil
}
}
}
//struct ComposeAttachmentsList_Previews: PreviewProvider {

View File

@ -8,22 +8,50 @@
import SwiftUI
struct ComposeContentWarningTextField: UIViewRepresentable {
struct ComposeEmojiTextField: UIViewRepresentable {
typealias UIViewType = UITextField
@Binding var text: String
@EnvironmentObject private var uiState: ComposeUIState
@Binding private var text: String
private let placeholder: String
private var didChange: ((String) -> Void)?
private var didEndEditing: (() -> Void)?
private var backgroundColor: UIColor? = nil
init(text: Binding<String>, placeholder: String) {
self._text = text
self.placeholder = placeholder
self.didChange = nil
self.didEndEditing = nil
}
mutating func didChange(_ didChange: @escaping (String) -> Void) -> Self {
self.didChange = didChange
return self
}
mutating func didEndEditing(_ didEndEditing: @escaping () -> Void) -> Self {
self.didEndEditing = didEndEditing
return self
}
mutating func backgroundColor(_ color: UIColor) -> Self {
self.backgroundColor = color
return self
}
func makeUIView(context: Context) -> UITextField {
let view = UITextField()
view.placeholder = "Write your warning here"
view.placeholder = placeholder
view.borderStyle = .roundedRect
view.delegate = context.coordinator
view.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged)
view.backgroundColor = backgroundColor
context.coordinator.textField = view
context.coordinator.uiState = uiState
context.coordinator.text = $text
@ -32,8 +60,14 @@ struct ComposeContentWarningTextField: UIViewRepresentable {
}
func updateUIView(_ uiView: UITextField, context: Context) {
if context.coordinator.skipSettingTextOnNextUpdate {
context.coordinator.skipSettingTextOnNextUpdate = false
} else {
uiView.text = text
}
context.coordinator.didChange = didChange
context.coordinator.didEndEditing = didEndEditing
}
func makeCoordinator() -> Coordinator {
return Coordinator()
@ -43,9 +77,14 @@ struct ComposeContentWarningTextField: UIViewRepresentable {
weak var textField: UITextField?
var text: Binding<String>!
var uiState: ComposeUIState!
var didChange: ((String) -> Void)?
var didEndEditing: (() -> Void)?
var skipSettingTextOnNextUpdate = false
@objc func didChange(_ textField: UITextField) {
text.wrappedValue = textField.text ?? ""
didChange?(text.wrappedValue)
}
func textFieldDidBeginEditing(_ textField: UITextField) {
@ -55,14 +94,13 @@ struct ComposeContentWarningTextField: UIViewRepresentable {
func textFieldDidEndEditing(_ textField: UITextField) {
updateAutocompleteState(textField: textField)
didEndEditing?()
}
func textFieldDidChangeSelection(_ textField: UITextField) {
// Update text binding before potentially triggering SwiftUI view update.
// See comment in MainComposeTextView.Coordinator.textViewDidChangeSelection
text.wrappedValue = textField.text ?? ""
updateAutocompleteState(textField: textField)
// see MainComposeTextView.Coordinator.textViewDidChangeSelection(_:)
skipSettingTextOnNextUpdate = true
self.updateAutocompleteState(textField: textField)
}
func autocomplete(with string: String) {

View File

@ -0,0 +1,208 @@
//
// ComposePollView.swift
// Tusker
//
// Created by Shadowfacts on 4/28/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import SwiftUI
struct ComposePollView: View {
private static let formatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()
f.maximumUnitCount = 1
f.unitsStyle = .full
f.allowedUnits = [.weekOfMonth, .day, .hour, .minute]
return f
}()
@ObservedObject var draft: Draft
@ObservedObject var poll: Draft.Poll
@Environment(\.colorScheme) var colorScheme: ColorScheme
@State private var duration: Duration
init(draft: Draft, poll: Draft.Poll) {
self.draft = draft
self.poll = poll
self._duration = State(initialValue: .fromTimeInterval(poll.duration) ?? .oneDay)
}
var body: some View {
VStack {
HStack {
Text("Poll")
.font(.headline)
Spacer()
Button(action: self.removePoll) {
Image(systemName: "xmark")
.imageScale(.small)
.padding(4)
}
.accentColor(buttonForegroundColor)
.background(Circle().foregroundColor(buttonBackgroundColor))
}
ForEach(Array(poll.options.enumerated()), id: \.element.id) { (e) in
ComposePollOption(poll: poll, option: e.element, optionIndex: e.offset)
}
.transition(.slide)
Button(action: self.addOption) {
Label("Add Option", systemImage: "plus")
}
HStack {
// use .animation(nil) on the binding and .frame(maxWidth: .infinity) on labels so frame doesn't have a size change animation when the text changes
Picker(selection: $poll.multiple.animation(nil), label: Text(poll.multiple ? "Allow multiple choices" : "Single choice").frame(maxWidth: .infinity)) {
Text("Allow multiple choices").tag(true)
Text("Single choice").tag(false)
}
.pickerStyle(MenuPickerStyle())
.frame(maxWidth: .infinity)
Picker(selection: $duration.animation(nil), label: Text(verbatim: ComposePollView.formatter.string(from: duration.timeInterval)!).frame(maxWidth: .infinity)) {
ForEach(Duration.allCases, id: \.self) { (duration) in
Text(ComposePollView.formatter.string(from: duration.timeInterval)!).tag(duration)
}
}
.pickerStyle(MenuPickerStyle())
.frame(maxWidth: .infinity)
}
}
.padding(8)
.background(
backgroundColor
.cornerRadius(10)
)
.onChange(of: duration, perform: { (value) in
poll.duration = value.timeInterval
})
}
private var backgroundColor: Color {
// in light mode, .secondarySystemBackground has a blue-ish hue, which we don't want
colorScheme == .dark ? Color(UIColor.secondarySystemBackground) : Color(white: 0.95)
}
private var buttonBackgroundColor: Color {
Color(white: colorScheme == .dark ? 0.1 : 0.8)
}
private var buttonForegroundColor: Color {
Color(UIColor.label)
}
private func removePoll() {
withAnimation {
self.draft.poll = nil
}
}
private func addOption() {
withAnimation(.easeInOut(duration: 0.25)) {
poll.options.append(Draft.Poll.Option(""))
}
}
}
extension ComposePollView {
enum Duration: Hashable, Equatable, CaseIterable {
case fiveMinutes, thirtyMinutes, oneHour, sixHours, oneDay, threeDays, sevenDays
static func fromTimeInterval(_ ti: TimeInterval) -> Duration? {
for it in allCases where it.timeInterval == ti {
return it
}
return nil
}
var timeInterval: TimeInterval {
switch self {
case .fiveMinutes:
return 5 * 60
case .thirtyMinutes:
return 30 * 60
case .oneHour:
return 60 * 60
case .sixHours:
return 6 * 60 * 60
case .oneDay:
return 24 * 60 * 60
case .threeDays:
return 3 * 24 * 60 * 60
case .sevenDays:
return 7 * 24 * 60 * 60
}
}
}
}
struct ComposePollOption: View {
@ObservedObject var poll: Draft.Poll
@ObservedObject var option: Draft.Poll.Option
let optionIndex: Int
var body: some View {
HStack(spacing: 4) {
Checkbox(radiusFraction: poll.multiple ? 0.1 : 0.5, borderWidth: 2)
.animation(.default)
textField
Button(action: self.removeOption) {
Image(systemName: "minus.circle.fill")
}
.foregroundColor(poll.options.count == 1 ? .gray : .red)
.disabled(poll.options.count == 1)
}
}
private var textField: some View {
var field = ComposeEmojiTextField(text: $option.text, placeholder: "Option \(optionIndex + 1)")
return field.backgroundColor(.systemBackground)
}
private func removeOption() {
_ = withAnimation {
poll.options.remove(at: optionIndex)
}
}
struct Checkbox: View {
private let radiusFraction: CGFloat
private let size: CGFloat = 20
private let innerSize: CGFloat
init(radiusFraction: CGFloat, borderWidth: CGFloat) {
self.radiusFraction = radiusFraction
self.innerSize = self.size - 2 * borderWidth
}
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.gray)
.frame(width: size, height: size)
.cornerRadius(radiusFraction * size)
Rectangle()
.foregroundColor(Color(UIColor.systemBackground))
.frame(width: innerSize, height: innerSize)
.cornerRadius(radiusFraction * innerSize)
}
}
}
}
//struct ComposePollView_Previews: PreviewProvider {
// static var previews: some View {
// ComposePollView()
// }
//}

View File

@ -40,7 +40,7 @@ struct ComposeView: View {
}
var postButtonEnabled: Bool {
draft.hasContent && charactersRemaining >= 0 && !isPosting && !requiresAttachmentDescriptions
draft.hasContent && charactersRemaining >= 0 && !isPosting && !requiresAttachmentDescriptions && (draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty })
}
var body: some View {
@ -101,7 +101,7 @@ struct ComposeView: View {
header
if draft.contentWarningEnabled {
ComposeContentWarningTextField(text: $draft.contentWarning)
ComposeEmojiTextField(text: $draft.contentWarning, placeholder: "Write your warning here")
}
MainComposeTextView(
@ -109,6 +109,13 @@ struct ComposeView: View {
placeholder: Text("What's on your mind?")
)
if let poll = draft.poll {
ComposePollView(draft: draft, poll: poll)
.transition(.opacity.combined(with: .asymmetric(insertion: .scale(scale: 0.5, anchor: .leading), removal: .scale(scale: 0.5, anchor: .trailing))))
.animation(.default)
}
ComposeAttachmentsList(
draft: draft
)
@ -213,7 +220,10 @@ struct ComposeView: View {
sensitive: sensitive,
spoilerText: contentWarning,
visibility: draft.visibility,
language: nil)
language: nil,
pollOptions: draft.poll?.options.map(\.text),
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
pollMultiple: draft.poll?.multiple)
self.mastodonController.run(request) { (response) in
switch response {
case let .failure(error):

View File

@ -135,7 +135,11 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
}
func updateUIView(_ uiView: UITextView, context: Context) {
if context.coordinator.skipSettingTextOnNextUpdate {
context.coordinator.skipSettingTextOnNextUpdate = false
} else {
uiView.text = text
}
if let visibilityButton = visibilityButton {
visibilityButton.image = UIImage(systemName: visibility.imageName)
@ -199,6 +203,8 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
var uiState: ComposeUIState
var caretScrollPositionAnimator: UIViewPropertyAnimator?
var skipSettingTextOnNextUpdate = false
init(text: Binding<String>, uiState: ComposeUIState, didChange: @escaping (UITextView) -> Void) {
self.text = text
self.didChange = didChange
@ -259,18 +265,11 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
}
func textViewDidChangeSelection(_ textView: UITextView) {
// Update the value of the text binding.
// Sometimes, when the user accepts an autocomplete suggestion from the system keyboard, the system
// calls didChangeSelection before textDidChange, resulting in a loop where the updating the Tusker autocomplete
// state in didChangeSection (via updateAutocompleteState) triggers a new SwiftUI view update,
// but when that SwiftUI update is handled, the model still has the old text (from prior to accepting the autocomplete
// suggestion), meaning the UITextView's text gets set back to whatever it was prior to the system autocomplete.
// To work around that, we also update the text binding in didChangeSelection, to ensure that, if the autocomplete state
// does change and trigger a SwiftUI update, the binding will have the correct text that was produced by the system
// autocompletion.
text.wrappedValue = textView.text ?? ""
updateAutocompleteState()
// Setting the text view's text causes it to move the cursor to the end (though only
// when the text contains an emoji :/), so skip setting the text on the next SwiftUI update
// that's triggered by setting the autocomplete state.
skipSettingTextOnNextUpdate = true
self.updateAutocompleteState()
}
func autocomplete(with string: String) {

View File

@ -118,7 +118,7 @@ class ProfileDirectoryFilterView: UICollectionReusableView {
}
@objc private func filterChanged() {
let scope = Scope(rawValue: scope.selectedSegmentIndex)!
let scope = Scope(rawValue: self.scope.selectedSegmentIndex)!
let order = sort.selectedSegmentIndex == 0 ? DirectoryOrder.active : .new
onFilterChanged?(scope, order)
}

View File

@ -94,11 +94,11 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
loadingVC = LoadingViewController()
embedChild(loadingVC!)
imageRequest = cache.get(url, loadOriginal: true) { [weak self] (data, image) in
guard let self = self else { return }
guard let self = self, let image = image else { return }
self.imageRequest = nil
DispatchQueue.main.async {
self.loadingVC?.removeViewAndController()
self.createLargeImage(data: data!, image: image!, url: self.url)
self.createLargeImage(data: data, image: image, url: self.url)
}
}
}

View File

@ -168,7 +168,13 @@ class MainSidebarViewController: UIViewController {
.profileDirectory,
], toSection: .discover)
}
let prevSelected = collectionView.indexPathsForSelectedItems
dataSource.apply(snapshot, animatingDifferences: false)
if let prevSelected = prevSelected?.first {
collectionView.selectItem(at: prevSelected, animated: false, scrollPosition: .top)
}
}
private func reloadLists() {

View File

@ -164,7 +164,10 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
}
// Switch the tab bar to focus the same item as the sidebar has selected
switch sidebar.selectedItem! {
switch sidebar.selectedItem {
case nil:
break
case let .tab(tab):
// sidebar items that map 1 <-> 1 can be transferred directly
tabBarViewController.select(tab: tab)

View File

@ -15,6 +15,7 @@ class NotificationsTableViewController: TimelineLikeTableViewController<Notifica
private let actionGroupCell = "actionGroupCell"
private let followGroupCell = "followGroupCell"
private let followRequestCell = "followRequestCell"
private let pollCell = "pollCell"
private let unknownCell = "unknownCell"
weak var mastodonController: MastodonController!
@ -49,6 +50,7 @@ class NotificationsTableViewController: TimelineLikeTableViewController<Notifica
tableView.register(UINib(nibName: "ActionNotificationGroupTableViewCell", bundle: .main), forCellReuseIdentifier: actionGroupCell)
tableView.register(UINib(nibName: "FollowNotificationGroupTableViewCell", bundle: .main), forCellReuseIdentifier: followGroupCell)
tableView.register(UINib(nibName: "FollowRequestNotificationTableViewCell", bundle: .main), forCellReuseIdentifier: followRequestCell)
tableView.register(UINib(nibName: "PollFinishedTableViewCell", bundle: .main), forCellReuseIdentifier: pollCell)
tableView.register(UINib(nibName: "BasicTableViewCell", bundle: .main), forCellReuseIdentifier: unknownCell)
}
@ -160,6 +162,13 @@ class NotificationsTableViewController: TimelineLikeTableViewController<Notifica
cell.updateUI(notification: notification)
return cell
case .poll:
guard let notification = group.notifications.first,
let cell = tableView.dequeueReusableCell(withIdentifier: pollCell, for: indexPath) as? PollFinishedTableViewCell else { fatalError() }
cell.delegate = self
cell.updateUI(notification: notification)
return cell
case .unknown:
let cell = tableView.dequeueReusableCell(withIdentifier: unknownCell, for: indexPath)
cell.textLabel!.text = NSLocalizedString("Unknown Notification", comment: "unknown notification fallback cell text")

View File

@ -13,6 +13,7 @@ struct BehaviorPrefsView: View {
var body: some View {
List {
untitledSection
linksSection
contentWarningsSection
}
@ -20,7 +21,15 @@ struct BehaviorPrefsView: View {
.navigationBarTitle(Text("Behavior"))
}
var linksSection: some View {
private var untitledSection: some View {
Section {
Toggle(isOn: $preferences.confirmBeforeReblog) {
Text("Require Confirmation Before Reblogging")
}
}
}
private var linksSection: some View {
Section(header: Text("Links")) {
Toggle(isOn: $preferences.openLinksInApps) {
Text("Open Links in Apps")
@ -34,7 +43,7 @@ struct BehaviorPrefsView: View {
}
}
var contentWarningsSection: some View {
private var contentWarningsSection: some View {
Section(header: Text("Content Warnings")) {
Toggle(isOn: $preferences.collapseLongPosts) {
Text("Collapse Long Posts")

View File

@ -72,7 +72,10 @@ class TimelineTableViewController: TimelineLikeTableViewController<TimelineEntry
let request = Client.getStatuses(timeline: timeline)
mastodonController?.run(request) { (response) in
guard case let .success(statuses, pagination) = response else { fatalError() }
guard case let .success(statuses, pagination) = response else {
completion([])
return
}
self.newer = pagination?.newer
self.older = pagination?.older
@ -92,7 +95,10 @@ class TimelineTableViewController: TimelineLikeTableViewController<TimelineEntry
let request = Client.getStatuses(timeline: timeline, range: older)
mastodonController.run(request) { (response) in
guard case let .success(statuses, pagination) = response else { fatalError() }
guard case let .success(statuses, pagination) = response else {
completion([])
return
}
self.older = pagination?.older
@ -111,7 +117,10 @@ class TimelineTableViewController: TimelineLikeTableViewController<TimelineEntry
let request = Client.getStatuses(timeline: timeline, range: newer)
mastodonController.run(request) { (response) in
guard case let .success(statuses, pagination) = response else { fatalError() }
guard case let .success(statuses, pagination) = response else {
completion([])
return
}
// if there are no new statuses, pagination is nil
// if we were to then overwrite self.newer, future refreshes would fail

View File

@ -176,7 +176,7 @@ extension MenuPreviewProvider {
if mastodonController.account != nil && mastodonController.account.id == status.account.id {
let pinned = status.pinned ?? false
actionsSection.append(createAction(identifier: "", title: pinned ? "Unpin" : "Pin", systemImageName: pinned ? "pin.slash" : "pin", handler: { [weak self] (_) in
actionsSection.append(createAction(identifier: "pin", title: pinned ? "Unpin" : "Pin", systemImageName: pinned ? "pin.slash" : "pin", handler: { [weak self] (_) in
guard let self = self else { return }
let request = (pinned ? Status.unpin : Status.pin)(status.id)
self.mastodonController?.run(request, completion: { [weak self] (response) in
@ -188,6 +188,20 @@ extension MenuPreviewProvider {
}))
}
if status.poll != nil {
actionsSection.insert(createAction(identifier: "refresh", title: "Refresh Poll", systemImageName: "arrow.clockwise", handler: { [weak self] (_) in
guard let mastodonController = self?.mastodonController else { return }
let request = Client.getStatus(id: status.id)
mastodonController.run(request, completion: { (response) in
if case let .success(status, _) = response {
// todo: this shouldn't really use the viewContext, but for some reason saving the
// backgroundContext with the new version of the status isn't updating the viewContext
mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false, context: mastodonController.persistentContainer.viewContext)
}
})
}), at: 0)
}
var shareSection = [
openInSafariAction(url: status.url!),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in

View File

@ -65,11 +65,18 @@ class TimelineLikeTableViewController<Item>: EnhancedTableViewController, Refres
func loadInitial() {
guard !loaded else { return }
// set loaded immediately so we don't trigger another request while the current one is running
loaded = true
loadInitialItems() { (items) in
guard items.count > 0 else { return }
DispatchQueue.main.async {
guard items.count > 0 else {
// set loaded back to false so the next time the VC appears, we try to load again
// todo: this should probably retry automatically
self.loaded = false
return
}
if self.sections.count < self.headerSectionsCount() {
self.sections.insert(contentsOf: Array(repeating: [], count: self.headerSectionsCount() - self.sections.count), at: 0)
}
@ -97,6 +104,8 @@ class TimelineLikeTableViewController<Item>: EnhancedTableViewController, Refres
return "Refresh"
}
// todo: these three should use Result<[Item], Client.Error> so we can differentiate between failed requests and there actually being no results
func loadInitialItems(completion: @escaping ([Item]) -> Void) {
fatalError("loadInitialItems(completion:) must be implemented by subclasses")
}

View File

@ -46,16 +46,26 @@ extension TuskerNavigationDelegate {
func selected(url: URL, allowUniversalLinks: Bool = true) {
func openSafari() {
if Preferences.shared.useInAppSafari {
if Preferences.shared.useInAppSafari,
url.scheme == "https" || url.scheme == "http" {
let config = SFSafariViewController.Configuration()
config.entersReaderIfAvailable = Preferences.shared.inAppSafariAutomaticReaderMode
present(SFSafariViewController(url: url, configuration: config), animated: true)
} else {
} else if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:])
} else {
var message = "The URL could not be opened."
if let scheme = url.scheme {
message += " This can happen if you do not have an app installed for '\(scheme)://' URLs."
}
let alert = UIAlertController(title: "Invalid URL", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
present(alert, animated: true)
}
}
if allowUniversalLinks && Preferences.shared.openLinksInApps {
if allowUniversalLinks && Preferences.shared.openLinksInApps,
url.scheme == "https" || url.scheme == "http" {
UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { (success) in
if (!success) {
openSafari()

View File

@ -1,9 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17506" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17504.1"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>

View File

@ -1,9 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17154" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17124"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>

View File

@ -0,0 +1,108 @@
//
// PollFinishedTableViewCell.swift
// Tusker
//
// Created by Shadowfacts on 4/28/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import SwiftSoup
class PollFinishedTableViewCell: UITableViewCell {
weak var delegate: TuskerNavigationDelegate?
var mastodonController: MastodonController? { delegate?.apiController }
@IBOutlet weak var displayNameLabel: EmojiLabel!
@IBOutlet weak var timestampLabel: UILabel!
@IBOutlet weak var statusContentLabel: UILabel!
@IBOutlet weak var pollView: StatusPollView!
var notification: Pachyderm.Notification?
private var updateTimestampWorkItem: DispatchWorkItem?
func updateUI(notification: Pachyderm.Notification) {
guard let statusID = notification.status?.id,
let status = delegate?.apiController.persistentContainer.status(for: statusID),
let poll = status.poll else {
return
}
self.notification = notification
updateTimestamp()
displayNameLabel.text = notification.account.displayName
displayNameLabel.setEmojis(notification.account.emojis, identifier: notification.account.id)
let doc = try! SwiftSoup.parse(status.content)
statusContentLabel.text = try! doc.text()
pollView.updateUI(status: status, poll: poll)
}
private func updateTimestamp() {
guard let notification = notification else { return }
timestampLabel.text = notification.createdAt.timeAgoString()
let delay: DispatchTimeInterval?
switch notification.createdAt.timeAgo().1 {
case .second:
delay = .seconds(10)
case .minute:
delay = .seconds(60)
default:
delay = nil
}
if let delay = delay {
if updateTimestampWorkItem == nil {
updateTimestampWorkItem = DispatchWorkItem { [weak self] in
self?.updateTimestamp()
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: updateTimestampWorkItem!)
}
} else {
updateTimestampWorkItem = nil
}
}
override func prepareForReuse() {
super.prepareForReuse()
updateTimestampWorkItem?.cancel()
updateTimestampWorkItem = nil
}
}
extension PollFinishedTableViewCell: SelectableTableViewCell {
func didSelectCell() {
guard let delegate = delegate,
let status = notification?.status else {
return
}
let vc = delegate.conversation(mainStatusID: status.id, state: .unknown)
delegate.show(vc)
}
}
extension PollFinishedTableViewCell: MenuPreviewProvider {
var navigationDelegate: TuskerNavigationDelegate? { delegate }
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
guard let delegate = delegate,
let statusID = notification?.status?.id,
let status = delegate.apiController.persistentContainer.status(for: statusID) else {
return nil
}
return (content: {
delegate.conversation(mainStatusID: statusID, state: .unknown)
}, actions: {
self.actionsForStatus(status, sourceView: self)
})
}
}

View File

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" rowHeight="102" id="KGk-i7-Jjw" customClass="PollFinishedTableViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="102"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
<rect key="frame" x="0.0" y="0.0" width="320" height="102"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="eSw-Oo-Scy">
<rect key="frame" x="72" y="11" width="232" height="80"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="69j-GL-yd7">
<rect key="frame" x="0.0" y="0.0" width="232" height="20.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="A poll has finished" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="9He-JX-i6Z">
<rect key="frame" x="0.0" y="0.0" width="208" height="20.5"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="252" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" text="2m" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Bsi-QS-utc">
<rect key="frame" x="208" y="0.0" width="24" height="20.5"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" text="Person" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="zwM-Iw-Hob" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="24.5" width="232" height="20.5"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Content" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="bLL-8K-VWn">
<rect key="frame" x="0.0" y="49" width="232" height="20.5"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ybA-ob-sHe" customClass="StatusPollView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="73.5" width="232" height="6.5"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
</subviews>
</stackView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="checkmark.square.fill" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="cqi-cV-ejs">
<rect key="frame" x="34" y="10" width="30" height="32"/>
<constraints>
<constraint firstAttribute="height" constant="30" id="E9e-iF-rqo"/>
<constraint firstAttribute="width" constant="30" id="Efu-VP-pjH"/>
</constraints>
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
</imageView>
</subviews>
<constraints>
<constraint firstItem="cqi-cV-ejs" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="34" id="8hN-WG-IsT"/>
<constraint firstAttribute="bottomMargin" secondItem="eSw-Oo-Scy" secondAttribute="bottom" id="9Hx-wD-Rfx"/>
<constraint firstItem="eSw-Oo-Scy" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="topMargin" id="CxC-Ch-JAx"/>
<constraint firstAttribute="trailingMargin" secondItem="eSw-Oo-Scy" secondAttribute="trailing" id="OPc-Wi-cHD"/>
<constraint firstItem="cqi-cV-ejs" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="topMargin" id="jQZ-cM-UuM"/>
<constraint firstItem="eSw-Oo-Scy" firstAttribute="leading" secondItem="cqi-cV-ejs" secondAttribute="trailing" constant="8" id="wMo-VG-1DK"/>
</constraints>
</tableViewCellContentView>
<viewLayoutGuide key="safeArea" id="njF-e1-oar"/>
<connections>
<outlet property="displayNameLabel" destination="zwM-Iw-Hob" id="3VF-5X-B94"/>
<outlet property="pollView" destination="ybA-ob-sHe" id="lpi-94-dvu"/>
<outlet property="statusContentLabel" destination="bLL-8K-VWn" id="GZo-ko-eaD"/>
<outlet property="timestampLabel" destination="Bsi-QS-utc" id="ufI-re-iM2"/>
</connections>
<point key="canvasLocation" x="-62.318840579710148" y="-22.767857142857142"/>
</tableViewCell>
</objects>
<resources>
<image name="checkmark.square.fill" catalog="system" width="128" height="114"/>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>

View File

@ -1,35 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16092.1" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16082.1"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" id="KGk-i7-Jjw">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Unknown Notification" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="BC8-kp-x12">
<rect key="frame" x="16" y="11.5" width="164" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="BC8-kp-x12" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leadingMargin" id="MQf-82-GMw"/>
<constraint firstItem="BC8-kp-x12" firstAttribute="centerY" secondItem="H2p-sc-9uM" secondAttribute="centerY" id="gHv-BQ-CXd"/>
</constraints>
</tableViewCellContentView>
<viewLayoutGuide key="safeArea" id="njF-e1-oar"/>
<point key="canvasLocation" x="132" y="154"/>
</tableViewCell>
</objects>
</document>

View File

@ -0,0 +1,74 @@
//
// PollOptionCheckboxView.swift
// Tusker
//
// Created by Shadowfacts on 4/25/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
class PollOptionCheckboxView: UIView {
var isChecked: Bool = false {
didSet {
updateStyle()
}
}
var readOnly: Bool = true {
didSet {
updateStyle()
}
}
var voted: Bool = false {
didSet {
updateStyle()
}
}
private let imageView: UIImageView
init(multiple: Bool) {
imageView = UIImageView(image: UIImage(systemName: "checkmark")!)
super.init(frame: .zero)
let size: CGFloat = 20
layer.cornerRadius = (multiple ? 0.1 : 0.5) * size
layer.borderWidth = 2
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.isHidden = true
addSubview(imageView)
updateStyle()
NSLayoutConstraint.activate([
widthAnchor.constraint(equalTo: heightAnchor),
widthAnchor.constraint(equalToConstant: size),
imageView.widthAnchor.constraint(equalTo: widthAnchor, constant: -3),
imageView.heightAnchor.constraint(equalTo: heightAnchor, constant: -3),
imageView.centerXAnchor.constraint(equalTo: centerXAnchor),
imageView.centerYAnchor.constraint(equalTo: centerYAnchor),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateStyle() {
imageView.isHidden = !isChecked
if voted || readOnly {
layer.borderColor = UIColor.clear.cgColor
} else if isChecked {
layer.borderColor = tintColor.cgColor
} else {
layer.borderColor = UIColor.gray.cgColor
}
backgroundColor = isChecked && !voted ? tintColor : .clear
imageView.tintColor = voted ? .label : .white
}
}

View File

@ -0,0 +1,91 @@
//
// PollOptionView.swift
// Tusker
//
// Created by Shadowfacts on 4/25/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class PollOptionView: UIView {
private let unselectedBackgroundColor = UIColor(white: 0.5, alpha: 0.25)
let checkbox: PollOptionCheckboxView
init(poll: Poll, option: Poll.Option) {
checkbox = PollOptionCheckboxView(multiple: poll.multiple)
super.init(frame: .zero)
let minHeight: CGFloat = 35
layer.cornerRadius = 0.1 * minHeight
backgroundColor = unselectedBackgroundColor
checkbox.translatesAutoresizingMaskIntoConstraints = false
addSubview(checkbox)
let label = EmojiLabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.numberOfLines = 0
label.text = option.title
label.setEmojis(poll.emojis, identifier: poll.id)
addSubview(label)
let percentLabel = UILabel()
percentLabel.translatesAutoresizingMaskIntoConstraints = false
percentLabel.text = "100%"
percentLabel.isHidden = true
addSubview(percentLabel)
if (poll.voted ?? false) || poll.effectiveExpired,
let optionVotes = option.votesCount {
let frac = poll.votesCount == 0 ? 0 : CGFloat(optionVotes) / CGFloat(poll.votesCount)
percentLabel.isHidden = false
percentLabel.text = String(format: "%.0f%%", frac * 100)
let fillView = UIView()
fillView.translatesAutoresizingMaskIntoConstraints = false
fillView.backgroundColor = tintColor.withAlphaComponent(0.6)
fillView.layer.zPosition = -1
fillView.layer.cornerRadius = layer.cornerRadius
addSubview(fillView)
NSLayoutConstraint.activate([
fillView.leadingAnchor.constraint(equalTo: leadingAnchor),
fillView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: frac),
fillView.topAnchor.constraint(equalTo: topAnchor),
fillView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
let minHeightConstraint = heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
// on the first layout, something is weird and this becomes ambiguous even though it's fine on subsequent layouts
// this keeps autolayout from complaining
minHeightConstraint.priority = .required - 1
NSLayoutConstraint.activate([
minHeightConstraint,
checkbox.centerYAnchor.constraint(equalTo: centerYAnchor),
checkbox.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
label.topAnchor.constraint(equalTo: topAnchor),
label.bottomAnchor.constraint(equalTo: bottomAnchor),
label.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 8),
label.trailingAnchor.constraint(equalTo: percentLabel.leadingAnchor),
percentLabel.topAnchor.constraint(equalTo: topAnchor),
percentLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
percentLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,180 @@
//
// PollOptionsView.swift
// Tusker
//
// Created by Shadowfacts on 4/26/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class PollOptionsView: UIControl {
var checkedOptionIndices: [Int] {
options.enumerated().filter { $0.element.checkbox.isChecked }.map(\.offset)
}
var checkedOptionsChanged: (() -> Void)?
private let stack: UIStackView
private var options: [PollOptionView] = []
private var poll: Poll!
private var animator: UIViewPropertyAnimator!
private var currentSelectedOptionIndex: Int?
private let animationDuration: TimeInterval = 0.1
private let scaledTransform = CGAffineTransform(scaleX: 0.95, y: 0.95)
private let generator = UIImpactFeedbackGenerator(style: .soft)
override var isEnabled: Bool {
didSet {
options.forEach { $0.checkbox.readOnly = !isEnabled }
}
}
override init(frame: CGRect) {
stack = UIStackView()
super.init(frame: frame)
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .vertical
stack.spacing = 4
addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: leadingAnchor),
stack.trailingAnchor.constraint(equalTo: trailingAnchor),
stack.topAnchor.constraint(equalTo: topAnchor),
stack.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func updateUI(poll: Poll) {
self.poll = poll
options.forEach { $0.removeFromSuperview() }
options = poll.options.enumerated().map { (index, opt) in
let optionView = PollOptionView(poll: poll, option: opt)
optionView.checkbox.readOnly = !isEnabled
optionView.checkbox.isChecked = poll.ownVotes?.contains(index) ?? false
optionView.checkbox.voted = poll.voted ?? false
stack.addArrangedSubview(optionView)
return optionView
}
}
private func selectOption(_ option: PollOptionView) {
if poll.multiple {
option.checkbox.isChecked.toggle()
} else {
for opt in options {
if opt === option {
opt.checkbox.isChecked = true
} else {
opt.checkbox.isChecked = false
}
}
}
checkedOptionsChanged?()
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// don't let subviews receive touch events
return self
}
// MARK: - UIControl
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
guard isEnabled else {
return false
}
for (index, view) in options.enumerated() {
if view.point(inside: touch.location(in: view), with: event) {
currentSelectedOptionIndex = index
animator = UIViewPropertyAnimator(duration: animationDuration, curve: .easeInOut) {
view.transform = self.scaledTransform
}
animator.startAnimation()
generator.impactOccurred()
generator.prepare()
return true
}
}
return false
}
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
var newIndex: Int? = nil
for (index, view) in options.enumerated() {
if view.point(inside: touch.location(in: view), with: event) {
newIndex = index
break
}
}
if newIndex != currentSelectedOptionIndex {
currentSelectedOptionIndex = newIndex
UIView.animate(withDuration: animationDuration, delay: 0, options: .curveEaseInOut) {
for (index, view) in self.options.enumerated() {
view.transform = index == newIndex ? self.scaledTransform : .identity
}
}
if newIndex != nil {
generator.impactOccurred()
generator.prepare()
}
}
return true
}
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
super.endTracking(touch, with: event)
func selectOption() {
guard let index = currentSelectedOptionIndex else { return }
let option = options[index]
animator = UIViewPropertyAnimator(duration: animationDuration, curve: .easeInOut) {
option.transform = .identity
self.selectOption(option)
}
animator.startAnimation()
}
if animator.isRunning {
animator.addCompletion { (_) in
selectOption()
}
} else {
selectOption()
}
}
override func cancelTracking(with event: UIEvent?) {
super.cancelTracking(with: event)
UIView.animate(withDuration: animationDuration, delay: 0, options: .curveEaseInOut) {
for view in self.options {
view.transform = .identity
}
}
}
}

View File

@ -0,0 +1,163 @@
//
// StatusPollView.swift
// Tusker
//
// Created by Shadowfacts on 4/25/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class StatusPollView: UIView {
private static let formatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()
f.includesTimeRemainingPhrase = true
f.maximumUnitCount = 1
f.unitsStyle = .full
f.allowedUnits = [.weekOfMonth, .day, .hour, .minute]
return f
}()
weak var mastodonController: MastodonController!
private var statusID: String!
private var poll: Poll!
private var optionsView: PollOptionsView!
private var voteButton: UIButton!
private var infoLabel: UILabel!
private var options: [PollOptionView] = []
private var canVote = true
private var animator: UIViewPropertyAnimator!
private var currentSelectedOptionIndex: Int!
override func awakeFromNib() {
super.awakeFromNib()
backgroundColor = .clear
optionsView = PollOptionsView(frame: .zero)
optionsView.translatesAutoresizingMaskIntoConstraints = false
optionsView.checkedOptionsChanged = self.checkedOptionsChanged
addSubview(optionsView)
infoLabel = UILabel()
infoLabel.translatesAutoresizingMaskIntoConstraints = false
infoLabel.textColor = .secondaryLabel
infoLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .callout), size: 0)
infoLabel.adjustsFontSizeToFitWidth = true
addSubview(infoLabel)
voteButton = UIButton(type: .system)
voteButton.translatesAutoresizingMaskIntoConstraints = false
voteButton.addTarget(self, action: #selector(votePressed), for: .touchUpInside)
voteButton.setTitle("Vote", for: .normal)
voteButton.setTitleColor(.secondaryLabel, for: .disabled)
voteButton.titleLabel!.font = infoLabel.font
addSubview(voteButton)
NSLayoutConstraint.activate([
optionsView.leadingAnchor.constraint(equalTo: leadingAnchor),
optionsView.trailingAnchor.constraint(equalTo: trailingAnchor),
optionsView.topAnchor.constraint(equalTo: topAnchor),
infoLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
infoLabel.topAnchor.constraint(equalTo: optionsView.bottomAnchor),
infoLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
infoLabel.trailingAnchor.constraint(lessThanOrEqualTo: voteButton.leadingAnchor, constant: -8),
voteButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
voteButton.trailingAnchor.constraint(equalTo: trailingAnchor),
voteButton.topAnchor.constraint(equalTo: optionsView.bottomAnchor),
voteButton.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
func updateUI(status: StatusMO, poll: Poll) {
self.statusID = status.id
self.poll = poll
options.forEach { $0.removeFromSuperview() }
// poll.voted is nil if there is no user (e.g., public timeline), in which case the poll also cannot be voted upon
if (poll.voted ?? true) || poll.expired || status.account.id == mastodonController.account.id {
canVote = false
} else {
canVote = true
}
optionsView.isEnabled = canVote
optionsView.updateUI(poll: poll)
var expired = false
let expiryText: String?
if let expiresAt = poll.expiresAt {
if expiresAt > Date() {
expiryText = StatusPollView.formatter.string(from: Date(), to: expiresAt)
} else {
expired = true
expiryText = nil
}
} else {
expiryText = "Does not expire"
}
let format = NSLocalizedString("poll votes count", comment: "poll total votes count")
infoLabel.text = String.localizedStringWithFormat(format, poll.votesCount)
if let expiryText = expiryText {
infoLabel.text! += ", \(expiryText)"
}
if expired {
voteButton.setTitle("Expired", for: .disabled)
} else if poll.voted ?? false {
if status.account.id == mastodonController.account.id {
voteButton.setTitle("", for: .disabled)
} else {
voteButton.setTitle("Voted", for: .disabled)
}
} else if poll.multiple {
voteButton.setTitle("Select multiple", for: .disabled)
} else {
voteButton.setTitle("Select one", for: .disabled)
}
voteButton.isEnabled = false
}
private func checkedOptionsChanged() {
voteButton.isEnabled = optionsView.checkedOptionIndices.count > 0
}
@objc private func votePressed() {
optionsView.isEnabled = false
voteButton.isEnabled = false
voteButton.setTitle("Voted", for: .disabled)
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
let request = Poll.vote(poll.id, choices: optionsView.checkedOptionIndices)
mastodonController.run(request) { (response) in
switch response {
case let .failure(error):
fatalError("error voting in poll: \(error)")
case let .success(poll, _):
let container = self.mastodonController.persistentContainer
DispatchQueue.main.async {
guard let status = container.status(for: self.statusID) else {
return
}
status.poll = poll
if container.viewContext.hasChanges {
try! container.viewContext.save()
}
container.statusSubject.send(status.id)
}
}
}
}
}

View File

@ -15,7 +15,7 @@ protocol StatusTableViewCellDelegate: TuskerNavigationDelegate {
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell)
}
class BaseStatusTableViewCell: UITableViewCell {
class BaseStatusTableViewCell: UITableViewCell, MenuPreviewProvider {
weak var delegate: StatusTableViewCellDelegate? {
didSet {
@ -34,6 +34,7 @@ class BaseStatusTableViewCell: UITableViewCell {
@IBOutlet weak var contentTextView: StatusContentTextView!
@IBOutlet weak var cardView: StatusCardView!
@IBOutlet weak var attachmentsView: AttachmentsContainerView!
@IBOutlet weak var pollView: StatusPollView!
@IBOutlet weak var replyButton: UIButton!
@IBOutlet weak var favoriteButton: UIButton!
@IBOutlet weak var reblogButton: UIButton!
@ -213,6 +214,14 @@ class BaseStatusTableViewCell: UITableViewCell {
// keep menu in sync with changed states e.g. bookmarked, muted
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: actionsForStatus(status, sourceView: moreButton))
if let poll = status.poll {
pollView.isHidden = false
pollView.mastodonController = mastodonController
pollView.updateUI(status: status, poll: poll)
} else {
pollView.isHidden = true
}
}
func updateUI(account: AccountMO) {
@ -330,6 +339,15 @@ class BaseStatusTableViewCell: UITableViewCell {
showStatusAutomatically = false
}
// MARK: - MenuPreviewProvider
var navigationDelegate: TuskerNavigationDelegate? { return delegate }
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
return nil
}
// MARK: - Interaction
@IBAction func collapseButtonPressed() {
setCollapsed(!collapsed, animated: true)
delegate?.statusCellCollapsedStateChanged(self)
@ -403,6 +421,23 @@ class BaseStatusTableViewCell: UITableViewCell {
@IBAction func reblogPressed() {
guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") }
// if we are about to reblog and the user has confirmation enabled
if !reblogged,
Preferences.shared.confirmBeforeReblog {
let alert = UIAlertController(title: "Confirm Reblog", message: "Are you sure you want to reblog this post by @\(status.account.acct)?", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
alert.addAction(UIAlertAction(title: "Reblog", style: .default) { (_) in
self.toggleReblogInternal()
})
delegate?.present(alert, animated: true)
} else {
toggleReblogInternal()
}
}
private func toggleReblogInternal() {
guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") }
let oldValue = reblogged
reblogged = !reblogged
@ -431,10 +466,6 @@ class BaseStatusTableViewCell: UITableViewCell {
@objc func accountPressed() {
delegate?.selected(account: accountID)
}
func getStatusCellPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
return nil
}
}
extension BaseStatusTableViewCell: AttachmentViewDelegate {
@ -509,21 +540,6 @@ extension BaseStatusTableViewCell: AVPlayerViewControllerDelegate {
}
}
extension BaseStatusTableViewCell: MenuPreviewProvider {
var navigationDelegate: TuskerNavigationDelegate? { return delegate }
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
guard let mastodonController = mastodonController else { return nil }
if avatarImageView.frame.contains(location) {
return (
content: { ProfileViewController(accountID: self.accountID, mastodonController: mastodonController) },
actions: { self.actionsForProfile(accountID: self.accountID, sourceView: self.avatarImageView) }
)
}
return self.getStatusCellPreviewProviders(for: location, sourceViewController: sourceViewController)
}
}
extension BaseStatusTableViewCell: UIDragInteractionDelegate {
func dragInteraction(_ interaction: UIDragInteraction, itemsForBeginning session: UIDragSession) -> [UIDragItem] {
guard let currentAccountID = mastodonController.accountInfo?.id,

View File

@ -36,6 +36,8 @@ class ConversationMainStatusTableViewCell: BaseStatusTableViewCell {
accessibilityElements = [profileAccessibilityElement!, contentWarningLabel!, collapseButton!, contentTextView!, totalFavoritesButton!, totalReblogsButton!, timestampAndClientLabel!, replyButton!, favoriteButton!, reblogButton!, moreButton!]
contentTextView.defaultFont = .systemFont(ofSize: 18)
profileDetailContainerView.addInteraction(UIContextMenuInteraction(delegate: self))
}
override func doUpdateUI(status: StatusMO, state: StatusState) {
@ -86,3 +88,13 @@ class ConversationMainStatusTableViewCell: BaseStatusTableViewCell {
}
}
}
extension ConversationMainStatusTableViewCell: UIContextMenuInteractionDelegate {
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(identifier: nil) {
ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController)
} actionProvider: { (_) in
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: self.actionsForProfile(accountID: self.accountID, sourceView: self.avatarImageView))
}
}
}

View File

@ -1,9 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17506" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17504.1"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="Image references" minToolsVersion="12.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
@ -90,26 +89,30 @@
</connections>
</button>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="749" scrollEnabled="NO" delaysContentTouches="NO" editable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="z0g-HN-gS0" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="124.5" width="343" height="47.5"/>
<rect key="frame" x="0.0" y="124.5" width="343" height="0.0"/>
<string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string>
<color key="textColor" systemColor="labelColor"/>
<fontDescription key="fontDescription" type="system" pointSize="20"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="QqC-GR-TLC" customClass="StatusCardView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="176" width="343" height="0.0"/>
<rect key="frame" x="0.0" y="128.5" width="343" height="0.0"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstAttribute="height" priority="999" constant="65" id="Tdo-Hv-ITE"/>
</constraints>
</view>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="IF9-9U-Gk0" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="176" width="343" height="0.0"/>
<rect key="frame" x="0.0" y="128.5" width="343" height="0.0"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
<constraints>
<constraint firstAttribute="height" secondItem="IF9-9U-Gk0" secondAttribute="width" multiplier="9:16" priority="999" id="5oh-eK-J5d"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="TLv-Xu-tT1" customClass="StatusPollView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="132.5" width="343" height="39.5"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ejU-sO-Og5">
<rect key="frame" x="0.0" y="180" width="343" height="0.5"/>
<color key="backgroundColor" systemColor="opaqueSeparatorColor"/>
@ -221,6 +224,7 @@
<constraint firstItem="ejU-sO-Og5" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="biK-oQ-SLy"/>
<constraint firstItem="3Bg-XP-d13" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="iIq-gh-90O"/>
<constraint firstItem="3Fp-Nj-sVj" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="kfI-WN-ouW"/>
<constraint firstItem="TLv-Xu-tT1" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="v87-hd-fd4"/>
</constraints>
</stackView>
</subviews>
@ -244,6 +248,7 @@
<outlet property="favoriteAndReblogCountStackView" destination="HZv-qj-gi6" id="jC9-cA-dXg"/>
<outlet property="favoriteButton" destination="DhN-rJ-jdA" id="b2Q-ch-kSP"/>
<outlet property="moreButton" destination="Ujo-Ap-dmK" id="2ba-5w-HDx"/>
<outlet property="pollView" destination="TLv-Xu-tT1" id="hJX-YD-lNr"/>
<outlet property="profileDetailContainerView" destination="Cnd-Fj-B7l" id="wco-VB-VQx"/>
<outlet property="reblogButton" destination="GUG-f7-Hdy" id="WtT-Ph-DQm"/>
<outlet property="replyButton" destination="2cc-lE-AdG" id="My8-JV-Nho"/>

View File

@ -50,6 +50,8 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
replyButton.imageView!.leadingAnchor.constraint(equalTo: contentTextView.leadingAnchor).isActive = true
contentTextView.defaultFont = .systemFont(ofSize: 16)
avatarImageView.addInteraction(UIContextMenuInteraction(delegate: self))
}
override func createObserversIfNecessary() {
@ -181,7 +183,7 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
reply()
}
override func getStatusCellPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> BaseStatusTableViewCell.PreviewProviders? {
override func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> BaseStatusTableViewCell.PreviewProviders? {
guard let mastodonController = mastodonController,
let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil
@ -303,3 +305,13 @@ extension TimelineStatusTableViewCell: DraggableTableViewCell {
return [UIDragItem(itemProvider: provider)]
}
}
extension TimelineStatusTableViewCell: UIContextMenuInteractionDelegate {
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(identifier: nil) {
ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController)
} actionProvider: { (_) in
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: self.actionsForProfile(accountID: self.accountID, sourceView: self.avatarImageView))
}
}
}

View File

@ -1,9 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17506" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17504.1"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -109,26 +108,30 @@
</connections>
</button>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" delaysContentTouches="NO" editable="NO" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="waJ-f5-LKv" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="83" width="277" height="86.5"/>
<rect key="frame" x="0.0" y="83" width="277" height="82.5"/>
<string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string>
<color key="textColor" systemColor="labelColor"/>
<fontDescription key="fontDescription" type="system" pointSize="16"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="LKo-VB-XWl" customClass="StatusCardView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="169.5" width="277" height="0.0"/>
<rect key="frame" x="0.0" y="167.5" width="277" height="0.0"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstAttribute="height" priority="999" constant="65" id="khY-jm-CPn"/>
</constraints>
</view>
<view hidden="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="nbq-yr-2mA" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="169.5" width="277" height="0.0"/>
<rect key="frame" x="0.0" y="167.5" width="277" height="0.0"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
<constraints>
<constraint firstAttribute="height" secondItem="nbq-yr-2mA" secondAttribute="width" multiplier="9:16" priority="999" id="Rvt-zs-fkd"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="x3b-Zl-9F0" customClass="StatusPollView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="169.5" width="277" height="0.0"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" distribution="equalSpacing" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="oie-wK-IpU">
@ -254,6 +257,7 @@
<outlet property="favoriteButton" destination="x0t-TR-jJ4" id="guV-yz-Lm6"/>
<outlet property="moreButton" destination="982-J4-NGl" id="Pux-tL-aWe"/>
<outlet property="pinImageView" destination="wtt-8G-Ua1" id="mE8-oe-m1l"/>
<outlet property="pollView" destination="x3b-Zl-9F0" id="WIF-Oz-cnm"/>
<outlet property="reblogButton" destination="6tW-z8-Qh9" id="u2t-8D-kOn"/>
<outlet property="reblogLabel" destination="lDH-50-AJZ" id="uJf-Pt-cEP"/>
<outlet property="replyButton" destination="rKF-yF-KIa" id="rka-q1-o4a"/>

View File

@ -2,10 +2,26 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>poll votes count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%2$#@votes@</string>
<key>votes</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>u</string>
<key>one</key>
<string>1 vote</string>
<key>other</key>
<string>%u votes</string>
</dict>
</dict>
<key>trending hashtag info</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@accounts@, %#@posts@ recently</string>
<string>%1$#@accounts@, %2$#@posts@ recently</string>
<key>posts</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>