diff --git a/Foundation/Source/Sources/ModuleBridge/User.swift b/Foundation/Source/Sources/ModuleBridge/User.swift index c787c32..55a909f 100644 --- a/Foundation/Source/Sources/ModuleBridge/User.swift +++ b/Foundation/Source/Sources/ModuleBridge/User.swift @@ -56,6 +56,22 @@ public extension User { } public extension UserProfile { + static func converting(_ user: User?) -> UserProfile? { + guard let user else { return nil } + return UserProfile( + userId: user.userId, + name: user.name, + username: user.username, + host: user.host, + avatarUrl: user.avatarUrl, + avatarBlurhash: user.avatarBlurHash, + isAdmin: user.isAdmin, + isModerator: user.isModerator, + isBot: user.isBot, + isCat: user.isCat + ) + } + static func converting(_ userDetails: NMUserDetails?, defaultHost: String) -> UserProfile? { guard let userDetails else { return nil } guard let date = userDetails.createdAt.toISODate(nil, region: nil)?.date else { diff --git a/Kimis.xcodeproj/project.pbxproj b/Kimis.xcodeproj/project.pbxproj index 9d78cf6..f2b7569 100644 --- a/Kimis.xcodeproj/project.pbxproj +++ b/Kimis.xcodeproj/project.pbxproj @@ -237,6 +237,11 @@ 50BED2262927802700C9D7E2 /* SPIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 50BED2252927802700C9D7E2 /* SPIndicator */; }; 50BED238292789FE00C9D7E2 /* NoteAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BED22D292789FE00C9D7E2 /* NoteAttachmentView.swift */; }; 50BED23A292789FE00C9D7E2 /* NoteAttachmentView+Elemet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BED22F292789FE00C9D7E2 /* NoteAttachmentView+Elemet.swift */; }; + 50CEBEBB2A6A455900484FD8 /* UserSimpleBannerListTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CEBEB42A6A455900484FD8 /* UserSimpleBannerListTableView.swift */; }; + 50CEBEBC2A6A455900484FD8 /* UserSimpleBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CEBEB62A6A455900484FD8 /* UserSimpleBannerView.swift */; }; + 50CEBEBD2A6A455900484FD8 /* UserSimpleBannerCell+Render.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CEBEB82A6A455900484FD8 /* UserSimpleBannerCell+Render.swift */; }; + 50CEBEBE2A6A455900484FD8 /* UserSimpleBannerCell+Context.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CEBEB92A6A455900484FD8 /* UserSimpleBannerCell+Context.swift */; }; + 50CEBEBF2A6A455900484FD8 /* UserSimpleBannerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CEBEBA2A6A455900484FD8 /* UserSimpleBannerCell.swift */; }; 50D3CC8B295D831500D5CE6A /* TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50D3CC8A295D831500D5CE6A /* TableView.swift */; }; 50D7B1722970FF3000E12904 /* PostEditorPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50D7B1712970FF3000E12904 /* PostEditorPollView.swift */; }; 50D7B17429710B5500E12904 /* PostEditorPollView+Delegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50D7B17329710B5500E12904 /* PostEditorPollView+Delegate.swift */; }; @@ -485,6 +490,11 @@ 50BED1B529277D0E00C9D7E2 /* TextParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextParser.swift; sourceTree = ""; }; 50BED22D292789FE00C9D7E2 /* NoteAttachmentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NoteAttachmentView.swift; sourceTree = ""; }; 50BED22F292789FE00C9D7E2 /* NoteAttachmentView+Elemet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NoteAttachmentView+Elemet.swift"; sourceTree = ""; }; + 50CEBEB42A6A455900484FD8 /* UserSimpleBannerListTableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserSimpleBannerListTableView.swift; sourceTree = ""; }; + 50CEBEB62A6A455900484FD8 /* UserSimpleBannerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserSimpleBannerView.swift; sourceTree = ""; }; + 50CEBEB82A6A455900484FD8 /* UserSimpleBannerCell+Render.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UserSimpleBannerCell+Render.swift"; sourceTree = ""; }; + 50CEBEB92A6A455900484FD8 /* UserSimpleBannerCell+Context.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UserSimpleBannerCell+Context.swift"; sourceTree = ""; }; + 50CEBEBA2A6A455900484FD8 /* UserSimpleBannerCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserSimpleBannerCell.swift; sourceTree = ""; }; 50D3CC8A295D831500D5CE6A /* TableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableView.swift; sourceTree = ""; }; 50D7B1712970FF3000E12904 /* PostEditorPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostEditorPollView.swift; sourceTree = ""; }; 50D7B17329710B5500E12904 /* PostEditorPollView+Delegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostEditorPollView+Delegate.swift"; sourceTree = ""; }; @@ -1133,6 +1143,7 @@ 50BED16C29277D0E00C9D7E2 /* NoteTableView */, 504E87C42937594500BDE03C /* NotificationTableView */, 5069EBB5295EE2AC00677A3F /* UsersListTableView */, + 50CEBEB22A6A455900484FD8 /* UserSimpleBannerListTableView */, 50FE1B132933A3E7000CE139 /* TrendingTableView */, 50BED18029277D0E00C9D7E2 /* EmojiPicker */, ); @@ -1363,6 +1374,43 @@ name = Frameworks; sourceTree = ""; }; + 50CEBEB22A6A455900484FD8 /* UserSimpleBannerListTableView */ = { + isa = PBXGroup; + children = ( + 50CEBEB32A6A455900484FD8 /* UserSimpleBannerListTableView */, + 50CEBEB52A6A455900484FD8 /* UserSimpleBannerView */, + 50CEBEB72A6A455900484FD8 /* UserCell */, + ); + name = UserSimpleBannerListTableView; + path = Kimis/Interface/Component/UserSimpleBannerListTableView; + sourceTree = SOURCE_ROOT; + }; + 50CEBEB32A6A455900484FD8 /* UserSimpleBannerListTableView */ = { + isa = PBXGroup; + children = ( + 50CEBEB42A6A455900484FD8 /* UserSimpleBannerListTableView.swift */, + ); + path = UserSimpleBannerListTableView; + sourceTree = ""; + }; + 50CEBEB52A6A455900484FD8 /* UserSimpleBannerView */ = { + isa = PBXGroup; + children = ( + 50CEBEB62A6A455900484FD8 /* UserSimpleBannerView.swift */, + ); + path = UserSimpleBannerView; + sourceTree = ""; + }; + 50CEBEB72A6A455900484FD8 /* UserCell */ = { + isa = PBXGroup; + children = ( + 50CEBEB82A6A455900484FD8 /* UserSimpleBannerCell+Render.swift */, + 50CEBEB92A6A455900484FD8 /* UserSimpleBannerCell+Context.swift */, + 50CEBEBA2A6A455900484FD8 /* UserSimpleBannerCell.swift */, + ); + path = UserCell; + sourceTree = ""; + }; 50D3CC84295D4D1500D5CE6A /* LoadingController */ = { isa = PBXGroup; children = ( @@ -1528,6 +1576,7 @@ 5020160329704EC600C57E39 /* PostEditorAttachmentView+Cell.swift in Sources */, 508FE99A292B54E9005D1933 /* NSAttributeString.swift in Sources */, 504519A3296D55EC009D613D /* Toolbar+File.swift in Sources */, + 50CEBEBE2A6A455900484FD8 /* UserSimpleBannerCell+Context.swift in Sources */, 50BED1D529277D0E00C9D7E2 /* TimelineTableView.swift in Sources */, 502F6E582968738F003691BE /* ToolbarView.swift in Sources */, 50AFDFE929305E9B00BEC741 /* NoteOperationStrip+More.swift in Sources */, @@ -1535,6 +1584,7 @@ 50FAC1F32A651B4000125B2A /* ReactionStrip+BaseView.swift in Sources */, 504E87CE293779C200BDE03C /* NotificationTableView+Publisher.swift in Sources */, 504519A6296D55EC009D613D /* Toolbar+User.swift in Sources */, + 50CEBEBD2A6A455900484FD8 /* UserSimpleBannerCell+Render.swift in Sources */, 50BED20129277D0E00C9D7E2 /* TextParser+TinyEmoji.swift in Sources */, 504E87D229377A3000BDE03C /* NotificationTableView+Delegate.swift in Sources */, 50BED20929277D0E00C9D7E2 /* Enumerator.swift in Sources */, @@ -1567,6 +1617,7 @@ 50A2F3792934B4B400DE9464 /* NoteCell+Pinned.swift in Sources */, 508FE98F292B3C40005D1933 /* NoteCell+ContextBuilder.swift in Sources */, 50BED1E729277D0E00C9D7E2 /* LargeTimelineController.swift in Sources */, + 50CEBEBB2A6A455900484FD8 /* UserSimpleBannerListTableView.swift in Sources */, 50BED1FE29277D0E00C9D7E2 /* TextParser+Visibility.swift in Sources */, 50BED1BA29277D0E00C9D7E2 /* EncryptedCodableDefault.swift in Sources */, 5057A942296180C90088A6D4 /* PollView.swift in Sources */, @@ -1680,6 +1731,7 @@ 50FE1B192933AFF9000CE139 /* TrendingTableView+Cell.swift in Sources */, 505E10C9292E110D001F9141 /* NoteCell+Reply.swift in Sources */, 50AFDFE529305E9200BEC741 /* NoteOperationStrip+Reaction.swift in Sources */, + 50CEBEBF2A6A455900484FD8 /* UserSimpleBannerCell.swift in Sources */, 5069EBC2295EEAB000677A3F /* UserCell+Context.swift in Sources */, 50D7B1722970FF3000E12904 /* PostEditorPollView.swift in Sources */, 50BED1D329277D0E00C9D7E2 /* NoteTableView.swift in Sources */, @@ -1718,6 +1770,7 @@ 50BED1C229277D0E00C9D7E2 /* TextView.swift in Sources */, 5065E4E429352B22001C540C /* TextParser+Email.swift in Sources */, 50BED20329277D0E00C9D7E2 /* TextParser+Body.swift in Sources */, + 50CEBEBC2A6A455900484FD8 /* UserSimpleBannerView.swift in Sources */, 50126253292F7752002E1636 /* ReactionStrip+EmojiView.swift in Sources */, 50126247292F3945002E1636 /* NoteCell+Progress.swift in Sources */, 50BED1C629277D0E00C9D7E2 /* UIFont.swift in Sources */, @@ -1871,7 +1924,7 @@ CODE_SIGN_ENTITLEMENTS = Kimis/KimisDebug.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 151; + CURRENT_PROJECT_VERSION = 152; DEVELOPMENT_TEAM = 6CMYQQFFT8; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1910,7 +1963,7 @@ CODE_SIGN_ENTITLEMENTS = Kimis/Kimis.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 151; + CURRENT_PROJECT_VERSION = 152; DEVELOPMENT_TEAM = 6CMYQQFFT8; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; GENERATE_INFOPLIST_FILE = YES; @@ -2040,9 +2093,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/hackiftekhar/IQKeyboardManager"; requirement = { - kind = versionRange; - maximumVersion = 999.999.999; - minimumVersion = 0.0.0; + kind = exactVersion; + version = 6.5.11; }; }; 50BED2242927802700C9D7E2 /* XCRemoteSwiftPackageReference "SPIndicator" */ = { diff --git a/Kimis.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Kimis.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2a4a242..9a73acb 100644 --- a/Kimis.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Kimis.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/bugsnag/bugsnag-cocoa", "state" : { - "revision" : "c730af7a4676617d82394782300df997d9a356cd", - "version" : "6.26.2" + "revision" : "2f373f21b965f1b13d7070662e2d35f46c17d975", + "version" : "6.27.2" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/SDWebImage", "state" : { - "revision" : "3289629ef6cbf1ad8c3d1dccf0cf09ac97547cd6", - "version" : "5.15.7" + "revision" : "633996a807442ec28df9d33b0f88ce57a0e2fdbf", + "version" : "5.17.0" } }, { diff --git a/Kimis/Backend/TextParser/Helper/TextParser+Image.swift b/Kimis/Backend/TextParser/Helper/TextParser+Image.swift index b9a0042..6863410 100644 --- a/Kimis/Backend/TextParser/Helper/TextParser+Image.swift +++ b/Kimis/Backend/TextParser/Helper/TextParser+Image.swift @@ -60,8 +60,8 @@ extension TextParser { class RemoteImageAttachment: SubviewTextAttachment { let provider: TextAttachedViewProvider - init(url: URL, size: CGSize) { - provider = RemoteImageAttachmentProvider(url: url, size: size) + init(url: URL, size: CGSize, cornerRadius: CGFloat = 0) { + provider = RemoteImageAttachmentProvider(url: url, size: size, cornerRadius: cornerRadius) super.init(viewProvider: provider) } @@ -74,9 +74,12 @@ extension TextParser { private class RemoteImageAttachmentProvider: TextAttachedViewProvider { let url: URL let size: CGSize - init(url: URL, size: CGSize) { + let cornerRadius: CGFloat + + init(url: URL, size: CGSize, cornerRadius: CGFloat = 0) { self.url = url self.size = size + self.cornerRadius = cornerRadius } @available(*, unavailable) @@ -89,6 +92,8 @@ extension TextParser { view.contentMode = .scaleAspectFit view.layer.minificationFilter = .trilinear view.sd_setImage(with: url, placeholderImage: placeholder, options: [], completed: nil) + view.clipsToBounds = true + view.layer.cornerRadius = cornerRadius return view } diff --git a/Kimis/Backend/TextParser/Render/TextParser+User.swift b/Kimis/Backend/TextParser/Render/TextParser+User.swift index 8b58413..6f569c0 100644 --- a/Kimis/Backend/TextParser/Render/TextParser+User.swift +++ b/Kimis/Backend/TextParser/Render/TextParser+User.swift @@ -202,4 +202,27 @@ extension TextParser { decodingIDNAIfNeeded(modifyingStringInPlace: ans) return finalize(ans, defaultHost: user.host) } + + func compileUserBanner(withUser user: User) -> NSMutableAttributedString { + var strings: [NSMutableAttributedString] = [] + if let url = URL(string: user.avatarUrl) { + strings.append( + NSMutableAttributedString( + attachment: RemoteImageAttachment(url: url, size: CGSize(width: size.base, height: size.base), cornerRadius: 8) + ) + ) + } + strings.append( + NSMutableAttributedString( + string: user.name, + attributes: [ + .font: getFont(size: size.base, weight: weight.base), + .link: "username://\(user.absoluteUsername.base64Encoded ?? "")", + ] + ) + ) + let ans = connect(strings: strings, separator: " ") + decodingIDNAIfNeeded(modifyingStringInPlace: ans) + return finalize(ans, defaultHost: user.host) + } } diff --git a/Kimis/Extension/UIKit/UIViewController.swift b/Kimis/Extension/UIKit/UIViewController.swift index 746c31d..199b156 100644 --- a/Kimis/Extension/UIKit/UIViewController.swift +++ b/Kimis/Extension/UIKit/UIViewController.swift @@ -60,10 +60,7 @@ extension UIViewController { } func present(next: UIViewController) { - guard let presenter = topMostController else { - print("[E] failed to find top most controller for present") - return - } + guard let presenter = topMostController else { return } if let navigator = presenter.navigationController, !(next is UINavigationController), !(next is UIAlertController), @@ -81,7 +78,8 @@ extension UIViewController { var result: UIViewController? = self while true { if let next = result?.presentedViewController, - !next.isBeingDismissed + !next.isBeingDismissed, + next as? UISearchController == nil { result = next continue @@ -112,6 +110,16 @@ extension UIViewController { result = target.associatedNavigationController continue } + if let target = result as? RootController, + let newTarget = target.controller + { + result = newTarget + continue + } + if let target = result as? SideBarController { + result = target.contentController + continue + } break } return result diff --git a/Kimis/Interface/Component/NoteView/Reactions/ReactionStrip+BaseView.swift b/Kimis/Interface/Component/NoteView/Reactions/ReactionStrip+BaseView.swift index 1467bd1..35bd6ad 100644 --- a/Kimis/Interface/Component/NoteView/Reactions/ReactionStrip+BaseView.swift +++ b/Kimis/Interface/Component/NoteView/Reactions/ReactionStrip+BaseView.swift @@ -124,7 +124,7 @@ extension ReactionStrip { } } - @objc func longPress(_ guesture: UILongPressGestureRecognizer) { + @objc func longPress(_: UILongPressGestureRecognizer) { // eat this event, let context menu to handle // if guesture.state == .began { postLongPress() } } diff --git a/Kimis/Interface/Component/NoteView/Reactions/ReactionStrip+UserList.swift b/Kimis/Interface/Component/NoteView/Reactions/ReactionStrip+UserList.swift index 4455d04..6f4cd1c 100644 --- a/Kimis/Interface/Component/NoteView/Reactions/ReactionStrip+UserList.swift +++ b/Kimis/Interface/Component/NoteView/Reactions/ReactionStrip+UserList.swift @@ -13,7 +13,10 @@ extension ReactionStrip { let reactionElement: ReactionElement let activityIndicator = UIActivityIndicatorView() - let userCollectionView = UserCollectionView() + let tableView = UsersListTableView() + var userList: [User] = [] { didSet { + view.setNeedsLayout() + } } // TODO: Current Api has limit for 100 users, make more! @@ -37,10 +40,10 @@ extension ReactionStrip { make.edges.equalToSuperview() } - contentView.addSubview(userCollectionView) - userCollectionView.alpha = 0 - userCollectionView.snp.makeConstraints { make in - make.edges.equalToSuperview() + tableView.alpha = 0 + contentView.addSubview(tableView) + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(8) } contentView.addSubview(activityIndicator) activityIndicator.snp.makeConstraints { make in @@ -85,229 +88,18 @@ extension ReactionStrip { forNote: noteId ) DispatchQueue.main.async { - withUIKitAnimation { - self.activityIndicator.alpha = 0 - self.userCollectionView.alpha = 1 - self.userCollectionView.userList = userList - } completion: { - self.activityIndicator.stopAnimating() - self.activityIndicator.isHidden = true + self.tableView.users = userList.compactMap { .converting($0) } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + withUIKitAnimation { + self.activityIndicator.alpha = 0 + self.tableView.alpha = 1 + } completion: { + self.activityIndicator.stopAnimating() + self.activityIndicator.isHidden = true + } } } } } } } - -extension ReactionStrip.UserListPopover { - class UserCollectionView: UIView, UICollectionViewDelegate { - let collectionView: UICollectionView - let layout = AlignedCollectionViewFlowLayout( - horizontalAlignment: .left, - verticalAlignment: .center - ) - var userList: [User] = [] { - didSet { applySnapshot() } - } - - static let inset: CGFloat = 4 - let cellHeight: CGFloat = NSAttributedString(string: "M\nM", attributes: [ - .font: UIFont.systemFont(ofSize: CGFloat(AppConfig.current.defaultNoteFontSize)), - ]) - .measureHeight(usingWidth: 100, lineLimit: 2, lineBreakMode: .byWordWrapping) - + inset - - var contentWidth: CGFloat { - collectionView.frame.width - - collectionView.contentInset.left - - collectionView.contentInset.right - } - - var cellSize: CGSize { - .init(width: contentWidth, height: cellHeight) - } - - init() { - collectionView = .init(frame: .zero, collectionViewLayout: layout) - super.init(frame: .zero) - addSubview(collectionView) -// collectionView.register(SimpleSectionHeader.self, forCellWithReuseIdentifier: SimpleSectionHeader.headerId) - collectionView.register(UserCollectionCellView.self, forCellWithReuseIdentifier: String(describing: UserCollectionCellView.self)) - collectionView.contentInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) - collectionView.delegate = self - } - - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError() - } - - enum Section { case main } - typealias DataSource = UICollectionViewDiffableDataSource - typealias Snapshot = NSDiffableDataSourceSnapshot - - private lazy var dataSource = makeDataSource() - func makeDataSource() -> DataSource { - let dataSource = DataSource( - collectionView: collectionView, - cellProvider: { collectionView, indexPath, user -> - UICollectionViewCell? in - let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: String(describing: UserCollectionCellView.self), - for: indexPath - ) as? UserCollectionCellView - cell?.load(user: user) - return cell - } - ) - return dataSource - } - - func applySnapshot(animatingDifferences: Bool = true) { - var snapshot = Snapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(userList) - dataSource.apply(snapshot, animatingDifferences: animatingDifferences) - } - - var prevWidth: CGFloat = 0 - override func layoutSubviews() { - super.layoutSubviews() - collectionView.frame = bounds - if prevWidth != collectionView.frame.width { - withUIKitAnimation { - self.layout.estimatedItemSize = self.cellSize - self.layout.itemSize = self.cellSize - self.collectionView.performBatchUpdates(nil, completion: nil) - self.collectionView.layoutIfNeeded() - } - } - } - } - - class UserCollectionCellView: UICollectionViewCell { - let insetView = UIView() - let avatarView = AvatarView() - let usernameView = TextView.noneInteractive() - - override init(frame: CGRect) { - super.init(frame: frame) - setup() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setup() - } - - static let inset: CGFloat = 4 - - func setup() { - contentView.addSubview(insetView) - insetView.addSubview(avatarView) - insetView.addSubview(usernameView) - usernameView.textAlignment = .left - usernameView.textContainer.maximumNumberOfLines = 2 - usernameView.textContainer.lineBreakMode = .byTruncatingTail - insetView.clipsToBounds = true - insetView.layer.cornerRadius = IH.contentMiniItemCornerRadius -// let thatColor = UIColor.accent.withAlphaComponent(0.1) -// insetView.backgroundColor = thatColor -// insetView.layer.borderColor = thatColor.cgColor -// insetView.layer.borderWidth = 1 - } - - override func prepareForReuse() { - super.prepareForReuse() - avatarView.clear() - usernameView.text = "" - } - - override func layoutSubviews() { - super.layoutSubviews() - insetView.frame = contentView.bounds.inset(by: .init( - top: Self.inset, left: Self.inset, bottom: Self.inset, right: Self.inset - )) - avatarView.frame = .init( - x: 0, - y: 0, - width: insetView.bounds.height, - height: insetView.bounds.height - ) - let nameContainerWidth = insetView.bounds.width - avatarView.frame.width - Self.inset * 3 - let textHeight = usernameView.attributedText.measureHeight( - usingWidth: nameContainerWidth, - lineLimit: usernameView.textContainer.maximumNumberOfLines, - lineBreakMode: usernameView.textContainer.lineBreakMode - ) - if textHeight > 0 { - usernameView.frame = .init( - x: avatarView.frame.width + Self.inset * 2, - y: (insetView.bounds.height - textHeight) / 2, - width: nameContainerWidth, - height: textHeight - ) - } else { - usernameView.frame = .init( - x: avatarView.frame.width + Self.inset * 2, - y: 0, - width: nameContainerWidth, - height: insetView.bounds.height - ) - } - } - - func load(user: User) { - avatarView.loadImage(with: .init( - url: user.avatarUrl, - blurHash: user.avatarBlurHash, - sensitive: false - )) - let textParser = TextParser() - usernameView.attributedText = textParser.compileRenoteUserHeader(with: user, lineBreak: true) - } - - func setWidth(_ width: CGFloat) { - insetView.snp.updateConstraints { make in - make.width.equalTo(width) - } - } - } - -// class SimpleSectionHeader: UICollectionReusableView { -// let label = UILabel() -// let effect: UIView -// -// static let headerId = "wiki.qaq.SimpleSectionHeader" -// -// override init(frame: CGRect) { -// let blur = UIBlurEffect(style: .regular) -// let effect = UIVisualEffectView(effect: blur) -// self.effect = effect -// -// label.textAlignment = .left -// label.font = .systemFont(ofSize: 12, weight: .semibold) -// label.alpha = 0.5 -// -// super.init(frame: frame) -// -// addSubview(effect) -// addSubview(label) -// } -// -// override func layoutSubviews() { -// super.layoutSubviews() -// label.frame = bounds.inset(by: UIEdgeInsets(horizontal: 4, vertical: 0)) -// effect.frame = bounds.inset(by: UIEdgeInsets(horizontal: -50, vertical: 0)) -// } -// -// @available(*, unavailable) -// required init?(coder _: NSCoder) { -// fatalError() -// } -// -// override func prepareForReuse() { -// label.text = "" -// } -// } -} diff --git a/Kimis/Interface/Component/UserSimpleBannerListTableView/UserCell/UserSimpleBannerCell+Context.swift b/Kimis/Interface/Component/UserSimpleBannerListTableView/UserCell/UserSimpleBannerCell+Context.swift new file mode 100644 index 0000000..a3db550 --- /dev/null +++ b/Kimis/Interface/Component/UserSimpleBannerListTableView/UserCell/UserSimpleBannerCell+Context.swift @@ -0,0 +1,32 @@ +// +// UserCell.swift +// Kimis +// +// Created by Lakr Aream on 2022/12/30. +// + +import Combine +import Source +import UIKit + +extension UserSimpleBannerCell { + class Context: Identifiable, Equatable, Hashable { + var id: Int { hashValue } + + var cellHeight: CGFloat = 0 + let profile: UserProfile? + var snapshot: (any AnySnapshot)? + + init(user: UserProfile) { + profile = user + } + + func hash(into hasher: inout Hasher) { + hasher.combine(profile) + } + + static func == (lhs: UserSimpleBannerCell.Context, rhs: UserSimpleBannerCell.Context) -> Bool { + lhs.hashValue == rhs.hashValue + } + } +} diff --git a/Kimis/Interface/Component/UserSimpleBannerListTableView/UserCell/UserSimpleBannerCell+Render.swift b/Kimis/Interface/Component/UserSimpleBannerListTableView/UserCell/UserSimpleBannerCell+Render.swift new file mode 100644 index 0000000..962967f --- /dev/null +++ b/Kimis/Interface/Component/UserSimpleBannerListTableView/UserCell/UserSimpleBannerCell+Render.swift @@ -0,0 +1,21 @@ +// +// UserCell.swift +// Kimis +// +// Created by Lakr Aream on 2022/12/30. +// + +import Combine +import Source +import UIKit + +extension UserSimpleBannerCell.Context { + func renderLayout(usingWidth width: CGFloat) { + // this is not thread safe impl, setting snapshot = nil may result empty cell + guard let profile else { return } + let transformedWidth = IH.containerWidth(usingWidth: width) + let snapshot = UserPreview.Snapshot(usingWidth: transformedWidth, user: profile) + cellHeight = snapshot.height + self.snapshot = snapshot + } +} diff --git a/Kimis/Interface/Component/UserSimpleBannerListTableView/UserCell/UserSimpleBannerCell.swift b/Kimis/Interface/Component/UserSimpleBannerListTableView/UserCell/UserSimpleBannerCell.swift new file mode 100644 index 0000000..799a97f --- /dev/null +++ b/Kimis/Interface/Component/UserSimpleBannerListTableView/UserCell/UserSimpleBannerCell.swift @@ -0,0 +1,65 @@ +// +// UserCell.swift +// Kimis +// +// Created by Lakr Aream on 2022/12/30. +// + +import Combine +import Source +import UIKit + +class UserSimpleBannerCell: TableViewCell { + static let id = "UserSimpleBannerCell" + + static let padding: CGFloat = IH.preferredViewPadding() + + let container: UIView = .init() + + let preview = UserSimpleBannerView() + + var context: Context? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = .clear + contentView.backgroundColor = .clear + contentView.addSubview(container) + container.addSubview(preview) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + let bounds = contentView.bounds + let width = IH.containerWidth(usingWidth: bounds.width) + let paddingInset = max(UserCell.padding, (bounds.width - width) / 2) + container.frame = CGRect( + x: paddingInset, + y: 0, + width: width - 2 * paddingInset, + height: bounds.height + ) + preview.frame = container.bounds + } + + override func prepareForReuse() { + super.prepareForReuse() + context = nil + contentView.backgroundColor = .clear + preview.snapshot = nil + } + + func load(_ context: Context) { + self.context = context + guard let snapshot = context.snapshot as? UserSimpleBannerView.Snapshot else { + assertionFailure() + return + } + preview.snapshot = snapshot + } +} diff --git a/Kimis/Interface/Component/UserSimpleBannerListTableView/UserSimpleBannerListTableView/UserSimpleBannerListTableView.swift b/Kimis/Interface/Component/UserSimpleBannerListTableView/UserSimpleBannerListTableView/UserSimpleBannerListTableView.swift new file mode 100644 index 0000000..bcf644f --- /dev/null +++ b/Kimis/Interface/Component/UserSimpleBannerListTableView/UserSimpleBannerListTableView/UserSimpleBannerListTableView.swift @@ -0,0 +1,143 @@ +// +// UsersListTableView.swift +// Kimis +// +// Created by Lakr Aream on 2022/12/30. +// + +import Combine +import Source +import UIKit + +class UserSimpleBannerListTableView: TableView, UITableViewDelegate, UITableViewDataSource, UIScrollViewDelegate { + @Published var users: [UserProfile] = [] + @Published var layoutWidth: CGFloat = 0 + @Published var scrollOffset: CGPoint = .zero + let refreshCaller = CurrentValueSubject(true) + + let progressView = ProgressFooterView() + + var _source: [UserSimpleBannerCell.Context] = [] { + didSet { reloadData() } + } + + let renderQueue = DispatchQueue(label: "wiki.qaq.render.user.list") + + override init() { + super.init() + + tableFooterView = progressView + tableFooterView?.frame.size.height = progressView.intrinsicContentSize.height + + register(UserSimpleBannerCell.self, forCellReuseIdentifier: UserCell.id) + register(FooterCountView.self, forHeaderFooterViewReuseIdentifier: FooterCountView.identifier) + + delegate = self + dataSource = self + + Publishers.CombineLatest3( + $users + .removeDuplicates() + .debounce(for: .seconds(0.1), scheduler: DispatchQueue.global()), + $layoutWidth + .removeDuplicates() + .filter { $0 > 0 } + .debounce(for: .seconds(0.1), scheduler: DispatchQueue.global()), + refreshCaller + ) + .debounce(for: .seconds(0.1), scheduler: DispatchQueue.global()) + .receive(on: renderQueue) + .sink { [weak self] input in + guard let self else { return } + withMainActor { + self.progressView.animate() + } + let context = input.0.map { UserSimpleBannerCell.Context(user: $0) } + let width = input.1 + context.forEach { $0.renderLayout(usingWidth: width - 2 * UserCell.padding) } + withMainActor { + self._source = context + self.progressView.stopAnimate() + } + } + .store(in: &cancellable) + } + + override func layoutSubviews() { + super.layoutSubviews() + if layoutWidth != bounds.width { + renderVisibleCellAndUpdate() + layoutWidth = bounds.width + } + } + + func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { + _source.count + } + + func tableView(_: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = dequeueReusableCell(withIdentifier: UserCell.id, for: indexPath) as! UserSimpleBannerCell + if let ctx = _source[safe: indexPath.row] { + cell.load(ctx) + } else { + cell.prepareForReuse() + } + return cell + } + + func tableView(_: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + _source[indexPath.row].cellHeight + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard let user = _source[safe: indexPath.row]?.profile else { return } + ControllerRouting.pushing(tag: .user, referencer: self, associatedData: user.userId) + } + + func tableView(_: UITableView, viewForFooterInSection _: Int) -> UIView? { + guard let footer = dequeueReusableHeaderFooterView( + withIdentifier: FooterCountView.identifier + ) as? FooterCountView else { + return nil + } + if _source.count > 0 { + footer.set(title: "\(_source.count) users(s)") + } else { + footer.set(title: "No User Data") + } + return footer + } + + func tableView(_: UITableView, heightForFooterInSection _: Int) -> CGFloat { + FooterCountView.footerHeight + } + + func tableView(_ tableView: UITableView, estimatedHeightForFooterInSection section: Int) -> CGFloat { + self.tableView(tableView, heightForFooterInSection: section) + } + + func renderVisibleCellAndUpdate() { + let visibleIndexPaths = indexPathsForVisibleRows ?? [] + for indexPath in visibleIndexPaths { + if let ctx = _source[safe: indexPath.row] { + ctx.renderLayout(usingWidth: bounds.width - 2 * UserCell.padding) + } + } + beginUpdates() + for indexPath in visibleIndexPaths { + guard let cell = cellForRow(at: indexPath) as? UserSimpleBannerCell else { + continue + } + guard let ctx = _source[safe: indexPath.row] else { + continue + } + cell.load(ctx) + } + endUpdates() + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + scrollOffset = scrollView.contentOffset + } +} diff --git a/Kimis/Interface/Component/UserSimpleBannerListTableView/UserSimpleBannerView/UserSimpleBannerView.swift b/Kimis/Interface/Component/UserSimpleBannerListTableView/UserSimpleBannerView/UserSimpleBannerView.swift new file mode 100644 index 0000000..d1b20d6 --- /dev/null +++ b/Kimis/Interface/Component/UserSimpleBannerListTableView/UserSimpleBannerView/UserSimpleBannerView.swift @@ -0,0 +1,192 @@ +// +// UserPreview.swift +// Kimis +// +// Created by Lakr Aream on 2022/12/30. +// + +import Source +import UIKit + +class UserSimpleBannerView: UIView { + static let usernameTextViewLimit = 2 + static let userDescTextViewLimit = 4 + static let defaultAvatarSize: CGFloat = 36 + + let avatarView = MKRoundedImageView() + let usernameTextView = TextView.noneInteractive() + let userDescTextView = TextView.noneInteractive() + + var snapshot: Snapshot? { + didSet { updateDataSource() } + } + + init() { + super.init(frame: .zero) + + addSubview(avatarView) + addSubview(usernameTextView) + addSubview(userDescTextView) + + usernameTextView.textContainer.maximumNumberOfLines = Self.usernameTextViewLimit + userDescTextView.textContainer.maximumNumberOfLines = Self.userDescTextViewLimit + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + override func layoutSubviews() { + super.layoutSubviews() + if let snapshot { + avatarView.frame = snapshot.avatarFrame + usernameTextView.frame = snapshot.usernameFrame + userDescTextView.frame = snapshot.userDescFrame + } else { + avatarView.frame = .zero + usernameTextView.frame = .zero + userDescTextView.frame = .zero + } + } + + func prepareForReuse() { + avatarView.loadImage(with: nil) + usernameTextView.attributedText = nil + userDescTextView.attributedText = nil + } + + func updateDataSource() { + guard let snapshot else { + prepareForReuse() + return + } + avatarView.loadImage(with: .init( + url: snapshot.user.avatarUrl, + blurHash: snapshot.user.avatarBlurhash + )) + usernameTextView.attributedText = snapshot.username + userDescTextView.attributedText = snapshot.userDesc + + setNeedsLayout() + } +} + +extension UserSimpleBannerView { + class Snapshot: AnySnapshot { + var id: UUID = .init() + + var renderHint: Any? + + var user: UserProfile = .init() + + var width: CGFloat = 0 + var height: CGFloat = 0 + + var avatarFrame: CGRect = .zero + var usernameFrame: CGRect = .zero + var userDescFrame: CGRect = .zero + + var username: NSMutableAttributedString = .init() + var userDesc: NSMutableAttributedString = .init() + + func hash(into hasher: inout Hasher) { + hasher.combine(width) + hasher.combine(user) + hasher.combine(height) + hasher.combine(avatarFrame) + hasher.combine(usernameFrame) + hasher.combine(userDescFrame) + hasher.combine(username) + hasher.combine(userDesc) + } + } +} + +extension UserSimpleBannerView.Snapshot { + convenience init(usingWidth width: CGFloat, user: UserProfile) { + self.init() + render(usingWidth: width, user: user) + } + + func render(usingWidth width: CGFloat, user: UserProfile) { + renderHint = user + render(usingWidth: width) + } + + func render(usingWidth width: CGFloat) { + prepareForRender() + defer { afterRender() } + + guard let user = renderHint as? UserProfile else { + assertionFailure() + return + } + + let spacing: CGFloat = IH.preferredParagraphStyleLineSpacing + + let textParser: TextParser = { + let parser = TextParser() + parser.options.fontSizeOffset = IH.preferredFontSizeOffset(usingWidth: width) + parser.options.compactPreview = true + parser.paragraphStyle.lineSpacing = spacing + parser.paragraphStyle.paragraphSpacing = 0 + return parser + }() + + let padding = IH.preferredPadding(usingWidth: width) + + let usernameText = textParser.compileUserHeader(with: User.converting(user), lineBreak: false) + let userDescText = textParser.compileUserDescriptionSimple(with: user) + + let avatarSize = UserPreview.defaultAvatarSize + IH.preferredAvatarSizeOffset(usingWidth: width) + let avatarFrame = CGRect( + x: 0, // paddings on the x-axis are handled in UserCell.swift + y: padding, + width: avatarSize, + height: avatarSize + ) + let contentAlign = avatarFrame.maxX + padding + let contentWidth = width - contentAlign + + let nameHeight = usernameText + .measureHeight(usingWidth: contentWidth, lineLimit: UserPreview.usernameTextViewLimit) + let usernameTextFrame = CGRect( + x: contentAlign, + y: avatarFrame.minY, + width: contentWidth, + height: nameHeight + ) + + let descHeight = userDescText + .measureHeight(usingWidth: contentWidth, lineLimit: UserPreview.userDescTextViewLimit) + let userDescTextFrame = CGRect( + x: contentAlign, + y: usernameTextFrame.maxY + spacing, + width: contentWidth, + height: descHeight + ) + + let height = max(avatarFrame.maxY, userDescTextFrame.maxY) + padding + + self.width = width + self.user = user + self.height = height + self.avatarFrame = avatarFrame + usernameFrame = usernameTextFrame + userDescFrame = userDescTextFrame + username = usernameText + userDesc = userDescText + } + + func invalidate() { + user = .init() + width = 0 + height = 0 + avatarFrame = .zero + usernameFrame = .zero + userDescFrame = .zero + username = .init() + userDesc = .init() + } +} diff --git a/Kimis/Interface/Component/UsersListTableView/UserPreview/UserPreview.swift b/Kimis/Interface/Component/UsersListTableView/UserPreview/UserPreview.swift index 3803602..82cda56 100644 --- a/Kimis/Interface/Component/UsersListTableView/UserPreview/UserPreview.swift +++ b/Kimis/Interface/Component/UsersListTableView/UserPreview/UserPreview.swift @@ -15,7 +15,6 @@ class UserPreview: UIView { let avatarView = MKRoundedImageView() let usernameTextView = TextView.noneInteractive() - let userDescTextView = TextView.noneInteractive() var snapshot: Snapshot? { didSet { updateDataSource() } @@ -26,10 +25,8 @@ class UserPreview: UIView { addSubview(avatarView) addSubview(usernameTextView) - addSubview(userDescTextView) usernameTextView.textContainer.maximumNumberOfLines = Self.usernameTextViewLimit - userDescTextView.textContainer.maximumNumberOfLines = Self.userDescTextViewLimit } @available(*, unavailable) @@ -42,18 +39,15 @@ class UserPreview: UIView { if let snapshot { avatarView.frame = snapshot.avatarFrame usernameTextView.frame = snapshot.usernameFrame - userDescTextView.frame = snapshot.userDescFrame } else { avatarView.frame = .zero usernameTextView.frame = .zero - userDescTextView.frame = .zero } } func prepareForReuse() { avatarView.loadImage(with: nil) usernameTextView.attributedText = nil - userDescTextView.attributedText = nil } func updateDataSource() { @@ -66,7 +60,6 @@ class UserPreview: UIView { blurHash: snapshot.user.avatarBlurhash )) usernameTextView.attributedText = snapshot.username - userDescTextView.attributedText = snapshot.userDesc setNeedsLayout() } @@ -85,10 +78,8 @@ extension UserPreview { var avatarFrame: CGRect = .zero var usernameFrame: CGRect = .zero - var userDescFrame: CGRect = .zero var username: NSMutableAttributedString = .init() - var userDesc: NSMutableAttributedString = .init() func hash(into hasher: inout Hasher) { hasher.combine(width) @@ -96,9 +87,7 @@ extension UserPreview { hasher.combine(height) hasher.combine(avatarFrame) hasher.combine(usernameFrame) - hasher.combine(userDescFrame) hasher.combine(username) - hasher.combine(userDesc) } } } @@ -137,7 +126,6 @@ extension UserPreview.Snapshot { let padding = IH.preferredPadding(usingWidth: width) let usernameText = textParser.compileUserHeader(with: User.converting(user), lineBreak: false) - let userDescText = textParser.compileUserDescriptionSimple(with: user) let avatarSize = UserPreview.defaultAvatarSize + IH.preferredAvatarSizeOffset(usingWidth: width) let avatarFrame = CGRect( @@ -158,25 +146,14 @@ extension UserPreview.Snapshot { height: nameHeight ) - let descHeight = userDescText - .measureHeight(usingWidth: contentWidth, lineLimit: UserPreview.userDescTextViewLimit) - let userDescTextFrame = CGRect( - x: contentAlign, - y: usernameTextFrame.maxY + spacing, - width: contentWidth, - height: descHeight - ) - - let height = max(avatarFrame.maxY, userDescTextFrame.maxY) + padding + let height = max(avatarFrame.maxY, usernameTextFrame.maxY) + padding self.width = width self.user = user self.height = height self.avatarFrame = avatarFrame usernameFrame = usernameTextFrame - userDescFrame = userDescTextFrame username = usernameText - userDesc = userDescText } func invalidate() { @@ -185,8 +162,6 @@ extension UserPreview.Snapshot { height = 0 avatarFrame = .zero usernameFrame = .zero - userDescFrame = .zero username = .init() - userDesc = .init() } } diff --git a/Kimis/Interface/Controller/LoginController/LoginController.swift b/Kimis/Interface/Controller/LoginController/LoginController.swift index ef9e962..b5584e9 100644 --- a/Kimis/Interface/Controller/LoginController/LoginController.swift +++ b/Kimis/Interface/Controller/LoginController/LoginController.swift @@ -47,9 +47,6 @@ private class RealLoginController: ViewController, UITextFieldDelegate { ret.autocapitalizationType = .none ret.autocorrectionType = .no ret.textContentType = .URL - #if DEBUG - ret.text = "social.qaq.wiki" - #endif ret.placeholder = "[Host] eg: misskey.io (not username)" ret.textColor = .accent ret.returnKeyType = .done @@ -385,7 +382,7 @@ private class RealLoginController: ViewController, UITextFieldDelegate { @objc func openAcknowledge() { UIApplication.shared.open( - URL(string: "https://social.qaq.wiki/@Lakr233")!, + URL(string: "https://github.com/Lakr233/Kimis")!, options: [:], completionHandler: nil ) diff --git a/Kimis/Interface/Routing/ControllerRouting.swift b/Kimis/Interface/Routing/ControllerRouting.swift index 51b4a36..87b90b1 100644 --- a/Kimis/Interface/Routing/ControllerRouting.swift +++ b/Kimis/Interface/Routing/ControllerRouting.swift @@ -88,9 +88,13 @@ enum ControllerRouting { } private static func findPushTarget(referencer: UIViewController?) -> UIViewController? { - guard let from = referencer ?? UIWindow.mainWindow?.topController else { + guard var from = referencer ?? UIWindow.mainWindow?.topController else { return nil } + while let pop = from.popoverPresentationController { + pop.presentedViewController.dismiss(animated: true) + from = pop.presentingViewController + } var splitLookup: UIViewController? = from while let parent = splitLookup?.parent { if let split = parent as? LLSplitController {