Compare commits

...

5 Commits

13 changed files with 358 additions and 26 deletions

View File

@ -18,6 +18,7 @@ public class Relationship: Decodable {
public let followRequested: Bool
public let domainBlocking: Bool
public let showingReblogs: Bool
public let endorsed: Bool?
private enum CodingKeys: String, CodingKey {
case id
@ -29,5 +30,6 @@ public class Relationship: Decodable {
case followRequested = "requested"
case domainBlocking = "domain_blocking"
case showingReblogs = "showing_reblogs"
case endorsed
}
}

View File

@ -238,6 +238,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 */; };
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; };
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; };
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; };
@ -260,6 +261,7 @@
D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; };
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; };
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */; };
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */; };
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; };
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */; };
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E0DC8D216EDF1E00369478 /* Previewing.swift */; };
@ -566,6 +568,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>"; };
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = "<group>"; };
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; };
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; };
@ -594,6 +597,7 @@
D6D4DDF1212518A200E1C4BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; };
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = "<group>"; };
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelationshipMO.swift; sourceTree = "<group>"; };
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.swift; sourceTree = "<group>"; };
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakArray.swift; sourceTree = "<group>"; };
D6E0DC8D216EDF1E00369478 /* Previewing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Previewing.swift; sourceTree = "<group>"; };
@ -893,6 +897,7 @@
D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */,
D60E2F232442372B005F8713 /* StatusMO.swift */,
D60E2F252442372B005F8713 /* AccountMO.swift */,
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */,
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
);
path = CoreData;
@ -1001,6 +1006,7 @@
D62275A524F1C81800B82A16 /* ComposeReplyView.swift */,
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */,
D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */,
D6C143D9253510F4007DC240 /* ComposeContentWarningTextField.swift */,
);
path = Compose;
sourceTree = "<group>";
@ -1837,6 +1843,7 @@
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */,
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */,
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */,
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */,
@ -1856,6 +1863,7 @@
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */,
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */,
D6C143DA253510F4007DC240 /* ComposeContentWarningTextField.swift in Sources */,
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */,
D627943923A553B600D38C68 /* UnbookmarkStatusActivity.swift in Sources */,
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */,

View File

