Merge pull request #691 from mastodon/follow_hashtags

[Feature] Follow hashtags
This commit is contained in:
Nathan Mattes 2022-12-01 20:33:06 +01:00 committed by GitHub
commit 56efe8a93a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1575 additions and 14 deletions

View File

@ -722,6 +722,19 @@
},
"bookmark": {
"title": "Bookmarks"
},
"followed_tags": {
"title": "Followed Tags",
"header": {
"posts": "posts",
"participants": "participants",
"posts_today": "posts today"
},
"actions": {
"follow": "Follow",
"unfollow": "Unfollow"
}
}
}
}

View File

@ -23,6 +23,13 @@
0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33725E6401400AAD544 /* PickServerCell.swift */; };
164F0EBC267D4FE400249499 /* BoopSound.caf in Resources */ = {isa = PBXBuildFile; fileRef = 164F0EBB267D4FE400249499 /* BoopSound.caf */; };
18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; };
2A1FE47C2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1FE47B2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift */; };
2A1FE47E2938C11200784BF1 /* Collection+IsNotEmpty.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1FE47D2938C11200784BF1 /* Collection+IsNotEmpty.swift */; };
2A3F6FE3292ECB5E002E6DA7 /* FollowedTagsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */; };
2A3F6FE5292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */; };
2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */; };
2A506CF6292D040100059C37 /* HashtagTimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */; };
2A76F75C2930D94700B3388D /* HashtagTimelineHeaderViewActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A76F75B2930D94700B3388D /* HashtagTimelineHeaderViewActionButton.swift */; };
2A82294F29262EE000D2A1F7 /* AppContext+NextAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A82294E29262EE000D2A1F7 /* AppContext+NextAccount.swift */; };
2AE244482927831100BDBF7C /* UIImage+SFSymbols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */; };
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; };
@ -520,6 +527,13 @@
0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = "<group>"; };
164F0EBB267D4FE400249499 /* BoopSound.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = BoopSound.caf; sourceTree = "<group>"; };
1D6D967E77A5357E2C6110D9 /* Pods-Mastodon.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.asdk - debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.asdk - debug.xcconfig"; sourceTree = "<group>"; };
2A1FE47B2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowedTagsViewModel+DiffableDataSource.swift"; sourceTree = "<group>"; };
2A1FE47D2938C11200784BF1 /* Collection+IsNotEmpty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+IsNotEmpty.swift"; sourceTree = "<group>"; };
2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewModel.swift; sourceTree = "<group>"; };
2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsTableViewCell.swift; sourceTree = "<group>"; };
2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewController.swift; sourceTree = "<group>"; };
2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineHeaderView.swift; sourceTree = "<group>"; };
2A76F75B2930D94700B3388D /* HashtagTimelineHeaderViewActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineHeaderViewActionButton.swift; sourceTree = "<group>"; };
2A82294E29262EE000D2A1F7 /* AppContext+NextAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppContext+NextAccount.swift"; sourceTree = "<group>"; };
2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+SFSymbols.swift"; sourceTree = "<group>"; };
2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = "<group>"; };
@ -1124,6 +1138,8 @@
0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */,
0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */,
0F20222C261457EE000C64BF /* HashtagTimelineViewModel+State.swift */,
2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */,
2A76F75B2930D94700B3388D /* HashtagTimelineHeaderViewActionButton.swift */,
);
path = HashtagTimeline;
sourceTree = "<group>";
@ -1226,6 +1242,17 @@
path = Pods;
sourceTree = "<group>";
};
2A506CF2292CD83B00059C37 /* FollowedTags */ = {
isa = PBXGroup;
children = (
2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */,
2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */,
2A1FE47B2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift */,
2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */,
);
path = FollowedTags;
sourceTree = "<group>";
};
2D152A8A25C295B8009AA50C /* Content */ = {
isa = PBXGroup;
children = (
@ -2250,6 +2277,7 @@
DBCC3B2F261440A50045B23D /* UITabBarController.swift */,
DB73BF4827140BA300781945 /* UICollectionViewDiffableDataSource.swift */,
DB73BF4A27140C0800781945 /* UITableViewDiffableDataSource.swift */,
2A1FE47D2938C11200784BF1 /* Collection+IsNotEmpty.swift */,
);
path = Extension;
sourceTree = "<group>";
@ -2369,6 +2397,7 @@
DB9D6C0825E4F5A60051B173 /* Profile */ = {
isa = PBXGroup;
children = (
2A506CF2292CD83B00059C37 /* FollowedTags */,
62047EBE28874C8F00A3BA5D /* Bookmark */,
DBB525462611ED57002F1F29 /* Header */,
DBB525262611EBDA002F1F29 /* Paging */,
@ -3290,17 +3319,20 @@
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
DB025B78278D606A002F581E /* StatusItem.swift in Sources */,
DB697DD4278F4927004EF2F7 /* StatusTableViewCellDelegate.swift in Sources */,
2A506CF6292D040100059C37 /* HashtagTimelineHeaderView.swift in Sources */,
DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */,
DB3E6FF52807C40300B035AE /* DiscoveryForYouViewController.swift in Sources */,
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,
DB0FCB842796B2A2006C02E2 /* FavoriteViewController+DataSourceProvider.swift in Sources */,
DB0FCB68279507EF006C02E2 /* DataSourceFacade+Meta.swift in Sources */,
2A1FE47E2938C11200784BF1 /* Collection+IsNotEmpty.swift in Sources */,
2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */,
6213AF5A28939C8400BCADB6 /* BookmarkViewModel.swift in Sources */,
5B24BBDB262DB14800A9381B /* ReportStatusViewModel+Diffable.swift in Sources */,
DB4F0968269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift in Sources */,
0FB3D2FE25E4CB6400AAD544 /* OnboardingHeadlineTableViewCell.swift in Sources */,
5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */,
2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */,
DB1D843026566512000346B3 /* KeyboardPreference.swift in Sources */,
DB852D1926FAEB6B00FC9D81 /* SidebarViewController.swift in Sources */,
2D206B9225F60EA700143C56 /* UIControl.swift in Sources */,
@ -3373,6 +3405,7 @@
DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */,
DB6988DE2848D11C002398EF /* PagerTabStripNavigateable.swift in Sources */,
2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */,
2A1FE47C2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift in Sources */,
DB852D1C26FB021500FC9D81 /* RootSplitViewController.swift in Sources */,
DB697DD1278F4871004EF2F7 /* AutoGenerateTableViewDelegate.swift in Sources */,
DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */,
@ -3464,6 +3497,7 @@
DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */,
DB3E6FE42806A5B800B035AE /* DiscoverySection.swift in Sources */,
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */,
2A76F75C2930D94700B3388D /* HashtagTimelineHeaderViewActionButton.swift in Sources */,
DB697DDB278F4DE3004EF2F7 /* DataSourceProvider+StatusTableViewCellDelegate.swift in Sources */,
DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */,
DBB45B5627B39FC9002DC5A7 /* MediaPreviewVideoViewController.swift in Sources */,
@ -3472,6 +3506,7 @@
DB67D08627312E67006A36CF /* WizardViewController.swift in Sources */,
DB6746EB278ED8B0008A6B94 /* PollOptionView+Configuration.swift in Sources */,
DB0F9D54283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift in Sources */,
2A3F6FE3292ECB5E002E6DA7 /* FollowedTagsViewModel.swift in Sources */,
DBFEEC99279BDCDE004F81DD /* ProfileAboutViewModel.swift in Sources */,
2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */,
DB4F097B26A039FF00D62E92 /* SearchHistorySection.swift in Sources */,
@ -3502,6 +3537,7 @@
5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */,
DBEFCD82282A2AB100C0ABEA /* ReportServerRulesView.swift in Sources */,
DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */,
2A3F6FE5292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift in Sources */,
C24C97032922F30500BAE8CB /* RefreshControl.swift in Sources */,
DB023D2A27A0FE5C005AC798 /* DataSourceProvider+NotificationTableViewCellDelegate.swift in Sources */,
DB98EB6027B10E150082E365 /* ReportCommentTableViewCell.swift in Sources */,

View File

@ -173,6 +173,7 @@ extension SceneCoordinator {
case rebloggedBy(viewModel: UserListViewModel)
case favoritedBy(viewModel: UserListViewModel)
case bookmark(viewModel: BookmarkViewModel)
case followedTags(viewModel: FollowedTagsViewModel)
// setting
case settings(viewModel: SettingsViewModel)
@ -448,6 +449,10 @@ private extension SceneCoordinator {
let _viewController = BookmarkViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .followedTags(let viewModel):
let _viewController = FollowedTagsViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .favorite(let viewModel):
let _viewController = FavoriteViewController()
_viewController.viewModel = viewModel

View File

@ -0,0 +1,14 @@
//
// Array+IsNotEmpty.swift
// Mastodon
//
// Created by Marcus Kida on 01.12.22.
//
import Foundation
extension Collection {
var isNotEmpty: Bool {
!isEmpty
}
}

View File

@ -0,0 +1,173 @@
//
// HashtagTimelineHeaderView.swift
// Mastodon
//
// Created by Marcus Kida on 22.11.22.
//
import UIKit
import CoreDataStack
import MastodonSDK
import MastodonUI
import MastodonAsset
import MastodonLocalization
fileprivate extension CGFloat {
static let padding: CGFloat = 16
static let descriptionLabelSpacing: CGFloat = 12
}
final class HashtagTimelineHeaderView: UIView {
struct Data {
let name: String
let following: Bool
let postCount: Int
let participantsCount: Int
let postsTodayCount: Int
static func from(_ entity: Mastodon.Entity.Tag) -> Self {
Data(
name: entity.name,
following: entity.following == true,
postCount: (entity.history ?? []).reduce(0) { res, acc in
res + (Int(acc.uses) ?? 0)
},
participantsCount: (entity.history ?? []).reduce(0) { res, acc in
res + (Int(acc.accounts) ?? 0)
},
postsTodayCount: Int(entity.history?.first?.uses ?? "0") ?? 0
)
}
static func from(_ entity: Tag) -> Self {
Data(
name: entity.name,
following: entity.following,
postCount: entity.histories.reduce(0) { res, acc in
res + (Int(acc.uses) ?? 0)
},
participantsCount: entity.histories.reduce(0) { res, acc in
res + (Int(acc.accounts) ?? 0)
},
postsTodayCount: Int(entity.histories.first?.uses ?? "0") ?? 0
)
}
}
let titleLabel = UILabel()
let postCountLabel = UILabel()
let participantsLabel = UILabel()
let postsTodayLabel = UILabel()
let postCountDescLabel = UILabel()
let participantsDescLabel = UILabel()
let postsTodayDescLabel = UILabel()
private var widthConstraint: NSLayoutConstraint!
var onButtonTapped: (() -> Void)?
let followButton: UIButton = {
let button = HashtagTimelineHeaderViewActionButton()
button.cornerRadius = 10
button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold)
return button
}()
init() {
super.init(frame: .zero)
setupLayout()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private extension HashtagTimelineHeaderView {
func setupLayout() {
[titleLabel, postCountLabel, participantsLabel, postsTodayLabel, postCountDescLabel, participantsDescLabel, postsTodayDescLabel, followButton].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
addSubview($0)
}
// hashtag name / title
titleLabel.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: .systemFont(ofSize: 22, weight: .bold))
[postCountLabel, participantsLabel, postsTodayLabel].forEach {
$0.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: .systemFont(ofSize: 20, weight: .bold))
$0.text = "999"
}
[postCountDescLabel, participantsDescLabel, postsTodayDescLabel].forEach {
$0.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .regular))
}
postCountDescLabel.text = L10n.Scene.FollowedTags.Header.posts
participantsDescLabel.text = L10n.Scene.FollowedTags.Header.participants
postsTodayDescLabel.text = L10n.Scene.FollowedTags.Header.postsToday
followButton.addAction(UIAction(handler: { [weak self] _ in
self?.onButtonTapped?()
}), for: .touchUpInside)
widthConstraint = widthAnchor.constraint(greaterThanOrEqualToConstant: 0)
NSLayoutConstraint.activate([
widthConstraint,
titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: .padding),
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: .padding),
titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -CGFloat.padding),
postCountLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: .padding),
postCountLabel.centerXAnchor.constraint(equalTo: postCountDescLabel.centerXAnchor),
postCountDescLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
participantsDescLabel.leadingAnchor.constraint(equalTo: postCountDescLabel.trailingAnchor, constant: .descriptionLabelSpacing),
participantsLabel.centerXAnchor.constraint(equalTo: participantsDescLabel.centerXAnchor),
participantsLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: .padding),
postsTodayDescLabel.leadingAnchor.constraint(equalTo: participantsDescLabel.trailingAnchor, constant: .descriptionLabelSpacing),
postsTodayLabel.centerXAnchor.constraint(equalTo: postsTodayDescLabel.centerXAnchor),
postsTodayLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: .padding),
postCountDescLabel.topAnchor.constraint(equalTo: postCountLabel.bottomAnchor),
participantsDescLabel.topAnchor.constraint(equalTo: participantsLabel.bottomAnchor),
postsTodayDescLabel.topAnchor.constraint(equalTo: postsTodayLabel.bottomAnchor),
postCountDescLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -CGFloat.padding),
participantsDescLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -CGFloat.padding),
postsTodayDescLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -CGFloat.padding),
followButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -CGFloat.padding),
followButton.bottomAnchor.constraint(equalTo: postsTodayDescLabel.bottomAnchor),
followButton.topAnchor.constraint(equalTo: postsTodayLabel.topAnchor)
])
}
}
extension HashtagTimelineHeaderView {
func update(_ entity: HashtagTimelineHeaderView.Data) {
titleLabel.text = "#\(entity.name)"
followButton.setTitle(entity.following == true ? L10n.Scene.FollowedTags.Actions.unfollow : L10n.Scene.FollowedTags.Actions.follow, for: .normal)
followButton.backgroundColor = entity.following == true ? Asset.Colors.Button.tagUnfollow.color : Asset.Colors.Button.tagFollow.color
followButton.setTitleColor(
entity.following == true ? Asset.Colors.Button.tagFollow.color : Asset.Colors.Button.tagUnfollow.color,
for: .normal
)
postCountLabel.text = String(entity.postCount)
participantsLabel.text = String(entity.participantsCount)
postsTodayLabel.text = String(entity.postsTodayCount)
}
func updateWidthConstraint(_ constant: CGFloat) {
widthConstraint.constant = constant
}
}

View File

@ -0,0 +1,47 @@
//
// HashtagTimelineHeaderViewActionButton.swift
// Mastodon
//
// Created by Marcus Kida on 25.11.22.
//
import UIKit
import MastodonUI
import MastodonAsset
class HashtagTimelineHeaderViewActionButton: RoundedEdgesButton {
init() {
super.init(frame: .zero)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func layoutSubviews() {
super.layoutSubviews()
let shadowColor: UIColor = {
switch traitCollection.userInterfaceStyle {
case .dark:
return .darkGray
default:
return .lightGray
}
}()
layer.setupShadow(
color: shadowColor,
alpha: 1,
x: 0,
y: 1,
blur: 2,
spread: 0,
roundedRect: bounds,
byRoundingCorners: .allCorners,
cornerRadii: CGSize(width: cornerRadius, height: cornerRadius)
)
}
}

View File

@ -15,6 +15,7 @@ import MastodonAsset
import MastodonCore
import MastodonUI
import MastodonLocalization
import MastodonSDK
final class HashtagTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
@ -27,6 +28,17 @@ final class HashtagTimelineViewController: UIViewController, NeedsDependency, Me
var disposeBag = Set<AnyCancellable>()
var viewModel: HashtagTimelineViewModel!
private lazy var headerView: HashtagTimelineHeaderView = {
let headerView = HashtagTimelineHeaderView()
headerView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
headerView.heightAnchor.constraint(equalToConstant: 118),
])
return headerView
}()
let composeBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem()
@ -114,11 +126,20 @@ extension HashtagTimelineViewController {
self?.updatePromptTitle()
}
.store(in: &disposeBag)
viewModel.hashtagDetails
.receive(on: DispatchQueue.main)
.sink { [weak self] tag in
guard let tag = tag else { return }
self?.updateHeaderView(with: tag)
}
.store(in: &disposeBag)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.viewWillAppear()
tableView.deselectRow(with: transitionCoordinator, animated: animated)
}
@ -148,7 +169,30 @@ extension HashtagTimelineViewController {
subtitle = L10n.Plural.peopleTalking(peopleTalkingNumber)
}
}
}
extension HashtagTimelineViewController {
private func updateHeaderView(with tag: Mastodon.Entity.Tag) {
if tableView.tableHeaderView == nil {
tableView.tableHeaderView = headerView
}
headerView.update(HashtagTimelineHeaderView.Data.from(tag))
headerView.onButtonTapped = { [weak self] in
switch tag.following {
case .some(false):
self?.viewModel.followTag()
case .some(true):
self?.viewModel.unfollowTag()
default:
break
}
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
headerView.updateWidthConstraint(tableView.bounds.width)
}
}
extension HashtagTimelineViewController {

View File

@ -23,7 +23,7 @@ final class HashtagTimelineViewModel {
var disposeBag = Set<AnyCancellable>()
var needLoadMiddleIndex: Int? = nil
// input
let context: AppContext
let authContext: AuthContext
@ -32,10 +32,11 @@ final class HashtagTimelineViewModel {
let timelinePredicate = CurrentValueSubject<NSPredicate?, Never>(nil)
let hashtagEntity = CurrentValueSubject<Mastodon.Entity.Tag?, Never>(nil)
let listBatchFetchViewModel = ListBatchFetchViewModel()
// output
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>?
let didLoadLatest = PassthroughSubject<Void, Never>()
let hashtagDetails = CurrentValueSubject<Mastodon.Entity.Tag?, Never>(nil)
// bottom loader
private(set) lazy var stateMachine: GKStateMachine = {
@ -61,6 +62,7 @@ final class HashtagTimelineViewModel {
domain: authContext.mastodonAuthenticationBox.domain,
additionalTweetPredicate: nil
)
updateTagInformation()
// end init
}
@ -68,5 +70,55 @@ final class HashtagTimelineViewModel {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
}
func viewWillAppear() {
let predicate = Tag.predicate(
domain: authContext.mastodonAuthenticationBox.domain,
name: hashtag
)
guard
let object = Tag.findOrFetch(in: context.managedObjectContext, matching: predicate)
else {
return hashtagDetails.send(hashtagDetails.value?.copy(following: false))
}
hashtagDetails.send(hashtagDetails.value?.copy(following: object.following))
}
}
extension HashtagTimelineViewModel {
func followTag() {
self.hashtagDetails.send(hashtagDetails.value?.copy(following: true))
Task { @MainActor in
let tag = try? await context.apiService.followTag(
for: hashtag,
authenticationBox: authContext.mastodonAuthenticationBox
).value
self.hashtagDetails.send(tag)
}
}
func unfollowTag() {
self.hashtagDetails.send(hashtagDetails.value?.copy(following: false))
Task { @MainActor in
let tag = try? await context.apiService.unfollowTag(
for: hashtag,
authenticationBox: authContext.mastodonAuthenticationBox
).value
self.hashtagDetails.send(tag)
}
}
}
private extension HashtagTimelineViewModel {
func updateTagInformation() {
Task { @MainActor in
let tag = try? await context.apiService.getTagInformation(
for: hashtag,
authenticationBox: authContext.mastodonAuthenticationBox
).value
self.hashtagDetails.send(tag)
}
}
}

View File

@ -0,0 +1,78 @@
//
// FollowedTagsTableViewCell.swift
// Mastodon
//
// Created by Marcus Kida on 24.11.22.
//
import UIKit
import CoreDataStack
final class FollowedTagsTableViewCell: UITableViewCell {
private var hashtagView: HashtagTimelineHeaderView!
private let separatorLine = UIView.separatorLine
private weak var viewModel: FollowedTagsViewModel?
private weak var hashtag: Tag?
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setup()
}
required init?(coder: NSCoder) {
fatalError("Not implemented")
}
override func prepareForReuse() {
hashtagView.removeFromSuperview()
viewModel = nil
hashtagView = nil
super.prepareForReuse()
setup()
}
}
private extension FollowedTagsTableViewCell {
func setup() {
selectionStyle = .none
hashtagView = HashtagTimelineHeaderView()
hashtagView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(hashtagView)
contentView.backgroundColor = .clear
NSLayoutConstraint.activate([
hashtagView.heightAnchor.constraint(equalToConstant: 118).priority(.required),
hashtagView.topAnchor.constraint(equalTo: contentView.topAnchor),
hashtagView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
hashtagView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
hashtagView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
separatorLine.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(separatorLine)
NSLayoutConstraint.activate([
separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)),
])
hashtagView.onButtonTapped = { [weak self] in
guard let self = self, let tag = self.hashtag else { return }
self.viewModel?.followOrUnfollow(tag)
}
}
}
extension FollowedTagsTableViewCell {
func populate(with tag: Tag) {
self.hashtag = tag
hashtagView.update(HashtagTimelineHeaderView.Data.from(tag))
}
func setup(_ viewModel: FollowedTagsViewModel) {
self.viewModel = viewModel
}
}

View File

@ -0,0 +1,77 @@
//
// FollowedTagsViewController.swift
// Mastodon
//
// Created by Marcus Kida on 22.11.22.
//
import os
import UIKit
import Combine
import MastodonAsset
import MastodonCore
import MastodonUI
import MastodonLocalization
final class FollowedTagsViewController: UIViewController, NeedsDependency {
let logger = Logger(subsystem: String(describing: FollowedTagsViewController.self), category: "ViewController")
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
var viewModel: FollowedTagsViewModel!
let titleView = DoubleTitleLabelNavigationBarTitleView()
lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.register(FollowedTagsTableViewCell.self, forCellReuseIdentifier: String(describing: FollowedTagsTableViewCell.self))
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .none
tableView.backgroundColor = .clear
return tableView
}()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension FollowedTagsViewController {
override func viewDidLoad() {
super.viewDidLoad()
let _title = L10n.Scene.FollowedTags.title
title = _title
titleView.update(title: _title, subtitle: nil)
navigationItem.titleView = titleView
view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor
ThemeService.shared.currentTheme
.receive(on: RunLoop.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.view.backgroundColor = theme.secondarySystemBackgroundColor
}
.store(in: &disposeBag)
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
tableView.pinToParent()
viewModel.setupTableView(tableView)
viewModel.presentHashtagTimeline
.receive(on: DispatchQueue.main)
.sink { [weak self] hashtagTimelineViewModel in
guard let self = self else { return }
_ = self.coordinator.present(
scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel),
from: self,
transition: .show
)
}
.store(in: &disposeBag)
}
}

View File

@ -0,0 +1,51 @@
//
// FollowedTagsViewModel+DiffableDataSource.swift
// Mastodon
//
// Created by Marcus Kida on 01.12.22.
//
import UIKit
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
import MastodonCore
extension FollowedTagsViewModel {
enum Section: Hashable {
case main
}
enum Item: Hashable {
case hashtag(Tag)
}
func tableViewDiffableDataSource(
for tableView: UITableView
) -> UITableViewDiffableDataSource<Section, Item> {
UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in
switch item {
case let .hashtag(tag):
guard let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: FollowedTagsTableViewCell.self), for: indexPath) as? FollowedTagsTableViewCell else {
assertionFailure()
return UITableViewCell()
}
cell.setup(self)
cell.populate(with: tag)
return cell
}
}
}
func setupDiffableDataSource(
tableView: UITableView
) {
diffableDataSource = tableViewDiffableDataSource(for: tableView)
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
diffableDataSource?.apply(snapshot)
}
}

