Autocomplete mentions

This commit is contained in:
Shadowfacts 2025-02-28 00:24:50 -05:00
parent 86e1ac495a
commit 4dbb7a372a
13 changed files with 347 additions and 99 deletions

View File

@ -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 }
}
}

View File

@ -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)
}

View File

@ -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 }
}

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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()

View File

@ -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<FocusableField?>.Binding
focusedField: FocusState<FocusableField?>.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<FocusableField?>.Binding
focusedField: FocusState<FocusableField?>.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<FocusableField?>.Binding
focusedField: FocusState<FocusableField?>.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<FocusableField?>.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:)))
}
}

View File

@ -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)
if let delegate {
delegate.displayNameLabel(account: account, style: .body, size: 16)
.lineLimit(1)
}
Text(verbatim: "@\(account.acct)")
.font(.body.weight(.light))

View File

@ -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<Draft>
@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

View File

@ -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)
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,7 +50,8 @@ struct ReplyStatusView: View {
}
})
replyContentView(status) { newHeight in
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 {
@ -60,6 +61,7 @@ struct ReplyStatusView: View {
.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)

View File

@ -13,21 +13,9 @@ import WebURLFoundationExtras
import Combine
import TuskerPreferences
import Pachyderm
import GalleryVC
class ShareHostingController: UIHostingController<ShareHostingController.View> {
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<ShareHostingController.View> {
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<ShareHostingController.View> {
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())
}
}

View File

@ -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;

View File

@ -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<ComposeHostingController.Vie
state: state,
mastodonController: mastodonController,
config: _config,
currentAccount: _currentAccount
currentAccount: _currentAccount,
delegate: ComposeUIDelegateImpl(mastodonController: mastodonController)
)
super.init(rootView: rootView)
@ -85,26 +87,6 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
config.dismiss = { [weak self] in self?.dismiss(mode: $0) }
config.presentAssetPicker = { [unowned self] in self.presentAssetPicker(completion: $0) }
config.presentDrawing = { [unowned self] in self.presentDrawing($0, completion: $1) }
config.userActivityForDraft = { [mastodonController] in
let activity = UserActivityManager.editDraftActivity(id: $0.id, accountID: mastodonController.accountInfo!.id)
activity.displaysAuxiliaryScene = true
return NSItemProvider(object: activity)
}
config.fetchAvatar = { @MainActor in await ImageCache.avatars.get($0).1 }
config.displayNameLabel = { AnyView(AccountDisplayNameView(account: $0, textStyle: $1, emojiSize: $2)) }
config.replyContentView = { [mastodonController] in AnyView(ComposeReplyContentView(status: $0, mastodonController: mastodonController, heightChanged: $1)) }
config.fetchImageAndGIFData = {
if case let (.some(data), .some(image)) = await ImageCache.attachments.get($0) {
return (image, data)
} else {
return nil
}
}
config.makeGifvGalleryContentVC = {
let asset = AVAsset(url: $0)
let controller = GifvController(asset: asset)
return GifvGalleryContentViewController(controller: controller, url: $0, caption: nil)
}
self.config = config
}
@ -209,17 +191,20 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
@ObservedObject @ObservableObjectBox private var config: ComposeUIConfig
@ObservedObject @ObservableObjectBox private var currentAccount: AccountMO?
let state: ComposeViewState
fileprivate let delegate: ComposeUIDelegateImpl
fileprivate init(
state: ComposeViewState,
mastodonController: MastodonController,
config: ObservableObjectBox<ComposeUIConfig>,
currentAccount: ObservableObjectBox<AccountMO?>
currentAccount: ObservableObjectBox<AccountMO?>,
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<ComposeHostingController.Vie
state: state,
mastodonController: mastodonController,
currentAccount: currentAccount,
config: config
config: config,
delegate: delegate
)
.task {
if currentAccount == nil,
@ -337,3 +323,43 @@ extension ComposeHostingController: ComposeDrawingViewControllerDelegate {
drawingCompletion = nil
}
}
private class ComposeUIDelegateImpl: ComposeUIDelegate {
let mastodonController: MastodonController
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
}
func fetchAvatar(url: URL) async -> 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))
}
}