From d5c9473528b34a50ceb82cede013db807dda3ea8 Mon Sep 17 00:00:00 2001 From: CMK <cirno.mainasuk@gmail.com> Date: Wed, 14 Apr 2021 15:59:29 +0800 Subject: [PATCH] feat: implement reply status entry and update query of API --- .../CoreData.xcdatamodel/contents | 7 ++-- CoreDataStack/Entity/Mention.swift | 7 +++- .../Section/ComposeStatusSection.swift | 12 +++++++ Mastodon/Helper/MastodonField.swift | 2 +- ...Provider+StatusTableViewCellDelegate.swift | 4 +++ .../StatusProvider/StatusProviderFacade.swift | 32 ++++++++++++++++++- .../StatusTableViewControllerAspect.swift | 2 +- .../Scene/Compose/ComposeViewController.swift | 2 +- .../ComposeViewModel+PublishState.swift | 11 +++++++ Mastodon/Scene/Compose/ComposeViewModel.swift | 31 +++++++++++++++++- .../HashtagTimelineViewController.swift | 4 +++ .../Favorite/FavoriteViewController.swift | 4 +++ .../TableviewCell/StatusTableViewCell.swift | 11 ++++--- .../Scene/Thread/ThreadViewController.swift | 7 ++-- .../CoreData/APIService+CoreData+Status.swift | 4 +-- .../API/Mastodon+API+Statuses.swift | 5 ++- 16 files changed, 126 insertions(+), 19 deletions(-) diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 5ed4021a7..eb095669a 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="UTF-8" standalone="yes"?> -<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D75" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> +<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <entity name="Application" representedClassName=".Application" syncable="YES"> <attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/> <attribute name="name" attributeType="String"/> @@ -115,6 +115,7 @@ <attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/> <attribute name="id" attributeType="String"/> <attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/> + <attribute name="index" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="url" attributeType="String"/> <attribute name="username" attributeType="String"/> <relationship name="status" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="mentions" inverseEntity="Status"/> @@ -209,7 +210,7 @@ <element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/> <element name="MastodonAuthentication" positionX="0" positionY="0" width="128" height="209"/> <element name="MastodonUser" positionX="0" positionY="0" width="128" height="659"/> - <element name="Mention" positionX="0" positionY="0" width="128" height="134"/> + <element name="Mention" positionX="0" positionY="0" width="128" height="149"/> <element name="Poll" positionX="0" positionY="0" width="128" height="194"/> <element name="PollOption" positionX="0" positionY="0" width="128" height="134"/> <element name="PrivateNote" positionX="0" positionY="0" width="128" height="89"/> @@ -217,4 +218,4 @@ <element name="Status" positionX="0" positionY="0" width="128" height="569"/> <element name="Tag" positionX="0" positionY="0" width="128" height="134"/> </elements> -</model> +</model> \ No newline at end of file diff --git a/CoreDataStack/Entity/Mention.swift b/CoreDataStack/Entity/Mention.swift index 9559ea5d5..864ca4948 100644 --- a/CoreDataStack/Entity/Mention.swift +++ b/CoreDataStack/Entity/Mention.swift @@ -10,6 +10,9 @@ import Foundation public final class Mention: NSManagedObject { public typealias ID = UUID + + @NSManaged public private(set) var index: NSNumber + @NSManaged public private(set) var identifier: ID @NSManaged public private(set) var id: String @NSManaged public private(set) var createAt: Date @@ -32,9 +35,11 @@ public extension Mention { @discardableResult static func insert( into context: NSManagedObjectContext, - property: Property + property: Property, + index: Int ) -> Mention { let mention: Mention = context.insertObject() + mention.index = NSNumber(value: index) mention.id = property.id mention.username = property.username mention.acct = property.acct diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index c8a8bc180..4b0b5aa57 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -57,7 +57,19 @@ extension ComposeStatusSection { return } let status = replyTo.reblog ?? replyTo + + // set avatar cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL())) + // set name username + cell.statusView.nameLabel.text = { + let author = status.author + return author.displayName.isEmpty ? author.username : author.displayName + }() + cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct + // set text + cell.statusView.activeTextLabel.configure(content: status.content) + // set date + cell.statusView.dateLabel.text = status.createdAt.shortTimeAgoSinceNow } return cell case .input(let replyToStatusObjectID, let attribute): diff --git a/Mastodon/Helper/MastodonField.swift b/Mastodon/Helper/MastodonField.swift index e828602e4..5f652b32c 100644 --- a/Mastodon/Helper/MastodonField.swift +++ b/Mastodon/Helper/MastodonField.swift @@ -11,7 +11,7 @@ import ActiveLabel enum MastodonField { static func parse(field string: String) -> ParseResult { - let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.]+)?)") + let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?)") let hashtagMatches = string.matches(pattern: "(?:#([^\\s.]+))") let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)") diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index 2983a6f96..25322e216 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -33,6 +33,10 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { // MARK: - ActionToolbarContainerDelegate extension StatusTableViewCellDelegate where Self: StatusProvider { + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, replyButtonDidPressed sender: UIButton) { + StatusProviderFacade.responseToStatusReplyAction(provider: self, cell: cell) + } + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) { StatusProviderFacade.responseToStatusReblogAction(provider: self, cell: cell) } diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 6db861ec6..0e26614c5 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -277,7 +277,6 @@ extension StatusProviderFacade { } extension StatusProviderFacade { - static func responseToStatusReblogAction(provider: StatusProvider) { _responseToStatusReblogAction( @@ -385,6 +384,37 @@ extension StatusProviderFacade { } +extension StatusProviderFacade { + + static func responseToStatusReplyAction(provider: StatusProvider) { + _responseToStatusReplyAction( + provider: provider, + status: provider.status() + ) + } + + static func responseToStatusReplyAction(provider: StatusProvider, cell: UITableViewCell) { + _responseToStatusReplyAction( + provider: provider, + status: provider.status(for: cell, indexPath: nil) + ) + } + + private static func _responseToStatusReplyAction(provider: StatusProvider, status: Future<Status?, Never>) { + status + .sink { [weak provider] status in + guard let provider = provider else { return } + guard let status = status?.reblog ?? status else { return } + + let composeViewModel = ComposeViewModel(context: provider.context, composeKind: .reply(repliedToStatusObjectID: status.objectID)) + provider.coordinator.present(scene: .compose(viewModel: composeViewModel), from: provider, transition: .modal(animated: true, completion: nil)) + } + .store(in: &provider.context.disposeBag) + + } + +} + extension StatusProviderFacade { enum Target { case primary // original status diff --git a/Mastodon/Protocol/StatusTableViewControllerAspect.swift b/Mastodon/Protocol/StatusTableViewControllerAspect.swift index 77b1e17ba..f96998ea6 100644 --- a/Mastodon/Protocol/StatusTableViewControllerAspect.swift +++ b/Mastodon/Protocol/StatusTableViewControllerAspect.swift @@ -10,7 +10,7 @@ import AVKit // Check List Last Updated // - HomeViewController: 2021/4/13 -// - FavoriteViewController: 2021/4/8 +// - FavoriteViewController: 2021/4/14 // - HashtagTimelineViewController: 2021/4/8 // - UserTimelineViewController: 2021/4/13 // - ThreadViewController: 2021/4/13 diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index e68be7295..29b8850b9 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -548,7 +548,7 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update: %s", ((#file as NSString).lastPathComponent), #line, #function, string) let stringRange = NSRange(location: 0, length: string.length) - let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.]+)?|#([^\\s.]+))") + let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))") // accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect // precondition :\B with following space let emojiMatches = string.matches(pattern: "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))") diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift index c3e903812..fd3f5bce0 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift @@ -8,6 +8,7 @@ import os.log import Foundation import Combine +import CoreDataStack import GameplayKit import MastodonSDK @@ -64,6 +65,15 @@ extension ComposeViewModel.PublishState { guard viewModel.isPollComposing.value else { return nil } return viewModel.pollExpiresOptionAttribute.expiresOption.value.seconds }() + let inReplyToID: Mastodon.Entity.Status.ID? = { + guard case let .reply(repliedToStatusObjectID) = viewModel.composeKind else { return nil } + var id: Mastodon.Entity.Status.ID? + viewModel.context.managedObjectContext.performAndWait { + guard let replyTo = viewModel.context.managedObjectContext.object(with: repliedToStatusObjectID) as? Status else { return } + id = replyTo.id + } + return id + }() let sensitive: Bool = viewModel.isContentWarningComposing.value let spoilerText: String? = { let text = viewModel.composeStatusAttribute.contentWarningContent.value.trimmingCharacters(in: .whitespacesAndNewlines) @@ -105,6 +115,7 @@ extension ComposeViewModel.PublishState { mediaIDs: mediaIDs.isEmpty ? nil : mediaIDs, pollOptions: pollOptions, pollExpiresIn: pollExpiresIn, + inReplyToID: inReplyToID, sensitive: sensitive, spoilerText: spoilerText, visibility: visibility diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 1043d8bec..56efd2529 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -87,7 +87,36 @@ final class ComposeViewModel { self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) // end init - if case let .hashtag(text) = composeKind { + if case let .reply(repliedToStatusObjectID) = composeKind { + context.managedObjectContext.performAndWait { + guard let status = context.managedObjectContext.object(with: repliedToStatusObjectID) as? Status else { return } + let composeAuthor: MastodonUser? = { + guard let objectID = self.activeAuthentication.value?.user.objectID else { return nil } + guard let author = context.managedObjectContext.object(with: objectID) as? MastodonUser else { return nil } + return author + }() + + var mentionAccts: [String] = [] + if composeAuthor?.id != status.author.id { + mentionAccts.append("@" + status.author.acct) + } + let mentions = (status.mentions ?? Set()) + .sorted(by: { $0.index.intValue < $1.index.intValue }) + .filter { $0.id != composeAuthor?.id } + for mention in mentions { + mentionAccts.append("@" + mention.acct) + } + for acct in mentionAccts { + UITextChecker.learnWord(acct) + } + + let initialComposeContent = mentionAccts.joined(separator: " ") + let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " " + self.preInsertedContent = preInsertedContent + self.composeStatusAttribute.composeContent.value = preInsertedContent + } + + } else if case let .hashtag(text) = composeKind { let initialComposeContent = "#" + text UITextChecker.learnWord(initialComposeContent) let preInsertedContent = initialComposeContent + " " diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index c9bf87410..4b45d6638 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -218,6 +218,10 @@ extension HashtagTimelineViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: indexPath) + } } // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift index a175ae348..8205d5a2e 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift @@ -114,6 +114,10 @@ extension FavoriteViewController: UITableViewDelegate { aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) } + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: indexPath) + } + } // MARK: - UITableViewDataSourcePrefetching diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 39916741e..10fcdca3c 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -32,6 +32,7 @@ protocol StatusTableViewCellDelegate: class { func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusTableViewCell(_ cell: StatusTableViewCell, playerViewControllerDidPressed playerViewController: AVPlayerViewController) + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, replyButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) @@ -302,19 +303,21 @@ extension StatusTableViewCell: MosaicImageViewContainerDelegate { // MARK: - ActionToolbarContainerDelegate extension StatusTableViewCell: ActionToolbarContainerDelegate { + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) { - + delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, replyButtonDidPressed: sender) } + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) { delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, reblogButtonDidPressed: sender) } + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, starButtonDidPressed sender: UIButton) { delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, likeButtonDidPressed: sender) } - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, bookmarkButtonDidPressed sender: UIButton) { - - } + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, moreButtonDidPressed sender: UIButton) { } + } diff --git a/Mastodon/Scene/Thread/ThreadViewController.swift b/Mastodon/Scene/Thread/ThreadViewController.swift index 43c40025e..db7c76a75 100644 --- a/Mastodon/Scene/Thread/ThreadViewController.swift +++ b/Mastodon/Scene/Thread/ThreadViewController.swift @@ -88,8 +88,6 @@ extension ThreadViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - // force readable layout frame update - tableView.reloadData() aspectViewWillAppear(animated) } @@ -104,7 +102,10 @@ extension ThreadViewController { extension ThreadViewController { @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - + guard let rootItem = viewModel.rootItem.value, + case let .root(statusObjectID, _) = rootItem else { return } + let composeViewModel = ComposeViewModel(context: context, composeKind: .reply(repliedToStatusObjectID: statusObjectID)) + coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift index a05574b6b..328fa2305 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift @@ -86,8 +86,8 @@ extension APIService.CoreData { let object = Poll.insert(into: managedObjectContext, property: Poll.Property(id: poll.id, expiresAt: poll.expiresAt, expired: poll.expired, multiple: poll.multiple, votesCount: poll.votesCount, votersCount: poll.votersCount, networkDate: networkDate), votedBy: votedBy, options: options) return object } - let metions = entity.mentions?.compactMap { mention -> Mention in - Mention.insert(into: managedObjectContext, property: Mention.Property(id: mention.id, username: mention.username, acct: mention.acct, url: mention.url)) + let metions = entity.mentions?.enumerated().compactMap { index, mention -> Mention in + Mention.insert(into: managedObjectContext, property: Mention.Property(id: mention.id, username: mention.username, acct: mention.acct, url: mention.url), index: index) } let emojis = entity.emojis?.compactMap { emoji -> Emoji in Emoji.insert(into: managedObjectContext, property: Emoji.Property(shortcode: emoji.shortcode, url: emoji.url, staticURL: emoji.staticURL, visibleInPicker: emoji.visibleInPicker, category: emoji.category)) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift index ae5d5e670..bb5a4abfc 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift @@ -98,6 +98,7 @@ extension Mastodon.API.Statuses { public let mediaIDs: [String]? public let pollOptions: [String]? public let pollExpiresIn: Int? + public let inReplyToID: Mastodon.Entity.Status.ID? public let sensitive: Bool? public let spoilerText: String? public let visibility: Mastodon.Entity.Status.Visibility? @@ -107,6 +108,7 @@ extension Mastodon.API.Statuses { mediaIDs: [String]?, pollOptions: [String]?, pollExpiresIn: Int?, + inReplyToID: Mastodon.Entity.Status.ID?, sensitive: Bool?, spoilerText: String?, visibility: Mastodon.Entity.Status.Visibility? @@ -115,10 +117,10 @@ extension Mastodon.API.Statuses { self.mediaIDs = mediaIDs self.pollOptions = pollOptions self.pollExpiresIn = pollExpiresIn + self.inReplyToID = inReplyToID self.sensitive = sensitive self.spoilerText = spoilerText self.visibility = visibility - } var contentType: String? { @@ -136,6 +138,7 @@ extension Mastodon.API.Statuses { data.append(Data.multipart(key: "poll[options][]", value: pollOption)) } pollExpiresIn.flatMap { data.append(Data.multipart(key: "poll[expires_in]", value: $0)) } + inReplyToID.flatMap { data.append(Data.multipart(key: "in_reply_to_id", value: $0)) } sensitive.flatMap { data.append(Data.multipart(key: "sensitive", value: $0)) } spoilerText.flatMap { data.append(Data.multipart(key: "spoiler_text", value: $0)) } visibility.flatMap { data.append(Data.multipart(key: "visibility", value: $0.rawValue)) }