View File

@ -0,0 +1,109 @@
//
// FollowedTagsViewModel.swift
// Mastodon
//
// Created by Marcus Kida on 23.11.22.
//
import os
import UIKit
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
import MastodonCore
final class FollowedTagsViewModel: NSObject {
let logger = Logger(subsystem: String(describing: FollowedTagsViewModel.self), category: "ViewModel")
var disposeBag = Set<AnyCancellable>()
let fetchedResultsController: FollowedTagsFetchedResultController
private weak var tableView: UITableView?
var diffableDataSource: UITableViewDiffableDataSource<Section, Item>?
// input
let context: AppContext
let authContext: AuthContext
// output
let presentHashtagTimeline = PassthroughSubject<HashtagTimelineViewModel, Never>()
init(context: AppContext, authContext: AuthContext) {
self.context = context
self.authContext = authContext
self.fetchedResultsController = FollowedTagsFetchedResultController(
managedObjectContext: context.managedObjectContext,
domain: authContext.mastodonAuthenticationBox.domain,
user: authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)!.user
)
super.init()
self.fetchedResultsController
.$records
.receive(on: DispatchQueue.main)
.sink { [weak self] records in
guard let self = self else { return }
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
snapshot.appendItems(records.map {.hashtag($0) })
self.diffableDataSource?.applySnapshot(snapshot, animated: true)
}
.store(in: &disposeBag)
}
}
extension FollowedTagsViewModel {
func setupTableView(_ tableView: UITableView) {
self.tableView = tableView
setupDiffableDataSource(tableView: tableView)
tableView.delegate = self
fetchFollowedTags()
}
func fetchFollowedTags() {
Task { @MainActor in
try await context.apiService.getFollowedTags(
domain: authContext.mastodonAuthenticationBox.domain,
query: Mastodon.API.Account.FollowedTagsQuery(limit: nil),
authenticationBox: authContext.mastodonAuthenticationBox
)
}
}
func followOrUnfollow(_ tag: Tag) {
Task { @MainActor in
switch tag.following {
case true:
_ = try? await context.apiService.unfollowTag(
for: tag.name,
authenticationBox: authContext.mastodonAuthenticationBox
)
case false:
_ = try? await context.apiService.followTag(
for: tag.name,
authenticationBox: authContext.mastodonAuthenticationBox
)
}
fetchFollowedTags()
}
}
}
extension FollowedTagsViewModel: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(indexPath)")
tableView.deselectRow(at: indexPath, animated: true)
let object = fetchedResultsController.records[indexPath.row]
let hashtagTimelineViewModel = HashtagTimelineViewModel(
context: self.context,
authContext: self.authContext,
hashtag: object.name
)
presentHashtagTimeline.send(hashtagTimelineViewModel)
}
}

