Autocomplete mentions
This commit is contained in:
parent
86e1ac495a
commit
4dbb7a372a
@ -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 }
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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 }
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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()
|
||||
|
@ -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:)))
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user