Compare commits

..

12 Commits

21 changed files with 265 additions and 53 deletions

View File

@ -1,5 +1,19 @@
# Changelog # Changelog
## 2023.4 (70)
Features/Improvements:
- Add GIF/ALT badges to attachments
- Add menu action to hide/show reblogs from specific accounts
- Apply Mastodon's link truncation
- Add preference to hide link preview cards
- Tweak link preview card border color in dark mode
- Unify haptic feedback across the app
- Move Drafts button to the nav bar when the post doesn't have any content, to reduce accidental presses
Bugfixes:
- Fix status URLs with fragments not being resolved
- Workaround for local-only posts not being decodable when logged in to Akkoma instances
## 2023.3 (69) ## 2023.3 (69)
Features/Improvements: Features/Improvements:
- Add Tip Jar under Preferences - Add Tip Jar under Preferences

View File

@ -0,0 +1,7 @@
# Haptic Feedback
## Selection changed
`UISelectionFeedbackGenerator`
## Actions
On success, `UIImpactFeedbackGenerator` with the `.light` style. On error, `UINotificationFeedbackGenerator` with the `.error` type.

View File

@ -62,7 +62,7 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
guard case .idle = state else { guard case .idle = state else {
if animated, if animated,
case .ducked(_, placeholder: let placeholder) = state { case .ducked(_, placeholder: let placeholder) = state {
UIImpactFeedbackGenerator(style: .soft).impactOccurred() UIImpactFeedbackGenerator(style: .light).impactOccurred()
let origConstant = placeholder.topConstraint.constant let origConstant = placeholder.topConstraint.constant
UIView.animateKeyframes(withDuration: 0.4, delay: 0) { UIView.animateKeyframes(withDuration: 0.4, delay: 0) {
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) { UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {

View File

@ -109,6 +109,12 @@ public final class Account: AccountProtocol, Decodable {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/follow") return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/follow")
} }
public static func setShowReblogs(_ accountID: String, showReblogs: Bool) -> Request<Relationship> {
return Request(method: .post, path: "/api/v1/accounts/\(accountID)/follow", body: ParametersBody([
"reblogs" => showReblogs
]))
}
public static func unfollow(_ accountID: String) -> Request<Relationship> { public static func unfollow(_ accountID: String) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unfollow") return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unfollow")
} }

View File

@ -44,6 +44,47 @@ public final class Status: StatusProtocol, Decodable {
public var applicationName: String? { application?.name } public var applicationName: String? { application?.name }
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id)
self.uri = try container.decode(String.self, forKey: .uri)
self.url = try container.decodeIfPresent(WebURL.self, forKey: .url)
self.account = try container.decode(Account.self, forKey: .account)
self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID)
self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID)
self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog)
self.content = try container.decode(String.self, forKey: .content)
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.emojis = try container.decode([Emoji].self, forKey: .emojis)
self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount)
self.favouritesCount = try container.decode(Int.self, forKey: .favouritesCount)
self.reblogged = try container.decodeIfPresent(Bool.self, forKey: .reblogged)
self.favourited = try container.decodeIfPresent(Bool.self, forKey: .favourited)
self.muted = try container.decodeIfPresent(Bool.self, forKey: .muted)
self.sensitive = try container.decode(Bool.self, forKey: .sensitive)
self.spoilerText = try container.decode(String.self, forKey: .spoilerText)
if let visibility = try? container.decode(Status.Visibility.self, forKey: .visibility) {
self.visibility = visibility
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly)
} else if let s = try? container.decode(String.self, forKey: .visibility),
s == "local" {
// hacky workaround for #332, akkoma describes local posts with a separate visibility
self.visibility = .public
self.localOnly = true
} else {
throw DecodingError.dataCorruptedError(forKey: .visibility, in: container, debugDescription: "Could not decode visibility")
}
self.attachments = try container.decode([Attachment].self, forKey: .attachments)
self.mentions = try container.decode([Mention].self, forKey: .mentions)
self.hashtags = try container.decode([Hashtag].self, forKey: .hashtags)
self.application = try container.decodeIfPresent(Application.self, forKey: .application)
self.language = try container.decodeIfPresent(String.self, forKey: .language)
self.pinned = try container.decodeIfPresent(Bool.self, forKey: .pinned)
self.bookmarked = try container.decodeIfPresent(Bool.self, forKey: .bookmarked)
self.card = try container.decodeIfPresent(Card.self, forKey: .card)
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
}
public static func getContext(_ statusID: String) -> Request<ConversationContext> { public static func getContext(_ statusID: String) -> Request<ConversationContext> {
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(statusID)/context") return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(statusID)/context")
} }