View File

@ -104,6 +104,13 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi
barButtonItem.accessibilityLabel = L10n.Common.Controls.Actions.seeMore
return barButtonItem
}()
private(set) lazy var followedTagsBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "number"), style: .plain, target: self, action: #selector(ProfileViewController.followedTagsItemPressed(_:)))
barButtonItem.tintColor = .white
barButtonItem.accessibilityLabel = L10n.Scene.FollowedTags.title
return barButtonItem
}()
let refreshControl: RefreshControl = {
let refreshControl = RefreshControl()
@ -243,6 +250,11 @@ extension ProfileViewController {
items.append(self.shareBarButtonItem)
items.append(self.favoriteBarButtonItem)
items.append(self.bookmarkBarButtonItem)
if self.currentInstance?.canFollowTags == true {
items.append(self.followedTagsBarButtonItem)
}
return
}
@ -545,6 +557,13 @@ extension ProfileViewController {
)
_ = coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil))
}
@objc private func followedTagsItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let followedTagsViewModel = FollowedTagsViewModel(context: context, authContext: viewModel.authContext)
_ = coordinator.present(scene: .followedTags(viewModel: followedTagsViewModel), from: self, transition: .show)
}
@objc private func refreshControlValueChanged(_ sender: RefreshControl) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
@ -914,3 +933,13 @@ extension ProfileViewController: PagerTabStripNavigateable {
}
private extension ProfileViewController {
var currentInstance: Instance? {
guard let authenticationRecord = authContext.mastodonAuthenticationBox
.authenticationRecord
.object(in: context.managedObjectContext)
else { return nil }
return authenticationRecord.instance
}
}

View File

@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>CoreData 4.xcdatamodel</string>
<string>CoreData 5.xcdatamodel</string>
</dict>
</plist>

View File

