From 4dbb7a372a0a1282f0398ec89caba3e740f2566c Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 28 Feb 2025 00:24:50 -0500 Subject: [PATCH] Autocomplete mentions --- .../Sources/ComposeUI/ComposeUIConfig.swift | 35 +++- .../Attachments/AttachmentThumbnailView.swift | 4 +- .../AttachmentsGalleryDataSource.swift | 7 +- .../Attachments/AttachmentsSection.swift | 18 +- .../AutocompleteMentionView.swift | 159 ++++++++++++++++++ .../Views/Autocomplete/AutocompleteView.swift | 3 + .../Sources/ComposeUI/Views/ComposeView.swift | 37 ++-- .../Sources/ComposeUI/Views/DraftEditor.swift | 12 +- .../Sources/ComposeUI/Views/DraftsView.swift | 4 +- .../ComposeUI/Views/ReplyStatusView.swift | 28 +-- ShareExtension/ShareHostingController.swift | 60 ++++--- Tusker.xcodeproj/project.pbxproj | 7 + .../Compose/ComposeHostingController.swift | 72 +++++--- 13 files changed, 347 insertions(+), 99 deletions(-) create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteMentionView.swift diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift index 635e75ce..46ac96ae 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift @@ -29,23 +29,46 @@ public struct ComposeUIConfig { public var dismiss: @MainActor (DismissMode) -> Void = { _ in } public var presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)? public var presentDrawing: ((PKDrawing, @escaping (PKDrawing) -> Void) -> Void)? - public var userActivityForDraft: ((Draft) -> NSItemProvider?) = { _ in nil } - public var fetchAvatar: AvatarImageView.FetchAvatar = { _ in nil } - public var displayNameLabel: (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView = { _, _, _ in AnyView(EmptyView()) } - public var replyContentView: (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView = { _, _ in AnyView(EmptyView()) } - public var fetchImageAndGIFData: (URL) async -> (UIImage, Data)? = { _ in nil } - public var makeGifvGalleryContentVC: (URL) -> (any GalleryContentViewController)? = { _ in nil } public init() { } } +// These callbacks don't depend on the UIHostingController, so we stick them in a separate object +// in order to more easily get them into the input accessory toolbar which doesn't inherit the environment +// since it lives outside the regular hierarchy. +@MainActor +public protocol ComposeUIDelegate: AnyObject { + func fetchAvatar(url: URL) async -> UIImage? + + func displayNameLabel(account: any AccountProtocol, style: Font.TextStyle, size: CGFloat) -> AnyView + + func fetchImageAndGIFData(url: URL) async -> (UIImage, Data)? + + func makeGifvGalleryContentVC(url: URL) -> (any GalleryContentViewController)? + + func userActivityForDraft(_ draft: Draft) -> NSItemProvider? + + func replyContentView(status: any StatusProtocol, heightChanged: @escaping (CGFloat) -> Void) -> AnyView +} + + private struct ComposeUIConfigEnvironmentKey: EnvironmentKey { static let defaultValue = ComposeUIConfig() } + +private struct ComposeUIDelegateEnvironmentKey: EnvironmentKey { + static var defaultValue: (any ComposeUIDelegate)? { nil } +} + extension EnvironmentValues { var composeUIConfig: ComposeUIConfig { get { self[ComposeUIConfigEnvironmentKey.self] } set { self[ComposeUIConfigEnvironmentKey.self] = newValue } } + + var composeUIDelegate: (any ComposeUIDelegate)? { + get { self[ComposeUIDelegateEnvironmentKey.self] } + set { self[ComposeUIDelegateEnvironmentKey.self] = newValue } + } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentThumbnailView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentThumbnailView.swift index 9de4343d..9982349a 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentThumbnailView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentThumbnailView.swift @@ -31,7 +31,7 @@ private struct AttachmentThumbnailViewContent: View { var contentMode: ContentMode = .fit var thumbnailSize: CGSize? @State private var mode: Mode = .empty - @Environment(\.composeUIConfig.fetchImageAndGIFData) private var fetchImageAndGIFData + @Environment(\.composeUIDelegate) private var delegate var body: some View { switch mode { @@ -59,7 +59,7 @@ private struct AttachmentThumbnailViewContent: View { case .editing(_, let kind, let url): switch kind { case .image: - if let (image, _) = await fetchImageAndGIFData(url) { + if let (image, _) = await delegate?.fetchImageAndGIFData(url: url) { self.mode = .image(image) } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentsGalleryDataSource.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentsGalleryDataSource.swift index 9147cecd..5ad6554c 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentsGalleryDataSource.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentsGalleryDataSource.swift @@ -12,8 +12,7 @@ import Photos struct AttachmentsGalleryDataSource: GalleryDataSource { let collectionView: UICollectionView - let fetchImageAndGIFData: (URL) async -> (UIImage, Data)? - let makeGifvGalleryContentVC: (URL) -> (any GalleryContentViewController)? + let delegate: (any ComposeUIDelegate)? let attachmentAtIndex: (Int) -> DraftAttachment? func galleryItemsCount() -> Int { @@ -29,7 +28,7 @@ struct AttachmentsGalleryDataSource: GalleryDataSource { switch kind { case .image: content = LoadingGalleryContentViewController(caption: nil) { - if let (image, data) = await fetchImageAndGIFData(url) { + if let (image, data) = await delegate?.fetchImageAndGIFData(url: url) { let gifController: GIFController? = if url.pathExtension == "gif" { GIFController(gifData: data) } else { @@ -43,7 +42,7 @@ struct AttachmentsGalleryDataSource: GalleryDataSource { case .video, .audio: content = VideoGalleryContentViewController(url: url, caption: nil) case .gifv: - content = LoadingGalleryContentViewController(caption: nil) { makeGifvGalleryContentVC(url) } + content = LoadingGalleryContentViewController(caption: nil) { delegate?.makeGifvGalleryContentVC(url: url) } case .unknown: content = LoadingGalleryContentViewController(caption: nil) { nil } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentsSection.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentsSection.swift index 83485a35..aacb963a 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentsSection.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentsSection.swift @@ -98,15 +98,13 @@ private struct WrappedCollectionView: UIViewControllerRepresentable { @ObservedObject var draft: Draft let spacing: CGFloat let minItemSize: CGFloat - @Environment(\.composeUIConfig.fetchImageAndGIFData) private var fetchImageAndGIFData - @Environment(\.composeUIConfig.makeGifvGalleryContentVC) private var makeGifvGalleryContentVC + @Environment(\.composeUIDelegate) private var delegate func makeUIViewController(context: Context) -> WrappedCollectionViewController { WrappedCollectionViewController( spacing: spacing, minItemSize: minItemSize, - fetchImageAndGIFData: fetchImageAndGIFData, - makeGifvGalleryContentVC: makeGifvGalleryContentVC + delegate: delegate ) } @@ -174,8 +172,7 @@ private class WrappedCollectionViewController: UIViewController { fileprivate var currentInteractiveMoveStartOffsetInCell: CGPoint? fileprivate var currentInteractiveMoveCell: HostingCollectionViewCell? fileprivate var addAttachment: ((DraftAttachment) -> Void)? = nil - fileprivate var fetchImageAndGIFData: (URL) async -> (UIImage, Data)? - fileprivate var makeGifvGalleryContentVC: (URL) -> (any GalleryContentViewController)? + fileprivate var delegate: (any ComposeUIDelegate)? var collectionView: UICollectionView { view as! UICollectionView @@ -184,13 +181,11 @@ private class WrappedCollectionViewController: UIViewController { init( spacing: CGFloat, minItemSize: CGFloat, - fetchImageAndGIFData: @escaping (URL) async -> (UIImage, Data)?, - makeGifvGalleryContentVC: @escaping (URL) -> (any GalleryContentViewController)? + delegate: (any ComposeUIDelegate)? ) { self.spacing = spacing self.minItemSize = minItemSize - self.fetchImageAndGIFData = fetchImageAndGIFData - self.makeGifvGalleryContentVC = makeGifvGalleryContentVC + self.delegate = delegate super.init(nibName: nil, bundle: nil) } @@ -352,8 +347,7 @@ extension WrappedCollectionViewController: UICollectionViewDelegate { } let dataSource = AttachmentsGalleryDataSource( collectionView: collectionView, - fetchImageAndGIFData: self.fetchImageAndGIFData, - makeGifvGalleryContentVC: self.makeGifvGalleryContentVC + delegate: delegate ) { [dataSource] in let item = dataSource?.itemIdentifier(for: IndexPath(item: $0, section: 0)) switch item { diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteMentionView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteMentionView.swift new file mode 100644 index 00000000..f6dbe996 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteMentionView.swift @@ -0,0 +1,159 @@ +// +// AutocompleteMentionView.swift +// ComposeUI +// +// Created by Shadowfacts on 2/27/25. +// + +import SwiftUI +import Pachyderm +import TuskerComponents +import TuskerPreferences + +struct AutocompleteMentionView: View { + let query: String + let mastodonController: any ComposeMastodonContext + @State private var loading = false + @State private var accounts: [AnyAccount] = [] + @FocusedInput private var input + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 8) { + if accounts.isEmpty { + if loading { + ProgressView() + .progressViewStyle(.circular) + } else { + Text("No accounts found") + .font(.caption.italic()) + } + } + + ForEach(accounts) { account in + MentionButton(account: account) { + self.autocomplete(account: account) + } + } + + Spacer() + } + .padding(.horizontal, 8) + .frame(height: ComposeToolbarView.autocompleteHeight) + .animation(.snappy, value: accounts) + } + .task(id: query) { + await queryChanged() + } + } + + private func autocomplete(account: AnyAccount) { + input?.autocomplete(with: "@\(account.value.acct)") + } + + private func queryChanged() async { + guard !query.isEmpty else { + loading = false + accounts = [] + return + } + + loading = true + + let localSearchTask = Task { + // we only want to search locally if the API call takes more than .25s or it fails + try await Task.sleep(nanoseconds: 250 * NSEC_PER_MSEC) + + let results = self.mastodonController.searchCachedAccounts(query: query) + try Task.checkCancellation() + + loading = false + if !results.isEmpty { + self.updateAccounts(results.map { .init(value: $0)}) + } + } + + let accounts = try? await mastodonController.run(Client.searchForAccount(query: query)).0 + guard let accounts, + !Task.isCancelled else { + return + } + + localSearchTask.cancel() + updateAccounts(accounts.map { .init(value: $0) }) + + loading = false + } + + private func updateAccounts(_ accounts: [AnyAccount]) { + // when sorting account suggestions, ignore the domain component of the acct unless the user is typing it themself + let ignoreDomain = !query.contains("@") + + self.accounts = accounts + .map { account in + let fuzzyStr = ignoreDomain ? String(account.value.acct.split(separator: "@").first!) : account.value.acct + return (account, FuzzyMatcher.match(pattern: query, str: fuzzyStr)) + } + .filter(\.1.matched) + .map { (account, res) in + // give higher weight to accounts tha the user follows or is followed by + var score = res.score + if let relationship = mastodonController.cachedRelationship(for: account.value.id) { + if relationship.following { + score += 3 + } + if relationship.followedBy { + score += 2 + } + } + return (account, score) + } + .sorted { $0.1 > $1.1 } + .map(\.0) + } +} + +private struct MentionButton: View { + let account: AnyAccount + let autocomplete: () -> Void + @PreferenceObserving(\.$avatarStyle) private var avatarStyle + @Environment(\.composeUIDelegate) private var delegate + + var body: some View { + Button(action: autocomplete) { + HStack(spacing: 4) { + AvatarImageView( + url: account.value.avatar, + size: 30, + style: avatarStyle == .circle ? .circle : .roundRect, + fetchAvatar: { await delegate?.fetchAvatar(url: $0) } + ) + + VStack(alignment: .leading) { + if let delegate { + delegate.displayNameLabel(account: account.value, style: .subheadline, size: 14) + } + + Text(verbatim: "@\(account.value.acct)") + .font(.caption) + .foregroundStyle(.primary) + } + } + } + // adds up to 44, ComposeToolbarView.autocompleteHeight + .frame(height: 30) + .padding(.vertical, 7) + } +} + +private struct AnyAccount: Equatable, Identifiable { + let value: any AccountProtocol + + var id: String { + value.id + } + + static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.value.id == rhs.value.id + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteView.swift index a87c04f4..7fb2165e 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteView.swift @@ -24,6 +24,9 @@ struct AutocompleteView: View { case .hashtag(let s): AutocompleteHashtagView(query: s, mastodonController: mastodonController) .composeToolbarBackground() + case .mention(let s): + AutocompleteMentionView(query: s, mastodonController: mastodonController) + .composeToolbarBackground() default: Color.red .composeToolbarBackground() diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift index b21bf0bd..ea0803e0 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift @@ -31,18 +31,21 @@ public struct ComposeView: View { let mastodonController: any ComposeMastodonContext let currentAccount: (any AccountProtocol)? let config: ComposeUIConfig + let delegate: (any ComposeUIDelegate)? @FocusState private var focusedField: FocusableField? public init( state: ComposeViewState, mastodonController: any ComposeMastodonContext, currentAccount: (any AccountProtocol)?, - config: ComposeUIConfig + config: ComposeUIConfig, + delegate: (any ComposeUIDelegate)? ) { self.state = state self.mastodonController = mastodonController self.currentAccount = currentAccount self.config = config + self.delegate = delegate } public var body: some View { @@ -54,9 +57,10 @@ public struct ComposeView: View { focusedField: $focusedField ) .environment(\.composeUIConfig, config) + .environment(\.composeUIDelegate, delegate) .environment(\.currentAccount, currentAccount) #if !targetEnvironment(macCatalyst) && !os(visionOS) - .injectInputAccessoryHost(state: state, mastodonController: mastodonController, focusedField: $focusedField) + .injectInputAccessoryHost(state: state, mastodonController: mastodonController, focusedField: $focusedField, delegate: delegate) #endif .onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext), perform: self.managedObjectsDidChange) } @@ -344,10 +348,11 @@ private extension View { func injectInputAccessoryHost( state: ComposeViewState, mastodonController: any ComposeMastodonContext, - focusedField: FocusState.Binding + focusedField: FocusState.Binding, + delegate: (any ComposeUIDelegate)? ) -> some View { if #available(iOS 16.0, *) { - self.modifier(InputAccessoryHostInjector(state: state, mastodonController: mastodonController, focusedField: focusedField)) + self.modifier(InputAccessoryHostInjector(state: state, mastodonController: mastodonController, focusedField: focusedField, delegate: delegate)) } else { self } @@ -363,14 +368,15 @@ private struct InputAccessoryHostInjector: ViewModifier { init( state: ComposeViewState, mastodonController: any ComposeMastodonContext, - focusedField: FocusState.Binding + focusedField: FocusState.Binding, + delegate: (any ComposeUIDelegate)? ) { - self._factory = StateObject(wrappedValue: ViewFactory(state: state, mastodonController: mastodonController, focusedField: focusedField)) + self._factory = StateObject(wrappedValue: ViewFactory(state: state, mastodonController: mastodonController, focusedField: focusedField, delegate: delegate)) } func body(content: Content) -> some View { content - .environment(\.inputAccessoryToolbarHost, factory.view) + .environment(\.inputAccessoryToolbarHost, factory.controller.view) .onChange(of: ComposeInputEquatableBox(input: composeInput ?? nil)) { newValue in factory.focusedInput = newValue.input ?? nil } @@ -378,20 +384,21 @@ private struct InputAccessoryHostInjector: ViewModifier { @MainActor private class ViewFactory: ObservableObject { - let view: UIView + let controller: UIViewController @MutableObservableBox var focusedInput: (any ComposeInput)? - + init( state: ComposeViewState, mastodonController: any ComposeMastodonContext, - focusedField: FocusState.Binding + focusedField: FocusState.Binding, + delegate: (any ComposeUIDelegate)? ) { self._focusedInput = MutableObservableBox(wrappedValue: nil) - let view = InputAccessoryToolbarView(state: state, mastodonController: mastodonController, focusedField: focusedField, focusedInputBox: _focusedInput) + let view = InputAccessoryToolbarView(state: state, mastodonController: mastodonController, focusedField: focusedField, focusedInputBox: _focusedInput, delegate: delegate) let controller = UIHostingController(rootView: view) controller.sizingOptions = .intrinsicContentSize controller.view.autoresizingMask = .flexibleHeight - self.view = controller.view + self.controller = controller } } } @@ -420,23 +427,27 @@ private struct InputAccessoryToolbarView: View { let mastodonController: any ComposeMastodonContext @FocusState.Binding var focusedField: FocusableField? let focusedInputBox: MutableObservableBox<(any ComposeInput)?> + let delegate: (any ComposeUIDelegate)? @PreferenceObserving(\.$accentColor) private var accentColor init( state: ComposeViewState, mastodonController: any ComposeMastodonContext, focusedField: FocusState.Binding, - focusedInputBox: MutableObservableBox<(any ComposeInput)?> + focusedInputBox: MutableObservableBox<(any ComposeInput)?>, + delegate: (any ComposeUIDelegate)? ) { self.state = state self.mastodonController = mastodonController self._focusedField = focusedField self.focusedInputBox = focusedInputBox + self.delegate = delegate } var body: some View { ComposeToolbarView(draft: state.draft, mastodonController: mastodonController, focusedField: $focusedField) .environment(\.toolbarInjectedFocusedInputBox, focusedInputBox) + .environment(\.composeUIDelegate, delegate) .tint(accentColor.color.map(Color.init(uiColor:))) } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/DraftEditor.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/DraftEditor.swift index 6529da3c..6eb8abc4 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/DraftEditor.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/DraftEditor.swift @@ -57,14 +57,14 @@ struct DraftEditor: View { private struct AvatarView: View { let account: (any AccountProtocol)? @PreferenceObserving(\.$avatarStyle) private var avatarStyle - @Environment(\.composeUIConfig.fetchAvatar) private var fetchAvatar + @Environment(\.composeUIDelegate) private var delegate var body: some View { AvatarImageView( url: account?.avatar, size: 50, style: avatarStyle == .circle ? .circle : .roundRect, - fetchAvatar: fetchAvatar + fetchAvatar: { await delegate?.fetchAvatar(url: $0) } ) .accessibilityHidden(true) } @@ -72,12 +72,14 @@ private struct AvatarView: View { private struct AccountNameView: View { let account: any AccountProtocol - @Environment(\.composeUIConfig.displayNameLabel) private var displayNameLabel + @Environment(\.composeUIDelegate) private var delegate var body: some View { HStack(spacing: 4) { - displayNameLabel(account, .body, 16) - .lineLimit(1) + if let delegate { + delegate.displayNameLabel(account: account, style: .body, size: 16) + .lineLimit(1) + } Text(verbatim: "@\(account.acct)") .font(.body.weight(.light)) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/DraftsView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/DraftsView.swift index f3f4b4b5..00513f63 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/DraftsView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/DraftsView.swift @@ -59,7 +59,7 @@ private struct DraftsListView: View { let accountInfo: UserAccountInfo let selectDraft: (Draft) -> Void @FetchRequest(sortDescriptors: [SortDescriptor(\Draft.lastModified, order: .reverse)]) private var drafts: FetchedResults - @Environment(\.composeUIConfig.userActivityForDraft) private var userActivityForDraft + @Environment(\.composeUIDelegate) private var delegate var body: some View { List { @@ -77,7 +77,7 @@ private struct DraftsListView: View { } } .onDrag { - userActivityForDraft(draft) ?? NSItemProvider() + delegate?.userActivityForDraft(draft) ?? NSItemProvider() } } .onDelete { indices in diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift index c81c19a3..a1858fd0 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift @@ -16,9 +16,7 @@ struct ReplyStatusView: View { let globalFrameOutsideList: CGRect @PreferenceObserving(\.$avatarStyle) private var avatarStyle - @Environment(\.composeUIConfig.displayNameLabel) private var displayNameLabel - @Environment(\.composeUIConfig.replyContentView) private var replyContentView - @Environment(\.composeUIConfig.fetchAvatar) private var fetchAvatar + @Environment(\.composeUIDelegate) private var delegate @State private var displayNameHeight: CGFloat? @State private var contentHeight: CGFloat? @@ -31,9 +29,11 @@ struct ReplyStatusView: View { VStack(alignment: .leading, spacing: 0) { HStack { - displayNameLabel(status.account, .body, 17) - .lineLimit(1) - .layoutPriority(1) + if let delegate { + delegate.displayNameLabel(account: status.account, style: .body, size: 17) + .lineLimit(1) + .layoutPriority(1) + } Text(verbatim: "@\(status.account.acct)") .font(.body.weight(.light)) @@ -50,14 +50,16 @@ struct ReplyStatusView: View { } }) - replyContentView(status) { newHeight in - // otherwise, with long in-reply-to statuses, the main content text view position seems not to update - // and it ends up partially behind the header - DispatchQueue.main.async { - contentHeight = newHeight + if let delegate { + delegate.replyContentView(status: status) { newHeight in + // otherwise, with long in-reply-to statuses, the main content text view position seems not to update + // and it ends up partially behind the header + DispatchQueue.main.async { + contentHeight = newHeight + } } + .frame(height: contentHeight ?? 0) } - .frame(height: contentHeight ?? 0) } } .frame(minHeight: 50, alignment: .top) @@ -85,7 +87,7 @@ struct ReplyStatusView: View { url: status.account.avatar, size: 50, style: avatarStyle == .circle ? .circle : .roundRect, - fetchAvatar: fetchAvatar + fetchAvatar: { await delegate?.fetchAvatar(url: $0) } ) } .frame(width: 50, height: 50) diff --git a/ShareExtension/ShareHostingController.swift b/ShareExtension/ShareHostingController.swift index 4aaf841c..a7b7be71 100644 --- a/ShareExtension/ShareHostingController.swift +++ b/ShareExtension/ShareHostingController.swift @@ -13,21 +13,9 @@ import WebURLFoundationExtras import Combine import TuskerPreferences import Pachyderm +import GalleryVC class ShareHostingController: UIHostingController { - private static func fetchAvatar(_ url: URL) async -> UIImage? { - guard let (data, _) = try? await URLSession.shared.data(from: url), - let image = UIImage(data: data) else { - return nil - } - #if os(visionOS) - let size: CGFloat = 50 * 2 - #else - let size = 50 * UIScreen.main.scale - #endif - return await image.byPreparingThumbnail(ofSize: CGSize(width: size, height: size)) ?? image - } - @ObservableObjectBox private var config = ComposeUIConfig() private let accountSwitchingState: AccountSwitchingState private let state: ComposeViewState @@ -60,11 +48,6 @@ class ShareHostingController: UIHostingController { config.fillColor = Color(uiColor: .appFill) config.dismiss = { [unowned self] in self.dismiss(mode: $0) } - config.fetchAvatar = Self.fetchAvatar - config.displayNameLabel = { account, style, _ in - // TODO: move AccountDisplayNameView to TuskerComponents and use that here as well - AnyView(Text(account.displayName).font(.system(style))) - } self.config = config } @@ -101,7 +84,8 @@ class ShareHostingController: UIHostingController { state: state, mastodonController: accountSwitchingState.mastodonContext, currentAccount: currentAccount, - config: config + config: config, + delegate: ShareComposeUIDelegate.shared ) .onReceive(accountSwitchingState.$mastodonContext) { state.draft.accountID = $0.accountInfo!.id @@ -172,3 +156,41 @@ extension UIColor { } } } + +private class ShareComposeUIDelegate: ComposeUIDelegate { + static let shared = ShareComposeUIDelegate() + + func fetchAvatar(url: URL) async -> UIImage? { + guard let (data, _) = try? await URLSession.shared.data(from: url), + let image = UIImage(data: data) else { + return nil + } + #if os(visionOS) + let size: CGFloat = 50 * 2 + #else + let size = 50 * UIScreen.main.scale + #endif + return await image.byPreparingThumbnail(ofSize: CGSize(width: size, height: size)) ?? image + } + + func displayNameLabel(account: any AccountProtocol, style: Font.TextStyle, size: CGFloat) -> AnyView { + // TODO: move AccountDisplayNameView to TuskerComponents and use that here as well + AnyView(Text(account.displayName).font(.system(style))) + } + + func fetchImageAndGIFData(url: URL) async -> (UIImage, Data)? { + nil + } + + func makeGifvGalleryContentVC(url: URL) -> (any GalleryContentViewController)? { + nil + } + + func userActivityForDraft(_ draft: Draft) -> NSItemProvider? { + nil + } + + func replyContentView(status: any StatusProtocol, heightChanged: @escaping (CGFloat) -> Void) -> AnyView { + AnyView(EmptyView()) + } +} diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index bc46adc4..1e8d4192 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -363,6 +363,7 @@ D6EEDE932C3CF21800E10E51 /* AudioSessionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EEDE922C3CF21800E10E51 /* AudioSessionCoordinator.swift */; }; D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; }; D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; }; + D6F19A172D717DAF00008B88 /* GalleryVC in Frameworks */ = {isa = PBXBuildFile; productRef = D6F19A162D717DAF00008B88 /* GalleryVC */; }; D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */; }; D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; }; D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; }; @@ -838,6 +839,7 @@ files = ( D6A4532829EF665800032932 /* Pachyderm in Frameworks */, D6A4532A29EF665A00032932 /* TuskerPreferences in Frameworks */, + D6F19A172D717DAF00008B88 /* GalleryVC in Frameworks */, D6A4532629EF665600032932 /* InstanceFeatures in Frameworks */, D6A4532C29EF665D00032932 /* UserAccounts in Frameworks */, D6A4532429EF665200032932 /* ComposeUI in Frameworks */, @@ -1814,6 +1816,7 @@ D6A4532729EF665800032932 /* Pachyderm */, D6A4532929EF665A00032932 /* TuskerPreferences */, D6A4532B29EF665D00032932 /* UserAccounts */, + D6F19A162D717DAF00008B88 /* GalleryVC */, ); productName = ShareExtension; productReference = D6A4531329EF64BA00032932 /* ShareExtension.appex */; @@ -3389,6 +3392,10 @@ isa = XCSwiftPackageProductDependency; productName = TuskerPreferences; }; + D6F19A162D717DAF00008B88 /* GalleryVC */ = { + isa = XCSwiftPackageProductDependency; + productName = GalleryVC; + }; D6FA94E029B52898006AAC51 /* InstanceFeatures */ = { isa = XCSwiftPackageProductDependency; productName = InstanceFeatures; diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift index 01fd9ea2..ff08df35 100644 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -17,6 +17,7 @@ import CoreData #if canImport(Duckable) import Duckable #endif +import GalleryVC @MainActor protocol ComposeHostingControllerDelegate: AnyObject { @@ -51,7 +52,8 @@ class ComposeHostingController: UIHostingController, - currentAccount: ObservableObjectBox + currentAccount: ObservableObjectBox, + delegate: ComposeUIDelegateImpl ) { self.state = state self.mastodonController = mastodonController self._config = ObservedObject(wrappedValue: config) self._currentAccount = ObservedObject(wrappedValue: currentAccount) + self.delegate = delegate } var body: some SwiftUI.View { @@ -227,7 +212,8 @@ class ComposeHostingController: UIHostingController UIImage? { + await ImageCache.avatars.get(url).1 + } + + func displayNameLabel(account: any AccountProtocol, style: Font.TextStyle, size: CGFloat) -> AnyView { + AnyView(AccountDisplayNameView(account: account, textStyle: style, emojiSize: size)) + } + + func fetchImageAndGIFData(url: URL) async -> (UIImage, Data)? { + if case let (.some(data), .some(image)) = await ImageCache.attachments.get(url) { + return (image, data) + } else { + return nil + } + } + + func makeGifvGalleryContentVC(url: URL) -> (any GalleryContentViewController)? { + let asset = AVAsset(url: url) + let controller = GifvController(asset: asset) + return GifvGalleryContentViewController(controller: controller, url: url, caption: nil) + } + + func userActivityForDraft(_ draft: Draft) -> NSItemProvider? { + let activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: mastodonController.accountInfo!.id) + activity.displaysAuxiliaryScene = true + return NSItemProvider(object: activity) + } + + func replyContentView(status: any StatusProtocol, heightChanged: @escaping (CGFloat) -> Void) -> AnyView { + AnyView(ComposeReplyContentView(status: status, mastodonController: mastodonController, heightChanged: heightChanged)) + } +}