View File

@ -2372,7 +2372,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 69; CURRENT_PROJECT_VERSION = 70;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2380,7 +2380,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2023.3; MARKETING_VERSION = 2023.4;
OTHER_CODE_SIGN_FLAGS = ""; OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -2437,7 +2437,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 69; CURRENT_PROJECT_VERSION = 70;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2446,7 +2446,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2023.3; MARKETING_VERSION = 2023.4;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@ -2588,7 +2588,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 69; CURRENT_PROJECT_VERSION = 70;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2596,7 +2596,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2023.3; MARKETING_VERSION = 2023.4;
OTHER_CODE_SIGN_FLAGS = ""; OTHER_CODE_SIGN_FLAGS = "";
OTHER_LDFLAGS = ""; OTHER_LDFLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
@ -2616,7 +2616,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 69; CURRENT_PROJECT_VERSION = 70;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2624,7 +2624,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2023.3; MARKETING_VERSION = 2023.4;
OTHER_CODE_SIGN_FLAGS = ""; OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -2721,7 +2721,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 69; CURRENT_PROJECT_VERSION = 70;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2730,7 +2730,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2023.3; MARKETING_VERSION = 2023.4;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@ -2747,7 +2747,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 69; CURRENT_PROJECT_VERSION = 70;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2756,7 +2756,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2023.3; MARKETING_VERSION = 2023.4;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;

View File

