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 dismiss: @MainActor (DismissMode) -> Void = { _ in }
public var presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)? public var presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)?
public var presentDrawing: ((PKDrawing, @escaping (PKDrawing) -> 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() { 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 { private struct ComposeUIConfigEnvironmentKey: EnvironmentKey {
static let defaultValue = ComposeUIConfig() static let defaultValue = ComposeUIConfig()
} }
private struct ComposeUIDelegateEnvironmentKey: EnvironmentKey {
static var defaultValue: (any ComposeUIDelegate)? { nil }
}
extension EnvironmentValues { extension EnvironmentValues {
var composeUIConfig: ComposeUIConfig { var composeUIConfig: ComposeUIConfig {
get { self[ComposeUIConfigEnvironmentKey.self] } get { self[ComposeUIConfigEnvironmentKey.self] }
set { self[ComposeUIConfigEnvironmentKey.self] = newValue } 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 contentMode: ContentMode = .fit
var thumbnailSize: CGSize? var thumbnailSize: CGSize?
@State private var mode: Mode = .empty @State private var mode: Mode = .empty
@Environment(\.composeUIConfig.fetchImageAndGIFData) private var fetchImageAndGIFData @Environment(\.composeUIDelegate) private var delegate
var body: some View { var body: some View {
switch mode { switch mode {
@ -59,7 +59,7 @@ private struct AttachmentThumbnailViewContent: View {
case .editing(_, let kind, let url): case .editing(_, let kind, let url):
switch kind { switch kind {
case .image: case .image:
if let (image, _) = await fetchImageAndGIFData(url) { if let (image, _) = await delegate?.fetchImageAndGIFData(url: url) {
self.mode = .image(image) self.mode = .image(image)
} }

View File

@ -12,8 +12,7 @@ import Photos
struct AttachmentsGalleryDataSource: GalleryDataSource { struct AttachmentsGalleryDataSource: GalleryDataSource {
let collectionView: UICollectionView let collectionView: UICollectionView
let fetchImageAndGIFData: (URL) async -> (UIImage, Data)? let delegate: (any ComposeUIDelegate)?
let makeGifvGalleryContentVC: (URL) -> (any GalleryContentViewController)?
let attachmentAtIndex: (Int) -> DraftAttachment? let attachmentAtIndex: (Int) -> DraftAttachment?
func galleryItemsCount() -> Int { func galleryItemsCount() -> Int {
@ -29,7 +28,7 @@ struct AttachmentsGalleryDataSource: GalleryDataSource {
switch kind { switch kind {
case .image: case .image:
content = LoadingGalleryContentViewController(caption: nil) { 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" { let gifController: GIFController? = if url.pathExtension == "gif" {
GIFController(gifData: data) GIFController(gifData: data)
} else { } else {
@ -43,7 +42,7 @@ struct AttachmentsGalleryDataSource: GalleryDataSource {
case .video, .audio: case .video, .audio:
content = VideoGalleryContentViewController(url: url, caption: nil) content = VideoGalleryContentViewController(url: url, caption: nil)
case .gifv: case .gifv:
content = LoadingGalleryContentViewController(caption: nil) { makeGifvGalleryContentVC(url) } content = LoadingGalleryContentViewController(caption: nil) { delegate?.makeGifvGalleryContentVC(url: url) }
case .unknown: case .unknown:
content = LoadingGalleryContentViewController(caption: nil) { nil } content = LoadingGalleryContentViewController(caption: nil) { nil }
} }

View File

@ -98,15 +98,13 @@ private struct WrappedCollectionView: UIViewControllerRepresentable {
@ObservedObject var draft: Draft @ObservedObject var draft: Draft
let spacing: CGFloat let spacing: CGFloat
let minItemSize: CGFloat let minItemSize: CGFloat
@Environment(\.composeUIConfig.fetchImageAndGIFData) private var fetchImageAndGIFData @Environment(\.composeUIDelegate) private var delegate
@Environment(\.composeUIConfig.makeGifvGalleryContentVC) private var makeGifvGalleryContentVC
func makeUIViewController(context: Context) -> WrappedCollectionViewController { func makeUIViewController(context: Context) -> WrappedCollectionViewController {
WrappedCollectionViewController( WrappedCollectionViewController(
spacing: spacing, spacing: spacing,
minItemSize: minItemSize, minItemSize: minItemSize,
fetchImageAndGIFData: fetchImageAndGIFData, delegate: delegate
makeGifvGalleryContentVC: makeGifvGalleryContentVC
) )
} }
@ -174,8 +172,7 @@ private class WrappedCollectionViewController: UIViewController {
fileprivate var currentInteractiveMoveStartOffsetInCell: CGPoint? fileprivate var currentInteractiveMoveStartOffsetInCell: CGPoint?
fileprivate var currentInteractiveMoveCell: HostingCollectionViewCell? fileprivate var currentInteractiveMoveCell: HostingCollectionViewCell?
fileprivate var addAttachment: ((DraftAttachment) -> Void)? = nil fileprivate var addAttachment: ((DraftAttachment) -> Void)? = nil
fileprivate var fetchImageAndGIFData: (URL) async -> (UIImage, Data)? fileprivate var delegate: (any ComposeUIDelegate)?
fileprivate var makeGifvGalleryContentVC: (URL) -> (any GalleryContentViewController)?
var collectionView: UICollectionView { var collectionView: UICollectionView {
view as! UICollectionView view as! UICollectionView
@ -184,13 +181,11 @@ private class WrappedCollectionViewController: UIViewController {
init( init(
spacing: CGFloat, spacing: CGFloat,
minItemSize: CGFloat, minItemSize: CGFloat,
fetchImageAndGIFData: @escaping (URL) async -> (UIImage, Data)?, delegate: (any ComposeUIDelegate)?
makeGifvGalleryContentVC: @escaping (URL) -> (any GalleryContentViewController)?
) { ) {
self.spacing = spacing self.spacing = spacing
self.minItemSize = minItemSize self.minItemSize = minItemSize
self.fetchImageAndGIFData = fetchImageAndGIFData self.delegate = delegate
self.makeGifvGalleryContentVC = makeGifvGalleryContentVC
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
@ -352,8 +347,7 @@ extension WrappedCollectionViewController: UICollectionViewDelegate {
} }
let dataSource = AttachmentsGalleryDataSource( let dataSource = AttachmentsGalleryDataSource(
collectionView: collectionView, collectionView: collectionView,
fetchImageAndGIFData: self.fetchImageAndGIFData, delegate: delegate
makeGifvGalleryContentVC: self.makeGifvGalleryContentVC
) { [dataSource] in ) { [dataSource] in
let item = dataSource?.itemIdentifier(for: IndexPath(item: $0, section: 0)) let item = dataSource?.itemIdentifier(for: IndexPath(item: $0, section: 0))
switch item { 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): case .hashtag(let s):
AutocompleteHashtagView(query: s, mastodonController: mastodonController) AutocompleteHashtagView(query: s, mastodonController: mastodonController)
.composeToolbarBackground() .composeToolbarBackground()
case .mention(let s):
AutocompleteMentionView(query: s, mastodonController: mastodonController)
.composeToolbarBackground()
default: default:
Color.red Color.red
.composeToolbarBackground() .composeToolbarBackground()

View File

@ -31,18 +31,21 @@ public struct ComposeView: View {
let mastodonController: any ComposeMastodonContext let mastodonController: any ComposeMastodonContext
let currentAccount: (any AccountProtocol)? let currentAccount: (any AccountProtocol)?
let config: ComposeUIConfig let config: ComposeUIConfig
let delegate: (any ComposeUIDelegate)?
@FocusState private var focusedField: FocusableField? @FocusState private var focusedField: FocusableField?
public init( public init(
state: ComposeViewState, state: ComposeViewState,
mastodonController: any ComposeMastodonContext, mastodonController: any ComposeMastodonContext,
currentAccount: (any AccountProtocol)?, currentAccount: (any AccountProtocol)?,
config: ComposeUIConfig config: ComposeUIConfig,
delegate: (any ComposeUIDelegate)?
) { ) {
self.state = state self.state = state
self.mastodonController = mastodonController self.mastodonController = mastodonController
self.currentAccount = currentAccount self.currentAccount = currentAccount
self.config = config self.config = config
self.delegate = delegate
} }
public var body: some View { public var body: some View {
@ -54,9 +57,10 @@ public struct ComposeView: View {
focusedField: $focusedField focusedField: $focusedField
) )
.environment(\.composeUIConfig, config) .environment(\.composeUIConfig, config)
.environment(\.composeUIDelegate, delegate)
.environment(\.currentAccount, currentAccount) .environment(\.currentAccount, currentAccount)
#if !targetEnvironment(macCatalyst) && !os(visionOS) #if !targetEnvironment(macCatalyst) && !os(visionOS)
.injectInputAccessoryHost(state: state, mastodonController: mastodonController, focusedField: $focusedField) .injectInputAccessoryHost(state: state, mastodonController: mastodonController, focusedField: $focusedField, delegate: delegate)
#endif #endif
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext), perform: self.managedObjectsDidChange) .onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext), perform: self.managedObjectsDidChange)
} }
@ -344,10 +348,11 @@ private extension View {
func injectInputAccessoryHost( func injectInputAccessoryHost(
state: ComposeViewState, state: ComposeViewState,
mastodonController: any ComposeMastodonContext, mastodonController: any ComposeMastodonContext,
focusedField: FocusState<FocusableField?>.Binding focusedField: FocusState<FocusableField?>.Binding,
delegate: (any ComposeUIDelegate)?
) -> some View { ) -> some View {
if #available(iOS 16.0, *) { 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 { } else {
self self
} }
@ -363,14 +368,15 @@ private struct InputAccessoryHostInjector: ViewModifier {
init( init(
state: ComposeViewState, state: ComposeViewState,
mastodonController: any ComposeMastodonContext, 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 { func body(content: Content) -> some View {
content content
.environment(\.inputAccessoryToolbarHost, factory.view) .environment(\.inputAccessoryToolbarHost, factory.controller.view)
.onChange(of: ComposeInputEquatableBox(input: composeInput ?? nil)) { newValue in .onChange(of: ComposeInputEquatableBox(input: composeInput ?? nil)) { newValue in
factory.focusedInput = newValue.input ?? nil factory.focusedInput = newValue.input ?? nil
} }
@ -378,20 +384,21 @@ private struct InputAccessoryHostInjector: ViewModifier {
@MainActor @MainActor
private class ViewFactory: ObservableObject { private class ViewFactory: ObservableObject {
let view: UIView let controller: UIViewController
@MutableObservableBox var focusedInput: (any ComposeInput)? @MutableObservableBox var focusedInput: (any ComposeInput)?
init( init(
state: ComposeViewState, state: ComposeViewState,
mastodonController: any ComposeMastodonContext, mastodonController: any ComposeMastodonContext,
focusedField: FocusState<FocusableField?>.Binding focusedField: FocusState<FocusableField?>.Binding,
delegate: (any ComposeUIDelegate)?
) { ) {
self._focusedInput = MutableObservableBox(wrappedValue: nil) 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) let controller = UIHostingController(rootView: view)
controller.sizingOptions = .intrinsicContentSize controller.sizingOptions = .intrinsicContentSize
controller.view.autoresizingMask = .flexibleHeight controller.view.autoresizingMask = .flexibleHeight
self.view = controller.view self.controller = controller
} }
} }
} }
@ -420,23 +427,27 @@ private struct InputAccessoryToolbarView: View {
let mastodonController: any ComposeMastodonContext let mastodonController: any ComposeMastodonContext
@FocusState.Binding var focusedField: FocusableField? @FocusState.Binding var focusedField: FocusableField?
let focusedInputBox: MutableObservableBox<(any ComposeInput)?> let focusedInputBox: MutableObservableBox<(any ComposeInput)?>
let delegate: (any ComposeUIDelegate)?
@PreferenceObserving(\.$accentColor) private var accentColor @PreferenceObserving(\.$accentColor) private var accentColor
init( init(
state: ComposeViewState, state: ComposeViewState,
mastodonController: any ComposeMastodonContext, mastodonController: any ComposeMastodonContext,
focusedField: FocusState<FocusableField?>.Binding, focusedField: FocusState<FocusableField?>.Binding,
focusedInputBox: MutableObservableBox<(any ComposeInput)?> focusedInputBox: MutableObservableBox<(any ComposeInput)?>,
delegate: (any ComposeUIDelegate)?
) { ) {
self.state = state self.state = state
self.mastodonController = mastodonController self.mastodonController = mastodonController
self._focusedField = focusedField self._focusedField = focusedField
self.focusedInputBox = focusedInputBox self.focusedInputBox = focusedInputBox
self.delegate = delegate
} }
var body: some View { var body: some View {
ComposeToolbarView(draft: state.draft, mastodonController: mastodonController, focusedField: $focusedField) ComposeToolbarView(draft: state.draft, mastodonController: mastodonController, focusedField: $focusedField)
.environment(\.toolbarInjectedFocusedInputBox, focusedInputBox) .environment(\.toolbarInjectedFocusedInputBox, focusedInputBox)
.environment(\.composeUIDelegate, delegate)
.tint(accentColor.color.map(Color.init(uiColor:))) .tint(accentColor.color.map(Color.init(uiColor:)))
} }
} }

View File

@ -57,14 +57,14 @@ struct DraftEditor: View {
private struct AvatarView: View { private struct AvatarView: View {
let account: (any AccountProtocol)? let account: (any AccountProtocol)?
@PreferenceObserving(\.$avatarStyle) private var avatarStyle @PreferenceObserving(\.$avatarStyle) private var avatarStyle
@Environment(\.composeUIConfig.fetchAvatar) private var fetchAvatar @Environment(\.composeUIDelegate) private var delegate
var body: some View { var body: some View {
AvatarImageView( AvatarImageView(
url: account?.avatar, url: account?.avatar,
size: 50, size: 50,
style: avatarStyle == .circle ? .circle : .roundRect, style: avatarStyle == .circle ? .circle : .roundRect,
fetchAvatar: fetchAvatar fetchAvatar: { await delegate?.fetchAvatar(url: $0) }
) )
.accessibilityHidden(true) .accessibilityHidden(true)
} }
@ -72,12 +72,14 @@ private struct AvatarView: View {
private struct AccountNameView: View { private struct AccountNameView: View {
let account: any AccountProtocol let account: any AccountProtocol
@Environment(\.composeUIConfig.displayNameLabel) private var displayNameLabel @Environment(\.composeUIDelegate) private var delegate
var body: some View { var body: some View {
HStack(spacing: 4) { HStack(spacing: 4) {
displayNameLabel(account, .body, 16) if let delegate {
delegate.displayNameLabel(account: account, style: .body, size: 16)
.lineLimit(1) .lineLimit(1)
}
Text(verbatim: "@\(account.acct)") Text(verbatim: "@\(account.acct)")
.font(.body.weight(.light)) .font(.body.weight(.light))

View File

@ -59,7 +59,7 @@ private struct DraftsListView: View {
let accountInfo: UserAccountInfo let accountInfo: UserAccountInfo
let selectDraft: (Draft) -> Void let selectDraft: (Draft) -> Void
@FetchRequest(sortDescriptors: [SortDescriptor(\Draft.lastModified, order: .reverse)]) private var drafts: FetchedResults<Draft> @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 { var body: some View {
List { List {
@ -77,7 +77,7 @@ private struct DraftsListView: View {
} }
} }
.onDrag { .onDrag {
userActivityForDraft(draft) ?? NSItemProvider() delegate?.userActivityForDraft(draft) ?? NSItemProvider()
} }
} }
.onDelete { indices in .onDelete { indices in

View File

@ -16,9 +16,7 @@ struct ReplyStatusView: View {
let globalFrameOutsideList: CGRect let globalFrameOutsideList: CGRect
@PreferenceObserving(\.$avatarStyle) private var avatarStyle @PreferenceObserving(\.$avatarStyle) private var avatarStyle
@Environment(\.composeUIConfig.displayNameLabel) private var displayNameLabel @Environment(\.composeUIDelegate) private var delegate
@Environment(\.composeUIConfig.replyContentView) private var replyContentView
@Environment(\.composeUIConfig.fetchAvatar) private var fetchAvatar
@State private var displayNameHeight: CGFloat? @State private var displayNameHeight: CGFloat?
@State private var contentHeight: CGFloat? @State private var contentHeight: CGFloat?
@ -31,9 +29,11 @@ struct ReplyStatusView: View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
HStack { HStack {
displayNameLabel(status.account, .body, 17) if let delegate {
delegate.displayNameLabel(account: status.account, style: .body, size: 17)
.lineLimit(1) .lineLimit(1)
.layoutPriority(1) .layoutPriority(1)
}
Text(verbatim: "@\(status.account.acct)") Text(verbatim: "@\(status.account.acct)")
.font(.body.weight(.light)) .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 // 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 // and it ends up partially behind the header
DispatchQueue.main.async { DispatchQueue.main.async {
@ -60,6 +61,7 @@ struct ReplyStatusView: View {
.frame(height: contentHeight ?? 0) .frame(height: contentHeight ?? 0)
} }
} }
}
.frame(minHeight: 50, alignment: .top) .frame(minHeight: 50, alignment: .top)
} }
@ -85,7 +87,7 @@ struct ReplyStatusView: View {
url: status.account.avatar, url: status.account.avatar,
size: 50, size: 50,
style: avatarStyle == .circle ? .circle : .roundRect, style: avatarStyle == .circle ? .circle : .roundRect,
fetchAvatar: fetchAvatar fetchAvatar: { await delegate?.fetchAvatar(url: $0) }
) )
} }
.frame(width: 50, height: 50) .frame(width: 50, height: 50)

View File

@ -13,21 +13,9 @@ import WebURLFoundationExtras
import Combine import Combine
import TuskerPreferences import TuskerPreferences
import Pachyderm import Pachyderm
import GalleryVC
class ShareHostingController: UIHostingController<ShareHostingController.View> { 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() @ObservableObjectBox private var config = ComposeUIConfig()
private let accountSwitchingState: AccountSwitchingState private let accountSwitchingState: AccountSwitchingState
private let state: ComposeViewState private let state: ComposeViewState
@ -60,11 +48,6 @@ class ShareHostingController: UIHostingController<ShareHostingController.View> {
config.fillColor = Color(uiColor: .appFill) config.fillColor = Color(uiColor: .appFill)
config.dismiss = { [unowned self] in self.dismiss(mode: $0) } 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 self.config = config
} }
@ -101,7 +84,8 @@ class ShareHostingController: UIHostingController<ShareHostingController.View> {
state: state, state: state,
mastodonController: accountSwitchingState.mastodonContext, mastodonController: accountSwitchingState.mastodonContext,
currentAccount: currentAccount, currentAccount: currentAccount,
config: config config: config,
delegate: ShareComposeUIDelegate.shared
) )
.onReceive(accountSwitchingState.$mastodonContext) { .onReceive(accountSwitchingState.$mastodonContext) {
state.draft.accountID = $0.accountInfo!.id 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 */; }; D6EEDE932C3CF21800E10E51 /* AudioSessionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EEDE922C3CF21800E10E51 /* AudioSessionCoordinator.swift */; };
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; }; D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; };
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.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 */; }; D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */; };
D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; }; D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; };
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; }; D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; };
@ -838,6 +839,7 @@
files = ( files = (
D6A4532829EF665800032932 /* Pachyderm in Frameworks */, D6A4532829EF665800032932 /* Pachyderm in Frameworks */,
D6A4532A29EF665A00032932 /* TuskerPreferences in Frameworks */, D6A4532A29EF665A00032932 /* TuskerPreferences in Frameworks */,
D6F19A172D717DAF00008B88 /* GalleryVC in Frameworks */,
D6A4532629EF665600032932 /* InstanceFeatures in Frameworks */, D6A4532629EF665600032932 /* InstanceFeatures in Frameworks */,
D6A4532C29EF665D00032932 /* UserAccounts in Frameworks */, D6A4532C29EF665D00032932 /* UserAccounts in Frameworks */,
D6A4532429EF665200032932 /* ComposeUI in Frameworks */, D6A4532429EF665200032932 /* ComposeUI in Frameworks */,
@ -1814,6 +1816,7 @@
D6A4532729EF665800032932 /* Pachyderm */, D6A4532729EF665800032932 /* Pachyderm */,
D6A4532929EF665A00032932 /* TuskerPreferences */, D6A4532929EF665A00032932 /* TuskerPreferences */,
D6A4532B29EF665D00032932 /* UserAccounts */, D6A4532B29EF665D00032932 /* UserAccounts */,
D6F19A162D717DAF00008B88 /* GalleryVC */,
); );
productName = ShareExtension; productName = ShareExtension;
productReference = D6A4531329EF64BA00032932 /* ShareExtension.appex */; productReference = D6A4531329EF64BA00032932 /* ShareExtension.appex */;
@ -3389,6 +3392,10 @@
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = TuskerPreferences; productName = TuskerPreferences;
}; };
D6F19A162D717DAF00008B88 /* GalleryVC */ = {
isa = XCSwiftPackageProductDependency;
productName = GalleryVC;
};
D6FA94E029B52898006AAC51 /* InstanceFeatures */ = { D6FA94E029B52898006AAC51 /* InstanceFeatures */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = InstanceFeatures; productName = InstanceFeatures;

View File

@ -17,6 +17,7 @@ import CoreData
#if canImport(Duckable) #if canImport(Duckable)
import Duckable import Duckable
#endif #endif
import GalleryVC
@MainActor @MainActor
protocol ComposeHostingControllerDelegate: AnyObject { protocol ComposeHostingControllerDelegate: AnyObject {
@ -51,7 +52,8 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
state: state, state: state,
mastodonController: mastodonController, mastodonController: mastodonController,
config: _config, config: _config,
currentAccount: _currentAccount currentAccount: _currentAccount,
delegate: ComposeUIDelegateImpl(mastodonController: mastodonController)
) )
super.init(rootView: rootView) super.init(rootView: rootView)
@ -85,26 +87,6 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
config.dismiss = { [weak self] in self?.dismiss(mode: $0) } config.dismiss = { [weak self] in self?.dismiss(mode: $0) }
config.presentAssetPicker = { [unowned self] in self.presentAssetPicker(completion: $0) } config.presentAssetPicker = { [unowned self] in self.presentAssetPicker(completion: $0) }
config.presentDrawing = { [unowned self] in self.presentDrawing($0, completion: $1) } 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 self.config = config
} }
@ -209,17 +191,20 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
@ObservedObject @ObservableObjectBox private var config: ComposeUIConfig @ObservedObject @ObservableObjectBox private var config: ComposeUIConfig
@ObservedObject @ObservableObjectBox private var currentAccount: AccountMO? @ObservedObject @ObservableObjectBox private var currentAccount: AccountMO?
let state: ComposeViewState let state: ComposeViewState
fileprivate let delegate: ComposeUIDelegateImpl
fileprivate init( fileprivate init(
state: ComposeViewState, state: ComposeViewState,
mastodonController: MastodonController, mastodonController: MastodonController,
config: ObservableObjectBox<ComposeUIConfig>, config: ObservableObjectBox<ComposeUIConfig>,
currentAccount: ObservableObjectBox<AccountMO?> currentAccount: ObservableObjectBox<AccountMO?>,
delegate: ComposeUIDelegateImpl
) { ) {
self.state = state self.state = state
self.mastodonController = mastodonController self.mastodonController = mastodonController
self._config = ObservedObject(wrappedValue: config) self._config = ObservedObject(wrappedValue: config)
self._currentAccount = ObservedObject(wrappedValue: currentAccount) self._currentAccount = ObservedObject(wrappedValue: currentAccount)
self.delegate = delegate
} }
var body: some SwiftUI.View { var body: some SwiftUI.View {
@ -227,7 +212,8 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
state: state, state: state,
mastodonController: mastodonController, mastodonController: mastodonController,
currentAccount: currentAccount, currentAccount: currentAccount,
config: config config: config,
delegate: delegate
) )
.task { .task {
if currentAccount == nil, if currentAccount == nil,
@ -337,3 +323,43 @@ extension ComposeHostingController: ComposeDrawingViewControllerDelegate {
drawingCompletion = nil 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))
}
}