@ -0,0 +1,258 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21512" systemVersion="22A400" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Application" representedClassName="CoreDataStack.Application" syncable="YES">
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String"/>
<attribute name="vapidKey" optional="YES" attributeType="String"/>
<attribute name="website" optional="YES" attributeType="String"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="application" inverseEntity="Status"/>
</entity>
<entity name="DomainBlock" representedClassName="CoreDataStack.DomainBlock" syncable="YES">
<attribute name="blockedDomain" attributeType="String"/>
<attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="userID" attributeType="String"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="userID"/>
<constraint value="domain"/>
<constraint value="blockedDomain"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="Emoji" representedClassName="CoreDataStack.Emoji" syncable="YES">
<attribute name="category" optional="YES" attributeType="String"/>
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="shortcode" attributeType="String"/>
<attribute name="staticURL" attributeType="String"/>
<attribute name="url" attributeType="String"/>
<attribute name="visibleInPicker" attributeType="Boolean" usesScalarValueType="YES"/>
</entity>
<entity name="Feed" representedClassName="CoreDataStack.Feed" syncable="YES">
<attribute name="acctRaw" optional="YES" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="hasMore" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="isLoadingMore" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="kindRaw" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="notification" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Notification" inverseName="feeds" inverseEntity="Notification"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="feeds" inverseEntity="Status"/>
</entity>
<entity name="Instance" representedClassName="CoreDataStack.Instance" syncable="YES">
<attribute name="configurationRaw" optional="YES" attributeType="Binary"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="version" optional="YES" attributeType="String"/>
<relationship name="authentications" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonAuthentication" inverseName="instance" inverseEntity="MastodonAuthentication"/>
</entity>
<entity name="MastodonAuthentication" representedClassName="CoreDataStack.MastodonAuthentication" syncable="YES">
<attribute name="activedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="appAccessToken" attributeType="String"/>
<attribute name="clientID" attributeType="String"/>
<attribute name="clientSecret" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userAccessToken" attributeType="String"/>
<attribute name="userID" attributeType="String"/>
<attribute name="username" attributeType="String"/>
<relationship name="instance" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Instance" inverseName="authentications" inverseEntity="Instance"/>
<relationship name="user" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="mastodonAuthentication" inverseEntity="MastodonUser"/>
</entity>
<entity name="MastodonUser" representedClassName="CoreDataStack.MastodonUser" syncable="YES">
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" attributeType="String"/>
<attribute name="avatarStatic" optional="YES" attributeType="String"/>
<attribute name="bot" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="displayName" attributeType="String"/>
<attribute name="domain" attributeType="String"/>
<attribute name="emojis" optional="YES" attributeType="Binary"/>
<attribute name="fields" optional="YES" attributeType="Binary"/>
<attribute name="followersCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="followingCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="header" attributeType="String"/>
<attribute name="headerStatic" optional="YES" attributeType="String"/>
<attribute name="id" attributeType="String"/>
<attribute name="identifier" attributeType="String"/>
<attribute name="locked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="note" optional="YES" attributeType="String"/>
<attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="suspended" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="url" optional="YES" attributeType="String"/>
<attribute name="username" attributeType="String"/>
<relationship name="blocking" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="blockingBy" inverseEntity="MastodonUser"/>
<relationship name="blockingBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="blocking" inverseEntity="MastodonUser"/>
<relationship name="bookmarked" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="bookmarkedBy" inverseEntity="Status"/>
<relationship name="domainBlocking" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="domainBlockingBy" inverseEntity="MastodonUser"/>
<relationship name="domainBlockingBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="domainBlocking" inverseEntity="MastodonUser"/>
<relationship name="endorsed" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="endorsedBy" inverseEntity="MastodonUser"/>
<relationship name="endorsedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="endorsed" inverseEntity="MastodonUser"/>
<relationship name="favourite" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="favouritedBy" inverseEntity="Status"/>
<relationship name="followedTags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="followedBy" inverseEntity="Tag"/>
<relationship name="following" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followingBy" inverseEntity="MastodonUser"/>
<relationship name="followingBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="following" inverseEntity="MastodonUser"/>
<relationship name="followRequested" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followRequestedBy" inverseEntity="MastodonUser"/>
<relationship name="followRequestedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followRequested" inverseEntity="MastodonUser"/>
<relationship name="mastodonAuthentication" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonAuthentication" inverseName="user" inverseEntity="MastodonAuthentication"/>
<relationship name="muted" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="mutedBy" inverseEntity="Status"/>
<relationship name="muting" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="mutingBy" inverseEntity="MastodonUser"/>
<relationship name="mutingBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="muting" inverseEntity="MastodonUser"/>
<relationship name="notifications" toMany="YES" deletionRule="Nullify" destinationEntity="Notification" inverseName="account" inverseEntity="Notification"/>
<relationship name="pinnedStatus" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="pinnedBy" inverseEntity="Status"/>
<relationship name="privateNotes" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PrivateNote" inverseName="to" inverseEntity="PrivateNote"/>
<relationship name="privateNotesTo" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PrivateNote" inverseName="from" inverseEntity="PrivateNote"/>
<relationship name="reblogged" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="rebloggedBy" inverseEntity="Status"/>
<relationship name="searchHistories" toMany="YES" deletionRule="Nullify" destinationEntity="SearchHistory" inverseName="account" inverseEntity="SearchHistory"/>
<relationship name="showingReblogs" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="showingReblogsBy" inverseEntity="MastodonUser"/>
<relationship name="showingReblogsBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="showingReblogs" inverseEntity="MastodonUser"/>
<relationship name="statuses" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="author" inverseEntity="Status"/>
<relationship name="votePollOptions" toMany="YES" deletionRule="Nullify" destinationEntity="PollOption" inverseName="votedBy" inverseEntity="PollOption"/>
<relationship name="votePolls" toMany="YES" deletionRule="Nullify" destinationEntity="Poll" inverseName="votedBy" inverseEntity="Poll"/>
</entity>
<entity name="Notification" representedClassName="CoreDataStack.Notification" syncable="YES">
<attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="followRequestState" optional="YES" attributeType="Binary"/>
<attribute name="id" attributeType="String"/>
<attribute name="transientFollowRequestState" optional="YES" transient="YES" attributeType="Binary"/>
<attribute name="typeRaw" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userID" attributeType="String"/>
<relationship name="account" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="notifications" inverseEntity="MastodonUser"/>
<relationship name="feeds" toMany="YES" deletionRule="Cascade" destinationEntity="Feed" inverseName="notification" inverseEntity="Feed"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="notifications" inverseEntity="Status"/>
</entity>
<entity name="Poll" representedClassName="CoreDataStack.Poll" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String" defaultValueString=""/>
<attribute name="expired" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="expiresAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" attributeType="String"/>
<attribute name="isVoting" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="multiple" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="votersCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="votesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="options" toMany="YES" deletionRule="Cascade" destinationEntity="PollOption" inverseName="poll" inverseEntity="PollOption"/>
<relationship name="status" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="poll" inverseEntity="Status"/>
<relationship name="votedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="votePolls" inverseEntity="MastodonUser"/>
</entity>
<entity name="PollOption" representedClassName="CoreDataStack.PollOption" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="index" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isSelected" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="title" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="votesCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="poll" maxCount="1" deletionRule="Nullify" destinationEntity="Poll" inverseName="options" inverseEntity="Poll"/>
<relationship name="votedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="votePollOptions" inverseEntity="MastodonUser"/>
</entity>
<entity name="PrivateNote" representedClassName="CoreDataStack.PrivateNote" syncable="YES">
<attribute name="note" optional="YES" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="from" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotesTo" inverseEntity="MastodonUser"/>
<relationship name="to" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotes" inverseEntity="MastodonUser"/>
</entity>
<entity name="SearchHistory" representedClassName="CoreDataStack.SearchHistory" syncable="YES">
<attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String" defaultValueString=""/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userID" attributeType="String" defaultValueString=""/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="searchHistories" inverseEntity="MastodonUser"/>
<relationship name="hashtag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag" inverseName="searchHistories" inverseEntity="Tag"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="searchHistories" inverseEntity="Status"/>
</entity>
<entity name="Setting" representedClassName="CoreDataStack.Setting" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="preferredStaticAvatar" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="preferredStaticEmoji" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="preferredTrueBlackDarkMode" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="preferredUsingDefaultBrowser" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userID" attributeType="String"/>
<relationship name="subscriptions" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Subscription" inverseName="setting" inverseEntity="Subscription"/>
</entity>
<entity name="Status" representedClassName="CoreDataStack.Status" syncable="YES">
<attribute name="attachments" optional="YES" attributeType="Binary"/>
<attribute name="content" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="deletedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="emojis" optional="YES" attributeType="Binary"/>
<attribute name="favouritesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" attributeType="String"/>
<attribute name="identifier" attributeType="String"/>
<attribute name="inReplyToAccountID" optional="YES" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
<attribute name="isSensitiveToggled" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="language" optional="YES" attributeType="String"/>
<attribute name="mentions" optional="YES" attributeType="Binary"/>
<attribute name="reblogsCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="repliesCount" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
<attribute name="revealedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="sensitive" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="spoilerText" optional="YES" attributeType="String"/>
<attribute name="text" optional="YES" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="uri" attributeType="String"/>
<attribute name="url" optional="YES" attributeType="String"/>
<attribute name="visibilityRaw" optional="YES" attributeType="String" elementID="visibility"/>
<relationship name="application" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Application" inverseName="status" inverseEntity="Application"/>
<relationship name="author" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="statuses" inverseEntity="MastodonUser"/>
<relationship name="bookmarkedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="bookmarked" inverseEntity="MastodonUser"/>
<relationship name="favouritedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="favourite" inverseEntity="MastodonUser"/>
<relationship name="feeds" toMany="YES" deletionRule="Cascade" destinationEntity="Feed" inverseName="status" inverseEntity="Feed"/>
<relationship name="mutedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="muted" inverseEntity="MastodonUser"/>
<relationship name="notifications" toMany="YES" deletionRule="Cascade" destinationEntity="Notification" inverseName="status" inverseEntity="Notification"/>
<relationship name="pinnedBy" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="pinnedStatus" inverseEntity="MastodonUser"/>
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Poll" inverseName="status" inverseEntity="Poll"/>
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="reblogFrom" inverseEntity="Status"/>
<relationship name="reblogFrom" toMany="YES" deletionRule="Cascade" destinationEntity="Status" inverseName="reblog" inverseEntity="Status"/>
<relationship name="rebloggedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="reblogged" inverseEntity="MastodonUser"/>
<relationship name="replyFrom" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="replyTo" inverseEntity="Status"/>
<relationship name="replyTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="replyFrom" inverseEntity="Status"/>
<relationship name="searchHistories" toMany="YES" deletionRule="Cascade" destinationEntity="SearchHistory" inverseName="status" inverseEntity="SearchHistory"/>
</entity>
<entity name="Subscription" representedClassName="CoreDataStack.Subscription" syncable="YES">
<attribute name="activedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="endpoint" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="policyRaw" attributeType="String"/>
<attribute name="serverKey" optional="YES" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userToken" optional="YES" attributeType="String"/>
<relationship name="alert" maxCount="1" deletionRule="Cascade" destinationEntity="SubscriptionAlerts" inverseName="subscription" inverseEntity="SubscriptionAlerts"/>
<relationship name="setting" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Setting" inverseName="subscriptions" inverseEntity="Setting"/>
</entity>
<entity name="SubscriptionAlerts" representedClassName="CoreDataStack.SubscriptionAlerts" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="favouriteRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="followRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="followRequestRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="mentionRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="pollRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="reblogRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="subscription" maxCount="1" deletionRule="Nullify" destinationEntity="Subscription" inverseName="alert" inverseEntity="Subscription"/>
</entity>
<entity name="Tag" representedClassName="CoreDataStack.Tag" syncable="YES">
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String" defaultValueString=""/>
<attribute name="following" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="histories" optional="YES" attributeType="Binary"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="url" attributeType="String"/>
<relationship name="followedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followedTags" inverseEntity="MastodonUser"/>
<relationship name="searchHistories" toMany="YES" deletionRule="Nullify" destinationEntity="SearchHistory" inverseName="hashtag" inverseEntity="SearchHistory"/>
</entity>
</model>