@ -55,7 +55,21 @@ struct HTMLConverter {
case let node as Element: case let node as Element:
let attributed = NSMutableAttributedString(string: "", attributes: [.font: font, .foregroundColor: color]) let attributed = NSMutableAttributedString(string: "", attributes: [.font: font, .foregroundColor: color])
for child in node.getChildNodes() { for child in node.getChildNodes() {
var appendEllipsis = false
if node.tagName() == "a",
let el = child as? Element {
if el.hasClass("invisible") {
continue
} else if el.hasClass("ellipsis") {
appendEllipsis = true
}
}
attributed.append(attributedTextForHTMLNode(child, usePreformattedText: usePreformattedText || node.tagName() == "pre")) attributed.append(attributedTextForHTMLNode(child, usePreformattedText: usePreformattedText || node.tagName() == "pre"))
if appendEllipsis {
attributed.append(NSAttributedString(""))
}
} }
switch node.tagName() { switch node.tagName() {

View File

@ -44,6 +44,7 @@ class Preferences: Codable, ObservableObject {
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon) self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon) self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false
self.showLinkPreviews = try container.decodeIfPresent(Bool.self, forKey: .showLinkPreviews) ?? true
self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
@ -97,6 +98,7 @@ class Preferences: Codable, ObservableObject {
try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon) try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon)
try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon) try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon)
try container.encode(hideActionsInTimeline, forKey: .hideActionsInTimeline) try container.encode(hideActionsInTimeline, forKey: .hideActionsInTimeline)
try container.encode(showLinkPreviews, forKey: .showLinkPreviews)
try container.encode(leadingStatusSwipeActions, forKey: .leadingStatusSwipeActions) try container.encode(leadingStatusSwipeActions, forKey: .leadingStatusSwipeActions)
try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions) try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions)
@ -144,6 +146,7 @@ class Preferences: Codable, ObservableObject {
@Published var showIsStatusReplyIcon = false @Published var showIsStatusReplyIcon = false
@Published var alwaysShowStatusVisibilityIcon = false @Published var alwaysShowStatusVisibilityIcon = false
@Published var hideActionsInTimeline = false @Published var hideActionsInTimeline = false
@Published var showLinkPreviews = true
@Published var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog] @Published var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog]
@Published var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share] @Published var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
@ -205,6 +208,7 @@ class Preferences: Codable, ObservableObject {
case showIsStatusReplyIcon case showIsStatusReplyIcon
case alwaysShowStatusVisibilityIcon case alwaysShowStatusVisibilityIcon
case hideActionsInTimeline case hideActionsInTimeline
case showLinkPreviews
case leadingStatusSwipeActions case leadingStatusSwipeActions
case trailingStatusSwipeActions case trailingStatusSwipeActions

View File

@ -54,6 +54,7 @@ struct ComposeToolbar: View {
.font(.system(size: imageSize)) .font(.system(size: imageSize))
.padding(5) .padding(5)
.hoverEffect() .hoverEffect()
.transition(.opacity.animation(.linear(duration: 0.2)))
} }
if let currentInput = uiState.currentInput, if let currentInput = uiState.currentInput,
@ -74,16 +75,11 @@ struct ComposeToolbar: View {
.accessibilityLabel(format.accessibilityLabel) .accessibilityLabel(format.accessibilityLabel)
.padding(5) .padding(5)
.hoverEffect() .hoverEffect()
.transition(.opacity.animation(.linear(duration: 0.2)))
} }
} }
Spacer() Spacer()
Button(action: self.draftsButtonPressed) {
Text("Drafts")
}
.padding(5)
.hoverEffect()
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.frame(minWidth: minWidth) .frame(minWidth: minWidth)
@ -119,10 +115,6 @@ struct ComposeToolbar: View {
uiState.currentInput?.beginAutocompletingEmoji() uiState.currentInput?.beginAutocompletingEmoji()
} }
private func draftsButtonPressed() {
uiState.isShowingDraftsList = true
}
private func formatAction(_ format: StatusFormat) -> () -> Void { private func formatAction(_ format: StatusFormat) -> () -> Void {
{ {
uiState.currentInput?.applyFormat(format) uiState.currentInput?.applyFormat(format)

View File

@ -239,7 +239,9 @@ struct ComposeView: View {
} }
} }
@ViewBuilder
private var postButton: some View { private var postButton: some View {
if draft.hasContent {
Button { Button {
Task { Task {
await self.postStatus() await self.postStatus()
@ -249,6 +251,13 @@ struct ComposeView: View {
} }
.keyboardShortcut(.return, modifiers: .command) .keyboardShortcut(.return, modifiers: .command)
.disabled(!postButtonEnabled) .disabled(!postButtonEnabled)
} else {
Button {
uiState.isShowingDraftsList = true
} label: {
Text("Drafts")
}
}
} }
private func cancel() { private func cancel() {

View File

@ -98,6 +98,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
if context.coordinator.skipSettingTextOnNextUpdate { if context.coordinator.skipSettingTextOnNextUpdate {
context.coordinator.skipSettingTextOnNextUpdate = false context.coordinator.skipSettingTextOnNextUpdate = false
} else { } else {
context.coordinator.skipNextAutocompleteUpdate = true
uiView.text = text uiView.text = text
} }
@ -185,6 +186,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
var caretScrollPositionAnimator: UIViewPropertyAnimator? var caretScrollPositionAnimator: UIViewPropertyAnimator?
var skipSettingTextOnNextUpdate = false var skipSettingTextOnNextUpdate = false
var skipNextAutocompleteUpdate = false
var toolbarElements: [ComposeUIState.ToolbarElement] { var toolbarElements: [ComposeUIState.ToolbarElement] {
[.emojiPicker, .formattingButtons] [.emojiPicker, .formattingButtons]
@ -324,6 +326,10 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
} }
private func updateAutocompleteState() { private func updateAutocompleteState() {
guard !skipNextAutocompleteUpdate else {
skipNextAutocompleteUpdate = false
return
}
guard let textView = textView, guard let textView = textView,
let text = textView.text, let text = textView.text,
let (lastWordStartIndex, foundFirstAtSign) = findAutocompleteLastWord() else { let (lastWordStartIndex, foundFirstAtSign) = findAutocompleteLastWord() else {

View File

@ -189,11 +189,11 @@ class ConversationViewController: UIViewController {
indicator.startAnimating() indicator.startAnimating()
state = .loading(indicator) state = .loading(indicator)
let url = WebURL(url)! let url = WebURL(url)!.serialized(excludingFragment: true)
let request = Client.search(query: url.serialized(), types: [.statuses], resolve: true) let request = Client.search(query: url, types: [.statuses], resolve: true)
do { do {
let (results, _) = try await mastodonController.run(request) let (results, _) = try await mastodonController.run(request)
guard let status = results.statuses.first(where: { $0.url == url }) else { guard let status = results.statuses.first(where: { $0.url?.serialized() == url }) else {
throw UnableToResolveError() throw UnableToResolveError()
} }
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status) _ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)

View File

@ -24,7 +24,7 @@ class FastAccountSwitcherViewController: UIViewController {
private(set) var accountViews: [FastSwitchingAccountView] = [] private(set) var accountViews: [FastSwitchingAccountView] = []
private var lastSelectedAccountViewIndex: Int? private var lastSelectedAccountViewIndex: Int?
private var selectionChangedFeedbackGenerator: UIImpactFeedbackGenerator? private var selectionChangedFeedbackGenerator: UISelectionFeedbackGenerator?
private var touchBeganFeedbackWorkItem: DispatchWorkItem? private var touchBeganFeedbackWorkItem: DispatchWorkItem?
var itemOrientation: ItemOrientation = .iconsTrailing var itemOrientation: ItemOrientation = .iconsTrailing
@ -148,7 +148,7 @@ class FastAccountSwitcherViewController: UIViewController {
private func switchAccount(newIndex: Int, hapticFeedback: Bool = true) { private func switchAccount(newIndex: Int, hapticFeedback: Bool = true) {
if newIndex == 0 { // add account placeholder if newIndex == 0 { // add account placeholder
if hapticFeedback { if hapticFeedback {
selectionChangedFeedbackGenerator?.impactOccurred() selectionChangedFeedbackGenerator?.selectionChanged()
} }
selectionChangedFeedbackGenerator = nil selectionChangedFeedbackGenerator = nil
@ -160,7 +160,7 @@ class FastAccountSwitcherViewController: UIViewController {
if account.id != LocalData.shared.mostRecentAccountID { if account.id != LocalData.shared.mostRecentAccountID {
if hapticFeedback { if hapticFeedback {
selectionChangedFeedbackGenerator?.impactOccurred() selectionChangedFeedbackGenerator?.selectionChanged()
} }
selectionChangedFeedbackGenerator = nil selectionChangedFeedbackGenerator = nil
@ -178,8 +178,8 @@ class FastAccountSwitcherViewController: UIViewController {
@objc private func handleLongPress(_ recognizer: UIGestureRecognizer) { @objc private func handleLongPress(_ recognizer: UIGestureRecognizer) {
switch recognizer.state { switch recognizer.state {
case .began: case .began:
selectionChangedFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
selectionChangedFeedbackGenerator?.impactOccurred() selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator()
selectionChangedFeedbackGenerator?.prepare() selectionChangedFeedbackGenerator?.prepare()
show() show()
@ -231,7 +231,7 @@ class FastAccountSwitcherViewController: UIViewController {
lastSelectedAccountViewIndex = selectedAccountViewIndex lastSelectedAccountViewIndex = selectedAccountViewIndex
if hapticFeedback { if hapticFeedback {
selectionChangedFeedbackGenerator?.impactOccurred(intensity: 0.5) selectionChangedFeedbackGenerator?.selectionChanged()
selectionChangedFeedbackGenerator?.prepare() selectionChangedFeedbackGenerator?.prepare()
} }
} }
@ -260,7 +260,7 @@ class FastAccountSwitcherViewController: UIViewController {
// if the user is merely tapping, not initiating a pan, we don't want to trigger a double-impact // if the user is merely tapping, not initiating a pan, we don't want to trigger a double-impact
// if the tap ends very quickly, this will be cancelled // if the tap ends very quickly, this will be cancelled
touchBeganFeedbackWorkItem = DispatchWorkItem { touchBeganFeedbackWorkItem = DispatchWorkItem {
self.selectionChangedFeedbackGenerator?.impactOccurred(intensity: 0.5) self.selectionChangedFeedbackGenerator?.selectionChanged()
self.selectionChangedFeedbackGenerator?.prepare() self.selectionChangedFeedbackGenerator?.prepare()
self.touchBeganFeedbackWorkItem = nil self.touchBeganFeedbackWorkItem = nil
} }

View File

@ -84,6 +84,9 @@ struct AppearancePrefsView : View {
Toggle(isOn: $preferences.hideActionsInTimeline) { Toggle(isOn: $preferences.hideActionsInTimeline) {
Text("Hide Actions on Timeline") Text("Hide Actions on Timeline")
} }
Toggle(isOn: $preferences.showLinkPreviews) {
Text("Show Link Previews")
}
NavigationLink("Leading Swipe Actions") { NavigationLink("Leading Swipe Actions") {
SwipeActionsPrefsView(selection: $preferences.leadingStatusSwipeActions) SwipeActionsPrefsView(selection: $preferences.leadingStatusSwipeActions)
.edgesIgnoringSafeArea(.all) .edgesIgnoringSafeArea(.all)

View File

@ -97,8 +97,9 @@ extension MenuActionProvider {
})) }))
elementHandler([UIMenu(title: "Add to List", image: UIImage(systemName: "list.bullet"), children: listActions)]) elementHandler([UIMenu(title: "Add to List", image: UIImage(systemName: "list.bullet"), children: listActions)])
})) }))
suppressSection.append(relationshipAction(fetchRelationship, accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.blockAction(for: $0, mastodonController: $1) })) suppressSection.append(relationshipAction(fetchRelationship, accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.hideReblogsAction(for: $0, mastodonController: $1) }))
suppressSection.append(relationshipAction(fetchRelationship, accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.muteAction(for: $0, mastodonController: $1) })) suppressSection.append(relationshipAction(fetchRelationship, accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.muteAction(for: $0, mastodonController: $1) }))
suppressSection.append(relationshipAction(fetchRelationship, accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.blockAction(for: $0, mastodonController: $1) }))
suppressSection.append(createAction(identifier: "report", title: "Report", systemImageName: "flag", handler: { [unowned self] _ in suppressSection.append(createAction(identifier: "report", title: "Report", systemImageName: "flag", handler: { [unowned self] _ in
let view = ReportView(report: EditedReport(accountID: accountID), mastodonController: mastodonController) let view = ReportView(report: EditedReport(accountID: accountID), mastodonController: mastodonController)
let host = UIHostingController(rootView: view) let host = UIHostingController(rootView: view)
@ -538,6 +539,24 @@ extension MenuActionProvider {
} }
} }
@MainActor
private func hideReblogsAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement {
let title = relationship.showingReblogs ? "Hide Reblogs" : "Show Reblogs"
// todo: need alternate repeat icon to use here
return UIAction(title: title, image: nil) { [weak self] _ in
let req = Account.setShowReblogs(relationship.accountID, showReblogs: !relationship.showingReblogs)
mastodonController.run(req) { response in
switch response {
case .failure(let error):
self?.handleError(error, title: "Error \(relationship.showingReblogs ? "Hiding" : "Showing") Reblogs")
case .success(let relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
self?.handleSuccess(title: relationship.showingReblogs ? "Reblogs Shown" : "Reblogs Hidden")
}
}
}
}
} }
private func fetchRelationship(accountID: String, mastodonController: MastodonController) async -> RelationshipMO? { private func fetchRelationship(accountID: String, mastodonController: MastodonController) async -> RelationshipMO? {

View File

@ -127,6 +127,15 @@ class AttachmentView: GIFImageView {
} }
} }
var badges: Badges = []
if attachment.description?.isEmpty == false {
badges.formUnion(.alt)
}
if attachment.kind == .gifv || attachment.url.pathExtension == "gif" {
badges.formUnion(.gif)
}
createBadgesView(badges)
switch attachment.kind { switch attachment.kind {
case .image: case .image:
loadImage() loadImage()
@ -302,6 +311,64 @@ class AttachmentView: GIFImageView {
} }
} }
private func createBadgesView(_ badges: Badges) {
guard !badges.isEmpty else {
return
}
let stack = UIStackView()
stack.axis = .horizontal
stack.spacing = 2
stack.translatesAutoresizingMaskIntoConstraints = false
let font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 13, weight: .bold))
func makeBadgeView(text: String) {
let container = UIView()
container.backgroundColor = .secondarySystemBackground.resolvedColor(with: UITraitCollection(userInterfaceStyle: .dark))
let label = UILabel()
label.font = font
label.adjustsFontForContentSizeCategory = true
label.textColor = .white
label.text = text
label.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 2),
label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -2),
label.topAnchor.constraint(equalTo: container.topAnchor, constant: 2),
label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -2),
])
stack.addArrangedSubview(container)
}
if badges.contains(.gif) {
makeBadgeView(text: "GIF")
}
if badges.contains(.alt) {
makeBadgeView(text: "ALT")
}
let first = stack.arrangedSubviews.first!
first.layer.masksToBounds = true
first.layer.cornerRadius = 4
if stack.arrangedSubviews.count > 1 {
first.layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
let last = stack.arrangedSubviews.last!
last.layer.masksToBounds = true
last.layer.cornerRadius = 4
last.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
}
addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4),
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
])
}
// MARK: Interaction
func showGallery() { func showGallery() {
if let delegate = delegate, if let delegate = delegate,
let gallery = delegate.attachmentViewGallery(startingAt: index) { let gallery = delegate.attachmentViewGallery(startingAt: index) {
@ -328,6 +395,13 @@ fileprivate extension AttachmentView {
case gifData(URL, Data) case gifData(URL, Data)
case cgImage(URL, CGImage) case cgImage(URL, CGImage)
} }
struct Badges: OptionSet {
static let gif = Badges(rawValue: 1 << 0)
static let alt = Badges(rawValue: 1 << 1)
let rawValue: Int
}
} }
extension AttachmentView: UIContextMenuInteractionDelegate { extension AttachmentView: UIContextMenuInteractionDelegate {

View File

@ -146,7 +146,7 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
do { do {
_ = try await mastodonController.run(request) _ = try await mastodonController.run(request)
UINotificationFeedbackGenerator().notificationOccurred(.success) UIImpactFeedbackGenerator(style: .light).impactOccurred()
self.actionButtonsStackView.isHidden = true self.actionButtonsStackView.isHidden = true
self.addLabel(NSLocalizedString("Rejected", comment: "rejected follow request label")) self.addLabel(NSLocalizedString("Rejected", comment: "rejected follow request label"))
} catch let error as Client.Error { } catch let error as Client.Error {
@ -172,7 +172,7 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
do { do {
_ = try await mastodonController.run(request) _ = try await mastodonController.run(request)
UINotificationFeedbackGenerator().notificationOccurred(.success) UIImpactFeedbackGenerator(style: .light).impactOccurred()
self.actionButtonsStackView.isHidden = true self.actionButtonsStackView.isHidden = true
self.addLabel(NSLocalizedString("Accepted", comment: "accepted follow request label")) self.addLabel(NSLocalizedString("Accepted", comment: "accepted follow request label"))
} catch let error as Client.Error { } catch let error as Client.Error {

View File

@ -26,7 +26,7 @@ class PollOptionsView: UIControl {
private let animationDuration: TimeInterval = 0.1 private let animationDuration: TimeInterval = 0.1
private let scaledTransform = CGAffineTransform(scaleX: 0.95, y: 0.95) private let scaledTransform = CGAffineTransform(scaleX: 0.95, y: 0.95)
private let generator = UIImpactFeedbackGenerator(style: .soft) private let generator = UISelectionFeedbackGenerator()
override var isEnabled: Bool { override var isEnabled: Bool {
didSet { didSet {
@ -110,7 +110,7 @@ class PollOptionsView: UIControl {
} }
animator.startAnimation() animator.startAnimation()
generator.impactOccurred() generator.selectionChanged()
generator.prepare() generator.prepare()
return true return true
@ -139,7 +139,7 @@ class PollOptionsView: UIControl {
} }
if newIndex != nil { if newIndex != nil {
generator.impactOccurred() generator.selectionChanged()
generator.prepare() generator.prepare()
} }
} }

View File

@ -148,7 +148,7 @@ class StatusPollView: UIView {
voteButton.isEnabled = false voteButton.isEnabled = false
voteButton.disabledTitle = "Voted" voteButton.disabledTitle = "Voted"
UIImpactFeedbackGenerator(style: .medium).impactOccurred() UIImpactFeedbackGenerator(style: .light).impactOccurred()
let request = Poll.vote(poll!.id, choices: optionsView.checkedOptionIndices) let request = Poll.vote(poll!.id, choices: optionsView.checkedOptionIndices)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in

View File

@ -98,8 +98,8 @@ class StatusCardView: UIView {
hStack.spacing = 4 hStack.spacing = 4
hStack.clipsToBounds = true hStack.clipsToBounds = true
hStack.layer.borderWidth = 0.5 hStack.layer.borderWidth = 0.5
hStack.layer.borderColor = UIColor.lightGray.cgColor
hStack.backgroundColor = inactiveBackgroundColor hStack.backgroundColor = inactiveBackgroundColor
updateBorderColor()
addSubview(hStack) addSubview(hStack)
@ -132,11 +132,24 @@ class StatusCardView: UIView {
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
} }
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
updateBorderColor()
}
override func layoutSubviews() { override func layoutSubviews() {
super.layoutSubviews() super.layoutSubviews()
hStack.layer.cornerRadius = 0.1 * bounds.height hStack.layer.cornerRadius = 0.1 * bounds.height
} }
private func updateBorderColor() {
if traitCollection.userInterfaceStyle == .dark {
hStack.layer.borderColor = UIColor.darkGray.cgColor
} else {
hStack.layer.borderColor = UIColor.lightGray.cgColor
}
}
func updateUI(status: StatusMO) { func updateUI(status: StatusMO) {
guard status.id != statusID else { guard status.id != statusID else {
return return

View File

@ -83,17 +83,21 @@ extension StatusCollectionViewCell {
accountID = status.account.id accountID = status.account.id
updateAccountUI(account: status.account) updateAccountUI(account: status.account)
updateUIForPreferences(status: status)
contentContainer.contentTextView.setTextFrom(status: status, precomputed: precomputedContent) contentContainer.contentTextView.setTextFrom(status: status, precomputed: precomputedContent)
contentContainer.contentTextView.navigationDelegate = delegate contentContainer.contentTextView.navigationDelegate = delegate
contentContainer.attachmentsView.delegate = self contentContainer.attachmentsView.delegate = self
contentContainer.attachmentsView.updateUI(status: status) contentContainer.attachmentsView.updateUI(status: status)
if Preferences.shared.showLinkPreviews {
contentContainer.cardView.updateUI(status: status) contentContainer.cardView.updateUI(status: status)
contentContainer.cardView.isHidden = status.card == nil contentContainer.cardView.isHidden = status.card == nil
contentContainer.cardView.navigationDelegate = delegate contentContainer.cardView.navigationDelegate = delegate
contentContainer.cardView.actionProvider = delegate contentContainer.cardView.actionProvider = delegate
} else {
contentContainer.cardView.isHidden = true
}
updateUIForPreferences(status: status)
updateStatusState(status: status) updateStatusState(status: status)
contentWarningLabel.text = status.spoilerText contentWarningLabel.text = status.spoilerText
@ -150,6 +154,12 @@ extension StatusCollectionViewCell {
func baseUpdateUIForPreferences(status: StatusMO) { func baseUpdateUIForPreferences(status: StatusMO) {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.avatarImageViewSize avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.avatarImageViewSize
let newCardHidden = !Preferences.shared.showLinkPreviews || status.card == nil
if contentContainer.cardView.isHidden != newCardHidden {
delegate?.statusCellNeedsReconfigure(self, animated: false, completion: nil)
}
switch Preferences.shared.attachmentBlurMode { switch Preferences.shared.attachmentBlurMode {
case .never: case .never:
contentContainer.attachmentsView.contentHidden = false contentContainer.attachmentsView.contentHidden = false