@ -29,10 +29,13 @@ class FollowAccountActivity: AccountActivity {
let request = Account.follow(account.id)
mastodonController.run(request) { (response) in
if case .failure(_) = response {
switch response {
case .failure(_):
// todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
case let .success(relationship, _):
self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
}
}
}

View File

@ -29,10 +29,13 @@ class UnfollowAccountActivity: AccountActivity {
let request = Account.unfollow(account.id)
mastodonController.run(request) { (response) in
if case .failure(_) = response {
switch response {
case .failure(_):
// todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
case let .success(relationship, _):
self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
}
}
}

View File

@ -26,6 +26,7 @@ class MastodonCachePersistentStore: NSPersistentContainer {
let statusSubject = PassthroughSubject<String, Never>()
let accountSubject = PassthroughSubject<String, Never>()
let relationshipSubject = PassthroughSubject<String, Never>()
init(for accountInfo: LocalData.UserAccountInfo?, transient: Bool = false) {
if transient {
@ -136,6 +137,40 @@ class MastodonCachePersistentStore: NSPersistentContainer {
}
}
func relationship(forAccount id: String, in context: NSManagedObjectContext? = nil) -> RelationshipMO? {
let context = context ?? viewContext
let request: NSFetchRequest<RelationshipMO> = RelationshipMO.fetchRequest()
request.predicate = NSPredicate(format: "accountID = %@", id)
request.fetchLimit = 1
if let result = try? context.fetch(request), let relationship = result.first {
return relationship
} else {
return nil
}
}
@discardableResult
private func upsert(relationship: Relationship) -> RelationshipMO {
if let relationshipMO = self.relationship(forAccount: relationship.id, in: self.backgroundContext) {
relationshipMO.updateFrom(apiRelationship: relationship, container: self)
return relationshipMO
} else {
let relationshipMO = RelationshipMO(apiRelationship: relationship, container: self, context: self.backgroundContext)
return relationshipMO
}
}
func addOrUpdate(relationship: Relationship, completion: ((RelationshipMO) -> Void)? = nil) {
backgroundContext.perform {
let relationshipMO = self.upsert(relationship: relationship)
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?(relationshipMO)
self.relationshipSubject.send(relationship.id)
}
}
func addAll(accounts: [Account], completion: (() -> Void)? = nil) {
backgroundContext.perform {
accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) }

View File

@ -0,0 +1,59 @@
//
// RelationshipMO.swift
// Tusker
//
// Created by Shadowfacts on 10/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
import CoreData
import Pachyderm
@objc(RelationshipMO)
public final class RelationshipMO: NSManagedObject {
@nonobjc public class func fetchRequest() -> NSFetchRequest<RelationshipMO> {
return NSFetchRequest<RelationshipMO>(entityName: "Relationship")
}
@NSManaged public var accountID: String
@NSManaged public var blocking: Bool
@NSManaged public var domainBlocking: Bool
@NSManaged public var endorsed: Bool
@NSManaged public var followedBy: Bool
@NSManaged public var following: Bool
@NSManaged public var muting: Bool
@NSManaged public var mutingNotifications: Bool
@NSManaged public var requested: Bool
@NSManaged public var showingReblogs: Bool
@NSManaged public var account: AccountMO?
}
extension RelationshipMO {
convenience init(apiRelationship relationship: Relationship, container: MastodonCachePersistentStore, context: NSManagedObjectContext) {
self.init(context: context)
self.updateFrom(apiRelationship: relationship, container: container)
}
func updateFrom(apiRelationship relationship: Relationship, container: MastodonCachePersistentStore) {
guard let context = managedObjectContext else {
// we have been deleted, don't bother updating
return
}
self.accountID = relationship.id
self.blocking = relationship.blocking
self.domainBlocking = relationship.domainBlocking
self.endorsed = relationship.endorsed ?? false
self.followedBy = relationship.followedBy
self.following = relationship.following
self.muting = relationship.muting
self.mutingNotifications = relationship.mutingNotifications
self.requested = relationship.followRequested
self.showingReblogs = relationship.showingReblogs
self.account = container.account(for: relationship.id, in: context)
}
}

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="16119" systemVersion="19D76" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17507" systemVersion="19G2021" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="AccountMO" syncable="YES">
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" attributeType="URI"/>
@ -20,12 +20,26 @@
<attribute name="url" attributeType="URI"/>
<attribute name="username" attributeType="String"/>
<relationship name="movedTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account"/>
<relationship name="relationship" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Relationship" inverseName="account" inverseEntity="Relationship"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="Relationship" representedClassName="RelationshipMO" syncable="YES">
<attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="blocking" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="domainBlocking" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="endorsed" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="followedBy" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="following" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="muting" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="mutingNotifications" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="requested" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="showingReblogs" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="relationship" inverseEntity="Account"/>
</entity>
<entity name="Status" representedClassName="StatusMO" syncable="YES">
<attribute name="applicationName" optional="YES" attributeType="String"/>
<attribute name="attachmentsData" attributeType="Binary"/>
@ -59,7 +73,8 @@
</uniquenessConstraints>
</entity>
<elements>
<element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="328"/>
<element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="343"/>
<element name="Status" positionX="-63" positionY="-18" width="128" height="418"/>
<element name="Relationship" positionX="63" positionY="135" width="128" height="208"/>
</elements>
</model>

View File

@ -73,7 +73,7 @@ struct ComposeAutocompleteMentionsView: View {
HStack(spacing: 8) {
ForEach(accounts, id: \.id) { (account) in
Button {
uiState.autocompleteHandler?.autocomplete(with: "@\(account.acct) ")
uiState.autocompleteHandler?.autocomplete(with: "@\(account.acct)")
} label: {
HStack(spacing: 4) {
ComposeAvatarImageView(url: account.avatar)
@ -210,7 +210,7 @@ struct ComposeAutocompleteEmojisView: View {
HStack(spacing: 8) {
ForEach(emojis, id: \.shortcode) { (emoji) in
Button {
uiState.autocompleteHandler?.autocomplete(with: ":\(emoji.shortcode): ")
uiState.autocompleteHandler?.autocomplete(with: ":\(emoji.shortcode):")
} label: {
HStack(spacing: 4) {
CustomEmojiImageView(emoji: emoji)
@ -266,7 +266,7 @@ struct ComposeAutocompleteHashtagsView: View {
HStack(spacing: 8) {
ForEach(hashtags, id: \.name) { (hashtag) in
Button {
uiState.autocompleteHandler?.autocomplete(with: "#\(hashtag.name) ")
uiState.autocompleteHandler?.autocomplete(with: "#\(hashtag.name)")
} label: {
Text(verbatim: "#\(hashtag.name)")
.foregroundColor(Color(UIColor.label))

View File

@ -0,0 +1,163 @@
//
// ComposeContentWarningTextField.swift
// Tusker
//
// Created by Shadowfacts on 10/12/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
struct ComposeContentWarningTextField: UIViewRepresentable {
typealias UIViewType = UITextField
@Binding var text: String
@EnvironmentObject private var uiState: ComposeUIState
func makeUIView(context: Context) -> UITextField {
let view = UITextField()
view.placeholder = "Write your warning here"
view.borderStyle = .roundedRect
view.delegate = context.coordinator
view.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged)
context.coordinator.textField = view
context.coordinator.uiState = uiState
context.coordinator.text = $text
return view
}
func updateUIView(_ uiView: UITextField, context: Context) {
uiView.text = text
}
func makeCoordinator() -> Coordinator {
return Coordinator()
}
class Coordinator: NSObject, UITextFieldDelegate, ComposeAutocompleteHandler {
weak var textField: UITextField?
var text: Binding<String>!
var uiState: ComposeUIState!
@objc func didChange(_ textField: UITextField) {
text.wrappedValue = textField.text ?? ""
}
func textFieldDidBeginEditing(_ textField: UITextField) {
uiState.autocompleteHandler = self
updateAutocompleteState(textField: textField)
}
func textFieldDidEndEditing(_ textField: UITextField) {
uiState.autocompleteHandler = nil
updateAutocompleteState(textField: textField)
}
func textFieldDidChangeSelection(_ textField: UITextField) {
updateAutocompleteState(textField: textField)
}
func autocomplete(with string: String) {
guard let textField = textField,
let text = textField.text,
let selectedRange = textField.selectedTextRange,
let lastWordStartIndex = findAutocompleteLastWord(textField: textField) else {
return
}
let distanceToEnd = textField.offset(from: selectedRange.start, to: textField.endOfDocument)
let selectedRangeStartUTF16 = textField.offset(from: textField.beginningOfDocument, to: selectedRange.start)
let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
let insertSpace: Bool
if text.distance(from: characterBeforeCursorIndex, to: text.endIndex) > 0 {
let charAfterCursor = text[text.index(after: characterBeforeCursorIndex)]
insertSpace = charAfterCursor != " " && charAfterCursor != "\n"
} else {
insertSpace = true
}
let string = insertSpace ? string + " " : string
textField.text!.replaceSubrange(lastWordStartIndex..<characterBeforeCursorIndex, with: string)
didChange(textField)
// keep the cursor at the same position in the text, immediately after what was inserted
let newCursorPosition = textField.position(from: textField.endOfDocument, offset: -distanceToEnd)!
textField.selectedTextRange = textField.textRange(from: newCursorPosition, to: newCursorPosition)
}
private func updateAutocompleteState(textField: UITextField) {
guard let selectedRange = textField.selectedTextRange,
let text = textField.text,
let lastWordStartIndex = findAutocompleteLastWord(textField: textField) else {
uiState.autocompleteState = nil
return
}
if lastWordStartIndex > text.startIndex {
let c = text[text.index(before: lastWordStartIndex)]
if isPermittedForAutocomplete(c) || c == ":" {
uiState.autocompleteState = nil
return
}
}
let selectedRangeStartUTF16 = textField.offset(from: textField.beginningOfDocument, to: selectedRange.start)
let cursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
if lastWordStartIndex >= text.startIndex {
let lastWord = text[lastWordStartIndex..<cursorIndex]
let exceptFirst = lastWord[lastWord.index(after: lastWord.startIndex)...]
if lastWord.first == ":" {
uiState.autocompleteState = .emoji(String(exceptFirst))
} else {
uiState.autocompleteState = nil
}
} else {
uiState.autocompleteState = nil
}
}
private func isPermittedForAutocomplete(_ c: Character) -> Bool {
return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_"
}
private func findAutocompleteLastWord(textField: UITextField) -> String.Index? {
guard textField.isFirstResponder,
let selectedRange = textField.selectedTextRange,
selectedRange.isEmpty,
let text = textField.text,
!text.isEmpty else {
return nil
}
let selectedRangeStartUTF16 = textField.offset(from: textField.beginningOfDocument, to: selectedRange.start)
let cursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
var lastWordStartIndex = text.index(before: cursorIndex)
while true {
let c = text[lastWordStartIndex]
if !isPermittedForAutocomplete(c) {
break
}
if lastWordStartIndex > text.startIndex {
lastWordStartIndex = text.index(before: lastWordStartIndex)
} else {
break
}
}
return lastWordStartIndex
}
}
}

View File

@ -114,8 +114,7 @@ struct ComposeView: View {
header
if draft.contentWarningEnabled {
TextField("Write your warning here", text: $draft.contentWarning)
.textFieldStyle(RoundedBorderTextFieldStyle())
ComposeContentWarningTextField(text: $draft.contentWarning)
}
MainComposeTextView(
@ -130,6 +129,7 @@ struct ComposeView: View {
.padding([.top, .bottom], -8)
}
.padding(stackPadding)
.padding(.bottom, uiState.autocompleteState != nil ? 46 : nil)
}
private var header: some View {

View File

@ -218,11 +218,13 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
uiState.delegate?.keyboardDidHide(accessoryView: textView!.inputAccessoryView!, notification: notification)
}
func textViewDidEndEditing(_ textView: UITextView) {
func textViewDidBeginEditing(_ textView: UITextView) {
uiState.autocompleteHandler = self
updateAutocompleteState()
}
func textViewDidBeginEditing(_ textView: UITextView) {
func textViewDidEndEditing(_ textView: UITextView) {
uiState.autocompleteHandler = nil
updateAutocompleteState()
}
@ -237,10 +239,24 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
return
}
let distanceToEnd = text.utf16.count - textView.selectedRange.upperBound
let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound)
let insertSpace: Bool
if text.distance(from: characterBeforeCursorIndex, to: text.endIndex) > 0 {
let charAfterCursor = text[text.index(after: characterBeforeCursorIndex)]
insertSpace = charAfterCursor != " " && charAfterCursor != "\n"
} else {
insertSpace = true
}
let string = insertSpace ? string + " " : string
textView.text.replaceSubrange(lastWordStartIndex..<characterBeforeCursorIndex, with: string)
self.textViewDidChange(textView)
// keep the cursor at the same position in the text, immediately after what was inserted
textView.selectedRange = NSRange(location: textView.text.utf16.count - distanceToEnd, length: 0)
}
private func updateAutocompleteState() {
@ -312,7 +328,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
var lastWordStartIndex = text.index(before: characterBeforeCursorIndex)
var foundFirstAtSign = false
while true {
let c = textView.text[lastWordStartIndex]
let c = text[lastWordStartIndex]
if !isPermittedForAutocomplete(c) {
if foundFirstAtSign {

View File

@ -71,7 +71,13 @@ extension MenuPreviewProvider {
elementHandler([
self.createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus", handler: { (_) in
let request = (following ? Account.unfollow : Account.follow)(accountID)
mastodonController.run(request) { (_) in
mastodonController.run(request) { (response) in
switch response {
case .failure(_):
fatalError()
case let .success(relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
}
}
})
])

View File

@ -23,7 +23,11 @@ class ProfileHeaderView: UIView {
return nib.instantiate(withOwner: nil, options: nil).first as! ProfileHeaderView
}
weak var delegate: ProfileHeaderViewDelegate?
weak var delegate: ProfileHeaderViewDelegate? {
didSet {
createObservers()
}
}
var mastodonController: MastodonController! { delegate?.apiController }
@IBOutlet weak var headerImageView: UIImageView!
@ -41,10 +45,10 @@ class ProfileHeaderView: UIView {
var accountID: String!
var avatarRequest: ImageCache.Request?
var headerRequest: ImageCache.Request?
private var avatarRequest: ImageCache.Request?
private var headerRequest: ImageCache.Request?
private var accountUpdater: Cancellable?
private var cancellables = [AnyCancellable]()
deinit {
avatarRequest?.cancel()
@ -74,6 +78,22 @@ class ProfileHeaderView: UIView {
}
}
private func createObservers() {
cancellables = []
mastodonController.persistentContainer.accountSubject
.filter { [weak self] in $0 == self?.accountID }
.receive(on: DispatchQueue.main)
.sink { [weak self] in self?.updateUI(for: $0) }
.store(in: &cancellables)
mastodonController.persistentContainer.relationshipSubject
.filter { [weak self] in $0 == self?.accountID }
.receive(on: DispatchQueue.main)
.sink { [weak self] (_) in self?.updateRelationship() }
.store(in: &cancellables)
}
func updateUI(for accountID: String) {
self.accountID = accountID
@ -111,6 +131,9 @@ class ProfileHeaderView: UIView {
// don't show relationship label for the user's own account
if accountID != mastodonController.account?.id {
// while fetching the most up-to-date, show the current data (if any)
updateRelationship()
let request = Client.getRelationships(accounts: [accountID])
mastodonController.run(request) { [weak self] (response) in
guard let self = self,
@ -118,9 +141,7 @@ class ProfileHeaderView: UIView {
let relationship = results.first else {
return
}
DispatchQueue.main.async {
self.followsYouLabel.isHidden = !relationship.followedBy
}
self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
}
}
@ -152,13 +173,14 @@ class ProfileHeaderView: UIView {
nameLabel.heightAnchor.constraint(equalTo: valueTextView.heightAnchor).isActive = true
}
if accountUpdater == nil {
accountUpdater = mastodonController.persistentContainer.accountSubject
.filter { [weak self] in $0 == self?.accountID }
.receive(on: DispatchQueue.main)
.sink { [weak self] in self?.updateUI(for: $0) }
}
private func updateRelationship() {
guard let relationship = mastodonController.persistentContainer.relationship(forAccount: accountID) else {
return
}
followsYouLabel.isHidden = !relationship.followedBy
}
@objc private func updateUIForPreferences() {