View File

@ -10,7 +10,8 @@ import CoreData
public final class Instance: NSManagedObject {
@NSManaged public var domain: String
@NSManaged public var version: String?
@NSManaged public private(set) var createdAt: Date
@NSManaged public private(set) var updatedAt: Date
@ -35,6 +36,7 @@ extension Instance {
) -> Instance {
let instance: Instance = context.insertObject()
instance.domain = property.domain
instance.version = property.version
return instance
}
@ -50,9 +52,11 @@ extension Instance {
extension Instance {
public struct Property {
public let domain: String
public init(domain: String) {
public let version: String?
public init(domain: String, version: String?) {
self.domain = domain
self.version = version
}
}
}

View File

@ -77,6 +77,7 @@ final public class MastodonUser: NSManagedObject {
@NSManaged public private(set) var votePollOptions: Set<PollOption>
@NSManaged public private(set) var votePolls: Set<Poll>
// relationships
@NSManaged public private(set) var followedTags: Set<Tag>
@NSManaged public private(set) var following: Set<MastodonUser>
@NSManaged public private(set) var followingBy: Set<MastodonUser>
@NSManaged public private(set) var followRequested: Set<MastodonUser>

View File

@ -24,10 +24,13 @@ public final class Tag: NSManagedObject {
@NSManaged public private(set) var name: String
// sourcery: autoUpdatableObject, autoGenerateProperty
@NSManaged public private(set) var url: String
// sourcery: autoUpdatableObject, autoGenerateProperty
@NSManaged public private(set) var following: Bool
// one-to-one relationship
// many-to-many relationship
@NSManaged public private(set) var followedBy: Set<MastodonUser>
// one-to-many relationship
@NSManaged public private(set) var searchHistories: Set<SearchHistory>
@ -88,7 +91,16 @@ public extension Tag {
}
static func predicate(name: String) -> NSPredicate {
NSPredicate(format: "%K == %@", #keyPath(Tag.name), name)
// use case-insensitive query as tags #CaN #BE #speLLed #USiNG #arbITRARy #cASe
NSPredicate(format: "%K MATCHES[c] %@", #keyPath(Tag.name), name)
}
static func predicate(domain: String, following: Bool) -> NSPredicate {
NSPredicate(format: "%K == %@ AND %K == %d", #keyPath(Tag.domain), domain, #keyPath(Tag.following), following)
}
static func predicate(followedBy user: MastodonUser) -> NSPredicate {
NSPredicate(format: "ANY %K.%K == %@", #keyPath(Tag.followedBy), #keyPath(MastodonUser.id), user.id)
}
static func predicate(domain: String, name: String) -> NSPredicate {
@ -97,6 +109,13 @@ public extension Tag {
predicate(name: name),
])
}
static func predicate(domain: String, following: Bool, by user: MastodonUser) -> NSPredicate {
NSCompoundPredicate(andPredicateWithSubpredicates: [
predicate(domain: domain, following: following),
predicate(followedBy: user)
])
}
}
// MARK: - AutoGenerateProperty
@ -112,6 +131,7 @@ extension Tag: AutoGenerateProperty {
public let updatedAt: Date
public let name: String
public let url: String
public let following: Bool
public let histories: [MastodonTagHistory]
public init(
@ -121,6 +141,7 @@ extension Tag: AutoGenerateProperty {
updatedAt: Date,
name: String,
url: String,
following: Bool,
histories: [MastodonTagHistory]
) {
self.identifier = identifier
@ -129,6 +150,7 @@ extension Tag: AutoGenerateProperty {
self.updatedAt = updatedAt
self.name = name
self.url = url
self.following = following
self.histories = histories
}
}
@ -140,12 +162,14 @@ extension Tag: AutoGenerateProperty {
self.updatedAt = property.updatedAt
self.name = property.name
self.url = property.url
self.following = property.following
self.histories = property.histories
}
public func update(property: Property) {
update(updatedAt: property.updatedAt)
update(url: property.url)
update(following: property.following)
update(histories: property.histories)
}
// sourcery:end
@ -167,12 +191,30 @@ extension Tag: AutoUpdatableObject {
self.url = url
}
}
public func update(following: Bool) {
if self.following != following {
self.following = following
}
}
public func update(histories: [MastodonTagHistory]) {
if self.histories != histories {
self.histories = histories
}
}
// sourcery:end
public func update(followed: Bool, by mastodonUser: MastodonUser) {
if following {
if !self.followedBy.contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Tag.followedBy)).add(mastodonUser)
}
} else {
if self.followedBy.contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Tag.followedBy)).remove(mastodonUser)
}
}
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x38",
"green" : "0x29",
"red" : "0x2B"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFF"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x38",
"green" : "0x29",
"red" : "0x2B"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -49,6 +49,8 @@ public enum Asset {
public static let actionToolbar = ColorAsset(name: "Colors/Button/action.toolbar")
public static let disabled = ColorAsset(name: "Colors/Button/disabled")
public static let inactive = ColorAsset(name: "Colors/Button/inactive")
public static let tagFollow = ColorAsset(name: "Colors/Button/tagFollow")
public static let tagUnfollow = ColorAsset(name: "Colors/Button/tagUnfollow")
}
public enum Icon {
public static let plus = ColorAsset(name: "Colors/Icon/plus")

View File

@ -23,3 +23,10 @@ extension Instance {
return try? JSONEncoder().encode(configuration)
}
}
extension Instance {
public var canFollowTags: Bool {
guard let majorVersionString = version?.split(separator: ".").first else { return false }
return Int(majorVersionString) == 4 // following Tags is support beginning with Mastodon v4.0.0
}
}

View File

@ -22,6 +22,7 @@ extension Tag.Property {
updatedAt: networkDate,
name: entity.name,
url: entity.url,
following: entity.following ?? false,
histories: {
guard let histories = entity.history else { return [] }
let result: [MastodonTagHistory] = histories.map { history in

View File

@ -0,0 +1,77 @@
//
// FollowedTagsFetchedResultController.swift
//
//
// Created by Marcus Kida on 23.11.22.
//
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
public final class FollowedTagsFetchedResultController: NSObject {
var disposeBag = Set<AnyCancellable>()
let fetchedResultsController: NSFetchedResultsController<Tag>
// input
@Published public var domain: String? = nil
@Published public var user: MastodonUser? = nil
// output
@Published public private(set) var records: [Tag] = []
public init(managedObjectContext: NSManagedObjectContext, domain: String, user: MastodonUser) {
self.domain = domain
self.fetchedResultsController = {
let fetchRequest = Tag.sortedFetchRequest
fetchRequest.predicate = Tag.predicate(domain: domain, following: true, by: user)
fetchRequest.sortDescriptors = Tag.defaultSortDescriptors
fetchRequest.returnsObjectsAsFaults = false
fetchRequest.fetchBatchSize = 20
let controller = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: managedObjectContext,
sectionNameKeyPath: nil,
cacheName: nil
)
return controller
}()
super.init()
fetchedResultsController.delegate = self
try? fetchedResultsController.performFetch()
Publishers.CombineLatest(
self.$domain,
self.$user
)
.receive(on: DispatchQueue.main)
.sink { [weak self] domain, user in
guard let self = self, let domain = domain, let user = user else { return }
self.fetchedResultsController.fetchRequest.predicate = Tag.predicate(domain: domain, following: true, by: user)
do {
try self.fetchedResultsController.performFetch()
} catch {
assertionFailure(error.localizedDescription)
}
}
.store(in: &disposeBag)
}
}
// MARK: - NSFetchedResultsControllerDelegate
extension FollowedTagsFetchedResultController: NSFetchedResultsControllerDelegate {
public func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let objects = fetchedResultsController.fetchedObjects ?? []
self.records = objects
}
}

View File

@ -103,6 +103,9 @@ extension Persistence.Tag {
property: property
)
update(tag: object, context: context)
if let followingUser = context.me {
object.update(followed: property.following, by: followingUser)
}
return object
}
@ -116,7 +119,11 @@ extension Persistence.Tag {
domain: context.domain,
networkDate: context.networkDate
)
tag.update(property: property)
if let followingUser = context.me {
tag.update(followed: property.following, by: followingUser)
}
update(tag: tag, context: context)
}

View File

@ -161,3 +161,41 @@ extension APIService {
}
}
extension APIService {
@discardableResult
public func getFollowedTags(
domain: String,
query: Mastodon.API.Account.FollowedTagsQuery,
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Tag]> {
let domain = authenticationBox.domain
let authorization = authenticationBox.userAuthorization
let response = try await Mastodon.API.Account.followedTags(
session: session,
domain: domain,
query: query,
authorization: authorization
).singleOutput()
let managedObjectContext = self.backgroundManagedObjectContext
try await managedObjectContext.performChanges {
let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user
for entity in response.value {
_ = Persistence.Tag.createOrMerge(
in: managedObjectContext,
context: Persistence.Tag.PersistContext(
domain: domain,
entity: entity,
me: me,
networkDate: response.networkDate
)
)
}
}
return response
} // end func
}

View File

@ -0,0 +1,92 @@
//
// APIService+Tags.swift
//
//
// Created by Marcus Kida on 23.11.22.
//
import os.log
import Foundation
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
extension APIService {
public func getTagInformation(
for tag: String,
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<Mastodon.Entity.Tag> {
let domain = authenticationBox.domain
let authorization = authenticationBox.userAuthorization
let response = try await Mastodon.API.Tags.getTagInformation(
session: session,
domain: domain,
tagId: tag,
authorization: authorization
).singleOutput()
return try await persistTag(from: response, domain: domain, authenticationBox: authenticationBox)
} // end func
public func followTag(
for tag: String,
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<Mastodon.Entity.Tag> {
let domain = authenticationBox.domain
let authorization = authenticationBox.userAuthorization
let response = try await Mastodon.API.Tags.followTag(
session: session,
domain: domain,
tagId: tag,
authorization: authorization
).singleOutput()
return try await persistTag(from: response, domain: domain, authenticationBox: authenticationBox)
} // end func
public func unfollowTag(
for tag: String,
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<Mastodon.Entity.Tag> {
let domain = authenticationBox.domain
let authorization = authenticationBox.userAuthorization
let response = try await Mastodon.API.Tags.unfollowTag(
session: session,
domain: domain,
tagId: tag,
authorization: authorization
).singleOutput()
return try await persistTag(from: response, domain: domain, authenticationBox: authenticationBox)
} // end func
}
fileprivate extension APIService {
func persistTag(
from response: Mastodon.Response.Content<Mastodon.Entity.Tag>,
domain: String,
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<Mastodon.Entity.Tag> {
let managedObjectContext = self.backgroundManagedObjectContext
try await managedObjectContext.performChanges {
let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user
_ = Persistence.Tag.createOrMerge(
in: managedObjectContext,
context: Persistence.Tag.PersistContext(
domain: domain,
entity: response.value,
me: me,
networkDate: response.networkDate
)
)
}
return response
}
}

View File

@ -46,7 +46,7 @@ extension APIService.CoreData {
} else {
let instance = Instance.insert(
into: managedObjectContext,
property: Instance.Property(domain: domain)
property: Instance.Property(domain: domain, version: entity.version)
)
let configurationRaw = entity.configuration.flatMap { Instance.encode(configuration: $0) }
instance.update(configurationRaw: configurationRaw)
@ -69,7 +69,8 @@ extension APIService.CoreData {
let configurationRaw = entity.configuration.flatMap { Instance.encode(configuration: $0) }
instance.update(configurationRaw: configurationRaw)
instance.version = entity.version
instance.didUpdate(at: networkDate)
}

View File

@ -633,6 +633,24 @@ public enum L10n {
/// Favorited By
public static let title = L10n.tr("Localizable", "Scene.FavoritedBy.Title", fallback: "Favorited By")
}
public enum FollowedTags {
/// Followed Tags
public static let title = L10n.tr("Localizable", "Scene.FollowedTags.Title", fallback: "Followed Tags")
public enum Actions {
/// follow
public static let follow = L10n.tr("Localizable", "Scene.FollowedTags.Actions.Follow", fallback: "follow")
/// unfollow
public static let unfollow = L10n.tr("Localizable", "Scene.FollowedTags.Actions.Unfollow", fallback: "unfollow")
}
public enum Header {
/// participants
public static let participants = L10n.tr("Localizable", "Scene.FollowedTags.Header.Participants", fallback: "participants")
/// posts
public static let posts = L10n.tr("Localizable", "Scene.FollowedTags.Header.Posts", fallback: "posts")
/// posts today
public static let postsToday = L10n.tr("Localizable", "Scene.FollowedTags.Header.PostsToday", fallback: "posts today")
}
}
public enum Follower {
/// Followers from other servers are not displayed.
public static let footer = L10n.tr("Localizable", "Scene.Follower.Footer", fallback: "Followers from other servers are not displayed.")

View File

@ -231,6 +231,12 @@ uploaded to Mastodon.";
"Scene.FavoritedBy.Title" = "Favorited By";
"Scene.Follower.Footer" = "Followers from other servers are not displayed.";
"Scene.Follower.Title" = "follower";
"Scene.FollowedTags.Title" = "Followed Tags";
"Scene.FollowedTags.Header.Posts" = "posts";
"Scene.FollowedTags.Header.Participants" = "participants";
"Scene.FollowedTags.Header.PostsToday" = "posts today";
"Scene.FollowedTags.Actions.Follow" = "follow";
"Scene.FollowedTags.Actions.Unfollow" = "unfollow";
"Scene.Following.Footer" = "Follows from other servers are not displayed.";
"Scene.Following.Title" = "following";
"Scene.HomeTimeline.NavigationBarState.Accessibility.LogoHint" = "Tap to scroll to top and tap again to previous location";
@ -461,4 +467,4 @@ uploaded to Mastodon.";
back in your hands.";
"Scene.Wizard.AccessibilityHint" = "Double tap to dismiss this wizard";
"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Switch between multiple accounts by holding the profile button.";
"Scene.Wizard.NewInMastodon" = "New in Mastodon";
"Scene.Wizard.NewInMastodon" = "New in Mastodon";

View File

@ -231,6 +231,12 @@ uploaded to Mastodon.";
"Scene.FavoritedBy.Title" = "Favorited By";
"Scene.Follower.Footer" = "Followers from other servers are not displayed.";
"Scene.Follower.Title" = "follower";
"Scene.FollowedTags.Title" = "Followed Tags";
"Scene.FollowedTags.Header.Posts" = "posts";
"Scene.FollowedTags.Header.Participants" = "participants";
"Scene.FollowedTags.Header.PostsToday" = "posts today";
"Scene.FollowedTags.Actions.Follow" = "follow";
"Scene.FollowedTags.Actions.Unfollow" = "unfollow";
"Scene.Following.Footer" = "Follows from other servers are not displayed.";
"Scene.Following.Title" = "following";
"Scene.HomeTimeline.NavigationBarState.Accessibility.LogoHint" = "Tap to scroll to top and tap again to previous location";
@ -461,4 +467,4 @@ uploaded to Mastodon.";
back in your hands.";
"Scene.Wizard.AccessibilityHint" = "Double tap to dismiss this wizard";
"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Switch between multiple accounts by holding the profile button.";
"Scene.Wizard.NewInMastodon" = "New in Mastodon";
"Scene.Wizard.NewInMastodon" = "New in Mastodon";

View File

@ -0,0 +1,73 @@
//
// Mastodon+API+Account+FollowedTags.swift
//
//
// Created by Marcus Kida on 22.11.22.
//
import Foundation
import Combine
extension Mastodon.API.Account {
static func followedTagsEndpointURL(domain: String) -> URL {
return Mastodon.API.endpointURL(domain: domain)
.appendingPathComponent("followed_tags")
}
/// Followed Tags
///
/// View your followed hashtags.
///
/// - Since: 4.0.0
/// - Version: 4.0.3
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/followed_tags/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - authorization: User token
/// - Returns: `AnyPublisher` contains `[Tag]` nested in the response
public static func followedTags(
session: URLSession,
domain: String,
query: FollowedTagsQuery,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> {
let request = Mastodon.API.get(
url: followedTagsEndpointURL(domain: domain),
query: query,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: [Mastodon.Entity.Tag].self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
public struct FollowedTagsQuery: Codable, GetQuery {
public let limit: Int? // default 100
enum CodingKeys: String, CodingKey {
case limit
}
public init(
limit: Int?
) {
self.limit = limit
}
var queryItems: [URLQueryItem]? {
var items: [URLQueryItem] = []
limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) }
guard !items.isEmpty else { return nil }
return items
}
}
}

View File

@ -0,0 +1,117 @@
//
// Mastodin+API+Tags.swift
//
//
// Created by Marcus Kida on 23.11.22.
//
import Combine
import Foundation
extension Mastodon.API.Tags {
static func tagsEndpointURL(domain: String) -> URL {
return Mastodon.API.endpointURL(domain: domain)
.appendingPathComponent("tags")
}
/// Tags
///
/// View information about a single tag.
///
/// - Since: 4.0.0
/// - Version: 4.0.3
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/tags/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - authorization: User token
/// - tagId: The Hashtag
/// - Returns: `AnyPublisher` contains `Tag` nested in the response
public static func getTagInformation(
session: URLSession,
domain: String,
tagId: String,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Tag>, Error> {
let request = Mastodon.API.get(
url: tagsEndpointURL(domain: domain).appendingPathComponent(tagId),
query: nil,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Tag.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
/// Tags
///
/// Follow a hashtag.
///
/// - Since: 4.0.0
/// - Version: 4.0.3
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/tags/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - authorization: User token
/// - tagId: The Hashtag
/// - Returns: `AnyPublisher` contains `Tag` nested in the response
public static func followTag(
session: URLSession,
domain: String,
tagId: String,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Tag>, Error> {
let request = Mastodon.API.post(
url: tagsEndpointURL(domain: domain).appendingPathComponent(tagId)
.appendingPathComponent("follow"),
query: nil,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Tag.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
/// Tags
///
/// Unfollow a hashtag.
///
/// - Since: 4.0.0
/// - Version: 4.0.3
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/tags/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - authorization: User token
/// - tagId: The Hashtag
/// - Returns: `AnyPublisher` contains `Tag` nested in the response
public static func unfollowTag(
session: URLSession,
domain: String,
tagId: String,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Tag>, Error> {
let request = Mastodon.API.post(
url: tagsEndpointURL(domain: domain).appendingPathComponent(tagId)
.appendingPathComponent("unfollow"),
query: nil,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Tag.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
}

View File

@ -112,6 +112,7 @@ extension Mastodon.API {
public enum Polls { }
public enum Reblog { }
public enum Statuses { }
public enum Tags {}
public enum Timeline { }
public enum Trends { }
public enum Suggestions { }

View File

@ -11,9 +11,9 @@ extension Mastodon.Entity {
/// Tag
///
/// - Since: 0.9.0
/// - Version: 3.3.0
/// - Version: 4.0.0
/// # Last Update
/// 2021/1/28
/// 2022/11/22
/// # Reference
/// [Document](https://docs.joinmastodon.org/entities/tag/)
public struct Tag: Hashable, Codable {
@ -23,11 +23,13 @@ extension Mastodon.Entity {
public let url: String
public let history: [History]?
public let following: Bool?
enum CodingKeys: String, CodingKey {
case name
case url
case history
case following
}
public static func == (lhs: Mastodon.Entity.Tag, rhs: Mastodon.Entity.Tag) -> Bool {
@ -39,5 +41,9 @@ extension Mastodon.Entity {
hasher.combine(name)
hasher.combine(url)
}
public func copy(following: Bool?) -> Self {
Tag(name: name, url: url, history: history, following: following)
}
}
}