Compare commits

..

4 Commits

10 changed files with 128 additions and 57 deletions

View File

@ -62,6 +62,7 @@ public final class Preferences: Codable, ObservableObject {
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode
self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false
self.showAttachmentsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentsInTimeline) ?? true
if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) { if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) {
self.defaultPostVisibility = .visibility(existing) self.defaultPostVisibility = .visibility(existing)
@ -127,6 +128,7 @@ public final class Preferences: Codable, ObservableObject {
try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions) try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions)
try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode) try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode)
try container.encode(underlineTextLinks, forKey: .underlineTextLinks) try container.encode(underlineTextLinks, forKey: .underlineTextLinks)
try container.encode(showAttachmentsInTimeline, forKey: .showAttachmentsInTimeline)
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility) try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility) try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
@ -182,6 +184,7 @@ public final class Preferences: Codable, ObservableObject {
private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen
@Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode @Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode
@Published public var underlineTextLinks = false @Published public var underlineTextLinks = false
@Published public var showAttachmentsInTimeline = true
// MARK: Composing // MARK: Composing
@Published public var defaultPostVisibility = PostVisibility.serverDefault @Published public var defaultPostVisibility = PostVisibility.serverDefault
@ -253,6 +256,7 @@ public final class Preferences: Codable, ObservableObject {
case trailingStatusSwipeActions case trailingStatusSwipeActions
case widescreenNavigationMode case widescreenNavigationMode
case underlineTextLinks case underlineTextLinks
case showAttachmentsInTimeline
case defaultPostVisibility case defaultPostVisibility
case defaultReplyVisibility case defaultReplyVisibility

View File

@ -123,7 +123,8 @@
<attribute name="url" optional="YES" attributeType="URI"/> <attribute name="url" optional="YES" attributeType="URI"/>
<attribute name="visibilityString" attributeType="String"/> <attribute name="visibilityString" attributeType="String"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="statuses" inverseEntity="Account"/> <relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="statuses" inverseEntity="Account"/>
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status"/> <relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="reblogs" inverseEntity="Status"/>
<relationship name="reblogs" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="reblog" inverseEntity="Status"/>
<relationship name="timelines" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="TimelineState" inverseName="statuses" inverseEntity="TimelineState"/> <relationship name="timelines" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="TimelineState" inverseName="statuses" inverseEntity="TimelineState"/>
<uniquenessConstraints> <uniquenessConstraints>
<uniquenessConstraint> <uniquenessConstraint>

View File

@ -33,41 +33,37 @@ public struct LazilyDecoding<Enclosing: NSManagedObject, Value: Codable> {
public static subscript(_enclosingInstance instance: Enclosing, wrapped wrappedKeyPath: ReferenceWritableKeyPath<Enclosing, Value>, storage storageKeyPath: ReferenceWritableKeyPath<Enclosing, Self>) -> Value { public static subscript(_enclosingInstance instance: Enclosing, wrapped wrappedKeyPath: ReferenceWritableKeyPath<Enclosing, Value>, storage storageKeyPath: ReferenceWritableKeyPath<Enclosing, Self>) -> Value {
get { get {
instance.performOnContext { var wrapper = instance[keyPath: storageKeyPath]
var wrapper = instance[keyPath: storageKeyPath] if let value = wrapper.value {
if let value = wrapper.value { return value
return value } else {
} else { guard let data = instance[keyPath: wrapper.keyPath] else { return wrapper.fallback }
guard let data = instance[keyPath: wrapper.keyPath] else { return wrapper.fallback } do {
do { let value = try decoder.decode(Box.self, from: data)
let value = try decoder.decode(Box.self, from: data) wrapper.value = value.value
wrapper.value = value.value wrapper.observation = instance.observe(wrapper.keyPath, changeHandler: { instance, _ in
wrapper.observation = instance.observe(wrapper.keyPath, changeHandler: { instance, _ in var wrapper = instance[keyPath: storageKeyPath]
var wrapper = instance[keyPath: storageKeyPath] if wrapper.skipClearingOnNextUpdate {
if wrapper.skipClearingOnNextUpdate { wrapper.skipClearingOnNextUpdate = false
wrapper.skipClearingOnNextUpdate = false } else {
} else { wrapper.removeCachedValue()
wrapper.removeCachedValue() }
}
instance[keyPath: storageKeyPath] = wrapper
})
instance[keyPath: storageKeyPath] = wrapper instance[keyPath: storageKeyPath] = wrapper
return value.value })
} catch { instance[keyPath: storageKeyPath] = wrapper
return wrapper.fallback return value.value
} } catch {
return wrapper.fallback
} }
} }
} }
set { set {
instance.performOnContext { var wrapper = instance[keyPath: storageKeyPath]
var wrapper = instance[keyPath: storageKeyPath] wrapper.value = newValue
wrapper.value = newValue wrapper.skipClearingOnNextUpdate = true
wrapper.skipClearingOnNextUpdate = true instance[keyPath: storageKeyPath] = wrapper
instance[keyPath: storageKeyPath] = wrapper let newData = try! encoder.encode(Box(value: newValue))
let newData = try! encoder.encode(Box(value: newValue)) instance[keyPath: wrapper.keyPath] = newData
instance[keyPath: wrapper.keyPath] = newData
}
} }
} }
@ -78,16 +74,6 @@ public struct LazilyDecoding<Enclosing: NSManagedObject, Value: Codable> {
} }
extension NSManagedObject {
fileprivate func performOnContext<V>(_ f: () -> V) -> V {
if let managedObjectContext {
managedObjectContext.performAndWait(f)
} else {
f()
}
}
}
extension LazilyDecoding { extension LazilyDecoding {
init<T>(arrayFrom keyPath: ReferenceWritableKeyPath<Enclosing, Data?>) where Value == [T] { init<T>(arrayFrom keyPath: ReferenceWritableKeyPath<Enclosing, Data?>) where Value == [T] {
self.init(from: keyPath, fallback: []) self.init(from: keyPath, fallback: [])

View File

@ -148,7 +148,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
minDate.addTimeInterval(-7 * 24 * 60 * 60) minDate.addTimeInterval(-7 * 24 * 60 * 60)
let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest() let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest()
statusReq.predicate = NSPredicate(format: "(lastFetchedAt = nil) OR (lastFetchedAt < %@)", minDate as NSDate) statusReq.predicate = NSPredicate(format: "((lastFetchedAt = nil) OR (lastFetchedAt < %@)) AND (reblogs.@count = 0)", minDate as NSDate)
let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq) let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq)
deleteStatusReq.resultType = .resultTypeCount deleteStatusReq.resultType = .resultTypeCount
if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult { if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult {

View File

@ -118,12 +118,15 @@ struct AppearancePrefsView : View {
Toggle(isOn: $preferences.alwaysShowStatusVisibilityIcon) { Toggle(isOn: $preferences.alwaysShowStatusVisibilityIcon) {
Text("Always Show Status Visibility Icons") Text("Always Show Status Visibility Icons")
} }
Toggle(isOn: $preferences.hideActionsInTimeline) {
Text("Hide Actions on Timeline")
}
Toggle(isOn: $preferences.showLinkPreviews) { Toggle(isOn: $preferences.showLinkPreviews) {
Text("Show Link Previews") Text("Show Link Previews")
} }
Toggle(isOn: $preferences.showAttachmentsInTimeline) {
Text("Show Attachments on Timeline")
}
Toggle(isOn: $preferences.hideActionsInTimeline) {
Text("Hide Actions on Timeline")
}
Toggle(isOn: $preferences.underlineTextLinks) { Toggle(isOn: $preferences.underlineTextLinks) {
Text("Underline Links") Text("Underline Links")
} }

View File

@ -164,6 +164,18 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
.store(in: &cancellables) .store(in: &cancellables)
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
Preferences.shared.$showAttachmentsInTimeline
// skip the initial value
.dropFirst()
// publisher fires on willChange, wait the change is actually made
.receive(on: DispatchQueue.main)
.sink { [unowned self] _ in
var snapshot = self.dataSource.snapshot()
snapshot.reconfigureItems(snapshot.itemIdentifiers)
self.dataSource.apply(snapshot, animatingDifferences: false)
}
.store(in: &cancellables)
if userActivity != nil { if userActivity != nil {
userActivityNeedsUpdate userActivityNeedsUpdate
.debounce(for: .seconds(1), scheduler: DispatchQueue.main) .debounce(for: .seconds(1), scheduler: DispatchQueue.main)
@ -180,6 +192,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
// separate method because InstanceTimelineViewController needs to be able to customize it // separate method because InstanceTimelineViewController needs to be able to customize it
func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) { func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) {
cell.delegate = self cell.delegate = self
cell.showAttachmentsInline = Preferences.shared.showAttachmentsInTimeline
if case .home = timeline { if case .home = timeline {
cell.showFollowedHashtags = true cell.showFollowedHashtags = true
} else { } else {
@ -445,6 +458,16 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
if let status = mastodonController.persistentContainer.status(for: id) { if let status = mastodonController.persistentContainer.status(for: id) {
// touch the status so that, even if it's old, it doesn't get pruned when we go into the background // touch the status so that, even if it's old, it doesn't get pruned when we go into the background
status.touch() status.touch()
// there was a bug where th the reblogged status would get pruned even when it was still refernced by the reblog
// as a temporary workaround, until there are no longer user db's in this state,
// check if the reblog is invalid and reload the status if so
if let reblog = status.reblog,
// force the fault to fire
case _ = reblog.id,
reblog.isDeleted {
unloaded.append(id)
}
} else { } else {
unloaded.append(id) unloaded.append(id)
} }

View File

@ -30,8 +30,8 @@ class AttachmentsContainerView: UIView {
} }
} }
var blurView: UIVisualEffectView? private var blurView: UIVisualEffectView?
var hideButtonView: UIVisualEffectView? private var hideButtonView: UIVisualEffectView?
var contentHidden: Bool! { var contentHidden: Bool! {
didSet { didSet {
guard let blurView = blurView, guard let blurView = blurView,
@ -42,6 +42,8 @@ class AttachmentsContainerView: UIView {
} }
} }
private var label: UILabel?
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
commonInit() commonInit()
@ -67,9 +69,16 @@ class AttachmentsContainerView: UIView {
// MARK: - User Interaface // MARK: - User Interaface
func updateUI(attachments: [Attachment]) { func updateUI(attachments: [Attachment], labelOnly: Bool = false) {
let newTokens = attachments.map { AttachmentToken(attachment: $0) } let newTokens = attachments.map { AttachmentToken(attachment: $0) }
guard !labelOnly else {
self.attachments = attachments
self.attachmentTokens = newTokens
updateLabel(attachments: attachments)
return
}
guard self.attachmentTokens != newTokens else { guard self.attachmentTokens != newTokens else {
return return
} }
@ -77,11 +86,8 @@ class AttachmentsContainerView: UIView {
self.attachments = attachments self.attachments = attachments
self.attachmentTokens = newTokens self.attachmentTokens = newTokens
attachmentViews.allObjects.forEach { $0.removeFromSuperview() } removeAttachmentViews()
attachmentViews.removeAllObjects() hideButtonView?.isHidden = false
attachmentStacks.allObjects.forEach { $0.removeFromSuperview() }
attachmentStacks.removeAllObjects()
moreView?.removeFromSuperview()
var accessibilityElements = [Any]() var accessibilityElements = [Any]()
@ -284,6 +290,14 @@ class AttachmentsContainerView: UIView {
self.accessibilityElements = accessibilityElements self.accessibilityElements = accessibilityElements
} }
private func removeAttachmentViews() {
attachmentViews.allObjects.forEach { $0.removeFromSuperview() }
attachmentViews.removeAllObjects()
attachmentStacks.allObjects.forEach { $0.removeFromSuperview() }
attachmentStacks.removeAllObjects()
moreView?.removeFromSuperview()
}
private func createAttachmentView(index: Int, hSize: RelativeSize, vSize: RelativeSize) -> AttachmentView { private func createAttachmentView(index: Int, hSize: RelativeSize, vSize: RelativeSize) -> AttachmentView {
let attachmentView = AttachmentView(attachment: attachments[index], index: index) let attachmentView = AttachmentView(attachment: attachments[index], index: index)
attachmentView.delegate = delegate attachmentView.delegate = delegate
@ -396,6 +410,35 @@ class AttachmentsContainerView: UIView {
]) ])
} }
private func updateLabel(attachments: [Attachment]) {
removeAttachmentViews()
blurView?.isHidden = true
hideButtonView?.isHidden = true
aspectRatioConstraint?.isActive = false
if label == nil {
if attachments.isEmpty {
accessibilityElements = []
return
}
label = UILabel()
label!.font = .preferredFont(forTextStyle: .body)
label!.adjustsFontForContentSizeCategory = true
label!.textColor = .secondaryLabel
label!.translatesAutoresizingMaskIntoConstraints = false
addSubview(label!)
NSLayoutConstraint.activate([
label!.leadingAnchor.constraint(equalTo: leadingAnchor),
label!.trailingAnchor.constraint(equalTo: trailingAnchor),
label!.topAnchor.constraint(equalTo: topAnchor),
label!.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
label!.text = "\(attachments.count) attachment\(attachments.count == 1 ? "" : "s")"
accessibilityElements = [label!]
}
// MARK: - Interaction // MARK: - Interaction
@objc func blurViewTapped() { @objc func blurViewTapped() {

View File

@ -44,6 +44,7 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate
var isGrayscale: Bool { get set } var isGrayscale: Bool { get set }
var cancellables: Set<AnyCancellable> { get set } var cancellables: Set<AnyCancellable> { get set }
func updateAttachmentsUI(status: StatusMO)
func updateUIForPreferences(status: StatusMO) func updateUIForPreferences(status: StatusMO)
func updateStatusState(status: StatusMO) func updateStatusState(status: StatusMO)
func estimateContentHeight() -> CGFloat func estimateContentHeight() -> CGFloat
@ -91,8 +92,7 @@ extension StatusCollectionViewCell {
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 self.updateAttachmentsUI(status: status)
contentContainer.attachmentsView.updateUI(attachments: status.attachments)
contentContainer.pollView.isHidden = status.poll == nil contentContainer.pollView.isHidden = status.poll == nil
contentContainer.pollView.mastodonController = mastodonController contentContainer.pollView.mastodonController = mastodonController
contentContainer.pollView.delegate = delegate contentContainer.pollView.delegate = delegate
@ -167,6 +167,11 @@ extension StatusCollectionViewCell {
return true return true
} }
func updateAttachmentsUI(status: StatusMO) {
contentContainer.attachmentsView.delegate = self
contentContainer.attachmentsView.updateUI(attachments: status.attachments)
}
func updateAccountUI(account: AccountMO) { func updateAccountUI(account: AccountMO) {
avatarImageView.update(for: account.avatar) avatarImageView.update(for: account.avatar)
displayNameLabel.updateForAccountDisplayName(account: account) displayNameLabel.updateForAccountDisplayName(account: account)

View File

@ -14,8 +14,8 @@ protocol StatusContentPollView: UIView {
class StatusContentContainer<ContentView: ContentTextView, PollView: StatusContentPollView>: UIView { class StatusContentContainer<ContentView: ContentTextView, PollView: StatusContentPollView>: UIView {
private var useTopSpacer = false private let useTopSpacer: Bool
private let topSpacer = UIView().configure { private lazy var topSpacer = UIView().configure {
$0.backgroundColor = .clear $0.backgroundColor = .clear
// other 4pt is provided by this view's own spacing // other 4pt is provided by this view's own spacing
$0.heightAnchor.constraint(equalToConstant: 4).isActive = true $0.heightAnchor.constraint(equalToConstant: 4).isActive = true

View File

@ -297,6 +297,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
var showReplyIndicator = true var showReplyIndicator = true
var showPinned: Bool = false var showPinned: Bool = false
var showFollowedHashtags: Bool = false var showFollowedHashtags: Bool = false
var showAttachmentsInline = true
// alas these need to be internal so they're accessible from the protocol extensions // alas these need to be internal so they're accessible from the protocol extensions
var statusID: String! var statusID: String!
@ -645,6 +646,11 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
baseUpdateStatusState(status: status) baseUpdateStatusState(status: status)
} }
func updateAttachmentsUI(status: StatusMO) {
attachmentsView.delegate = self
attachmentsView.updateUI(attachments: status.attachments, labelOnly: !showAttachmentsInline)
}
func estimateContentHeight() -> CGFloat { func estimateContentHeight() -> CGFloat {
let width = bounds.width /* leading spacing: */ - 16 /* avatar: */ - 50 /* spacing: 8 */ - 8 /* trailing spacing: */ - 16 let width = bounds.width /* leading spacing: */ - 16 /* avatar: */ - 50 /* spacing: 8 */ - 8 /* trailing spacing: */ - 16
return contentContainer.estimateVisibleSubviewHeight(effectiveWidth: width) return contentContainer.estimateVisibleSubviewHeight(effectiveWidth: width)