Merge pull request #691 from mastodon/follow_hashtags
[Feature] Follow hashtags
This commit is contained in:
commit
56efe8a93a
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 */,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
//
|
||||
// Array+IsNotEmpty.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Marcus Kida on 01.12.22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Collection {
|
||||
var isNotEmpty: Bool {
|
||||
!isEmpty
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,6 @@
|
|||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>_XCCurrentVersionName</key>
|
||||
<string>CoreData 4.xcdatamodel</string>
|
||||
<string>CoreData 5.xcdatamodel</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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.")
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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 { }
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue