2020-10-12 02:14:45 +00:00
|
|
|
//
|
|
|
|
// ComposeAutocompleteView.swift
|
|
|
|
// Tusker
|
|
|
|
//
|
|
|
|
// Created by Shadowfacts on 10/10/20.
|
|
|
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
|
|
|
//
|
|
|
|
|
|
|
|
import SwiftUI
|
|
|
|
import CoreData
|
|
|
|
import Pachyderm
|
|
|
|
|
|
|
|
struct ComposeAutocompleteView: View {
|
|
|
|
let autocompleteState: ComposeUIState.AutocompleteState
|
|
|
|
|
|
|
|
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
|
|
|
|
|
|
|
private var backgroundColor: Color {
|
|
|
|
Color(white: colorScheme == .light ? 0.98 : 0.15)
|
|
|
|
}
|
|
|
|
|
|
|
|
private var borderColor: Color {
|
|
|
|
Color(white: colorScheme == .light ? 0.85 : 0.25)
|
|
|
|
}
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
suggestionsView
|
|
|
|
.background(backgroundColor)
|
|
|
|
.overlay(borderColor.frame(height: 0.5), alignment: .top)
|
|
|
|
}
|
|
|
|
|
|
|
|
@ViewBuilder
|
|
|
|
private var suggestionsView: some View {
|
|
|
|
switch autocompleteState {
|
|
|
|
case .mention(_):
|
|
|
|
ComposeAutocompleteMentionsView()
|
|
|
|
case .emoji(_):
|
|
|
|
ComposeAutocompleteEmojisView()
|
|
|
|
case .hashtag(_):
|
|
|
|
ComposeAutocompleteHashtagsView()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
struct ComposeAutocompleteMentionsView: View {
|
|
|
|
@EnvironmentObject private var mastodonController: MastodonController
|
|
|
|
@EnvironmentObject private var uiState: ComposeUIState
|
|
|
|
@ObservedObject private var preferences = Preferences.shared
|
|
|
|
|
|
|
|
// can't use AccountProtocol because of associated type requirements
|
2022-09-16 01:05:18 +00:00
|
|
|
@State private var accounts: [AnyAccount] = []
|
2020-10-12 02:14:45 +00:00
|
|
|
|
|
|
|
@State private var searchRequest: URLSessionTask?
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
ScrollView(.horizontal) {
|
|
|
|
// can't use LazyHStack because changing the contents of the ForEach causes the ScrollView to hang
|
|
|
|
HStack(spacing: 8) {
|
2022-09-16 01:05:18 +00:00
|
|
|
ForEach(accounts, id: \.value.id) { (account) in
|
2020-10-12 02:14:45 +00:00
|
|
|
Button {
|
2022-09-16 01:05:18 +00:00
|
|
|
uiState.currentInput?.autocomplete(with: "@\(account.value.acct)")
|
2020-10-12 02:14:45 +00:00
|
|
|
} label: {
|
|
|
|
HStack(spacing: 4) {
|
2022-09-16 01:05:18 +00:00
|
|
|
ComposeAvatarImageView(url: account.value.avatar)
|
2020-10-12 02:14:45 +00:00
|
|
|
.frame(width: 30, height: 30)
|
|
|
|
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 30)
|
|
|
|
|
|
|
|
VStack(alignment: .leading) {
|
2022-11-05 01:49:37 +00:00
|
|
|
AccountDisplayNameLabel(account: account.value, textStyle: .subheadline, emojiSize: 14)
|
2022-09-16 01:05:18 +00:00
|
|
|
.foregroundColor(Color(UIColor.label))
|
2020-10-12 02:14:45 +00:00
|
|
|
|
2022-09-16 01:05:18 +00:00
|
|
|
Text(verbatim: "@\(account.value.acct)")
|
2022-11-05 01:49:37 +00:00
|
|
|
.font(.caption)
|
2020-10-12 02:14:45 +00:00
|
|
|
.foregroundColor(Color(UIColor.label))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.frame(height: 30)
|
|
|
|
.padding(.vertical, 8)
|
|
|
|
}
|
2022-07-01 01:41:05 +00:00
|
|
|
.animation(.linear(duration: 0.1), value: accounts)
|
2020-10-12 02:14:45 +00:00
|
|
|
|
|
|
|
Spacer()
|
|
|
|
}
|
|
|
|
.padding(.horizontal, 8)
|
|
|
|
}
|
|
|
|
.onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged)
|
|
|
|
.onDisappear {
|
|
|
|
searchRequest?.cancel()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func queryChanged(_ state: ComposeUIState.AutocompleteState?) {
|
|
|
|
guard case let .mention(query) = state,
|
|
|
|
!query.isEmpty else {
|
|
|
|
accounts = []
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
let localSearchWorkItem = DispatchWorkItem {
|
|
|
|
// todo: there's got to be something more efficient than this :/
|
|
|
|
let wildcardedQuery = query.map { "*\($0)" }.joined() + "*"
|
|
|
|
let request: NSFetchRequest<AccountMO> = AccountMO.fetchRequest()
|
|
|
|
request.predicate = NSPredicate(format: "displayName LIKE %@ OR acct LIKE %@", wildcardedQuery, wildcardedQuery)
|
|
|
|
|
|
|
|
if let results = try? mastodonController.persistentContainer.viewContext.fetch(request) {
|
2022-09-16 01:05:18 +00:00
|
|
|
loadAccounts(results.map { .init(value: $0) }, query: query)
|
2020-10-12 02:14:45 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// we only want to search locally if the search API call takes more than .25sec or it fails
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: localSearchWorkItem)
|
|
|
|
|
|
|
|
if let oldRequest = searchRequest {
|
|
|
|
oldRequest.cancel()
|
|
|
|
}
|
|
|
|
|
|
|
|
let apiRequest = Client.searchForAccount(query: query)
|
|
|
|
searchRequest = mastodonController.run(apiRequest) { (response) in
|
|
|
|
guard case let .success(accounts, _) = response else { return }
|
|
|
|
|
|
|
|
localSearchWorkItem.cancel()
|
|
|
|
|
2020-10-14 23:28:32 +00:00
|
|
|
// dispatch back to the main thread because loadAccounts uses CoreData
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
// if the query has changed, don't bother loading the now-outdated results
|
|
|
|
if case .mention(query) = uiState.autocompleteState {
|
2022-09-16 01:05:18 +00:00
|
|
|
self.loadAccounts(accounts.map { .init(value: $0) }, query: query)
|
2020-10-14 23:28:32 +00:00
|
|
|
}
|
2020-10-12 02:14:45 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-09-16 01:05:18 +00:00
|
|
|
private func loadAccounts(_ accounts: [AnyAccount], query: String) {
|
2020-10-12 02:14:45 +00:00
|
|
|
// when sorting account suggestions, ignore the domain component of the acct unless the user is typing it themself
|
|
|
|
let ignoreDomain = !query.contains("@")
|
|
|
|
|
|
|
|
self.accounts =
|
2022-09-16 01:05:18 +00:00
|
|
|
accounts.map { (account) -> (AnyAccount, (matched: Bool, score: Int)) in
|
|
|
|
let fuzzyStr = ignoreDomain ? String(account.value.acct.split(separator: "@").first!) : account.value.acct
|
2020-10-12 02:14:45 +00:00
|
|
|
let res = (account, FuzzyMatcher.match(pattern: query, str: fuzzyStr))
|
|
|
|
return res
|
|
|
|
}
|
|
|
|
.filter(\.1.matched)
|
2022-09-16 01:05:18 +00:00
|
|
|
.map { (account, res) -> (AnyAccount, Int) in
|
2020-10-14 23:28:32 +00:00
|
|
|
// give higher weight to accounts that the user follows or is followed by
|
|
|
|
var score = res.score
|
2022-09-16 01:05:18 +00:00
|
|
|
if let relationship = mastodonController.persistentContainer.relationship(forAccount: account.value.id) {
|
2020-10-14 23:28:32 +00:00
|
|
|
if relationship.following {
|
|
|
|
score += 3
|
|
|
|
}
|
|
|
|
if relationship.followedBy {
|
|
|
|
score += 2
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return (account, score)
|
|
|
|
}
|
|
|
|
.sorted { $0.1 > $1.1 }
|
2020-10-12 02:14:45 +00:00
|
|
|
.map(\.0)
|
|
|
|
}
|
|
|
|
|
2022-09-16 01:05:18 +00:00
|
|
|
private struct AnyAccount: Equatable {
|
|
|
|
let value: any AccountProtocol
|
2022-07-01 01:41:05 +00:00
|
|
|
|
2022-09-16 01:05:18 +00:00
|
|
|
static func ==(lhs: AnyAccount, rhs: AnyAccount) -> Bool {
|
|
|
|
return lhs.value.id == rhs.value.id
|
2022-07-01 01:41:05 +00:00
|
|
|
}
|
2020-10-12 02:14:45 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
struct ComposeAutocompleteEmojisView: View {
|
|
|
|
@EnvironmentObject private var mastodonController: MastodonController
|
|
|
|
@EnvironmentObject private var uiState: ComposeUIState
|
|
|
|
|
2020-10-18 15:11:47 +00:00
|
|
|
@State var expanded = false
|
2020-10-12 02:14:45 +00:00
|
|
|
@State private var emojis: [Emoji] = []
|
2022-11-05 01:49:37 +00:00
|
|
|
@ScaledMetric private var emojiSize = 30
|
2020-10-12 02:14:45 +00:00
|
|
|
|
2022-11-29 03:13:06 +00:00
|
|
|
private var emojisBySection: [String: [Emoji]] {
|
|
|
|
var values: [String: [Emoji]] = [:]
|
|
|
|
for emoji in emojis {
|
|
|
|
let key = emoji.category ?? ""
|
|
|
|
if !values.keys.contains(key) {
|
|
|
|
values[key] = [emoji]
|
|
|
|
} else {
|
|
|
|
values[key]!.append(emoji)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return values
|
|
|
|
}
|
|
|
|
|
2020-10-12 02:14:45 +00:00
|
|
|
var body: some View {
|
2020-10-18 15:11:47 +00:00
|
|
|
// When exapnded, the toggle button should be at the top. When collapsed, it should be centered.
|
|
|
|
HStack(alignment: expanded ? .top : .center, spacing: 0) {
|
|
|
|
if case let .emoji(query) = uiState.autocompleteState {
|
|
|
|
emojiList(query: query)
|
|
|
|
.transition(.move(edge: .bottom))
|
2022-09-16 01:49:50 +00:00
|
|
|
.onReceive(uiState.$autocompleteState, perform: queryChanged)
|
|
|
|
.onAppear {
|
|
|
|
if uiState.shouldEmojiAutocompletionBeginExpanded {
|
|
|
|
expanded = true
|
|
|
|
uiState.shouldEmojiAutocompletionBeginExpanded = false
|
|
|
|
}
|
|
|
|
}
|
2020-10-18 15:11:47 +00:00
|
|
|
} else {
|
|
|
|
// when the autocomplete view is animating out, the autocomplete state is nil
|
|
|
|
// add a spacer so the expand button remains on the right
|
|
|
|
Spacer()
|
|
|
|
}
|
|
|
|
|
|
|
|
toggleExpandedButton
|
|
|
|
.padding(.trailing, 8)
|
|
|
|
.padding(.top, expanded ? 8 : 0)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@ViewBuilder
|
|
|
|
private func emojiList(query: String) -> some View {
|
|
|
|
if expanded {
|
2022-09-16 01:49:50 +00:00
|
|
|
verticalGrid
|
2020-10-18 15:11:47 +00:00
|
|
|
.frame(height: 150)
|
|
|
|
} else {
|
|
|
|
horizontalScrollView
|
2022-09-16 01:49:50 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private var verticalGrid: some View {
|
|
|
|
ScrollView {
|
2022-11-05 01:49:37 +00:00
|
|
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) {
|
2022-11-29 03:13:06 +00:00
|
|
|
ForEach(emojisBySection.keys.sorted(), id: \.self) { section in
|
|
|
|
Section {
|
|
|
|
ForEach(emojisBySection[section]!, id: \.shortcode) { emoji in
|
|
|
|
Button {
|
|
|
|
uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):")
|
|
|
|
} label: {
|
|
|
|
CustomEmojiImageView(emoji: emoji)
|
|
|
|
.frame(height: emojiSize)
|
|
|
|
}
|
2023-02-05 19:00:08 +00:00
|
|
|
.accessibilityLabel(emoji.shortcode)
|
2022-11-29 03:13:06 +00:00
|
|
|
}
|
|
|
|
} header: {
|
|
|
|
if !section.isEmpty {
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
|
|
Text(section)
|
|
|
|
.font(.caption)
|
|
|
|
|
|
|
|
Rectangle()
|
|
|
|
.foregroundColor(Color(.separator))
|
|
|
|
.frame(height: 0.5)
|
|
|
|
}
|
|
|
|
.padding(.top, 4)
|
|
|
|
}
|
2022-04-09 15:52:09 +00:00
|
|
|
}
|
|
|
|
}
|
2022-09-16 01:49:50 +00:00
|
|
|
}
|
|
|
|
.padding(.all, 8)
|
2020-10-18 15:11:47 +00:00
|
|
|
}
|
2022-09-16 01:49:50 +00:00
|
|
|
.frame(maxWidth: .infinity)
|
2020-10-18 15:11:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
private var horizontalScrollView: some View {
|
2020-10-12 02:14:45 +00:00
|
|
|
ScrollView(.horizontal) {
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
ForEach(emojis, id: \.shortcode) { (emoji) in
|
|
|
|
Button {
|
2022-04-09 15:41:27 +00:00
|
|
|
uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):")
|
2020-10-12 02:14:45 +00:00
|
|
|
} label: {
|
|
|
|
HStack(spacing: 4) {
|
|
|
|
CustomEmojiImageView(emoji: emoji)
|
2022-11-05 01:49:37 +00:00
|
|
|
.frame(height: emojiSize)
|
2020-10-12 02:14:45 +00:00
|
|
|
Text(verbatim: ":\(emoji.shortcode):")
|
|
|
|
.foregroundColor(Color(UIColor.label))
|
|
|
|
}
|
|
|
|
}
|
2023-02-05 19:00:08 +00:00
|
|
|
.accessibilityLabel(emoji.shortcode)
|
2022-11-05 01:49:37 +00:00
|
|
|
.frame(height: emojiSize)
|
2020-10-12 02:14:45 +00:00
|
|
|
}
|
2022-07-01 01:41:05 +00:00
|
|
|
.animation(.linear(duration: 0.2), value: emojis)
|
2020-10-12 02:14:45 +00:00
|
|
|
|
2022-11-05 01:49:37 +00:00
|
|
|
Spacer(minLength: emojiSize)
|
2020-10-12 02:14:45 +00:00
|
|
|
}
|
|
|
|
.padding(.horizontal, 8)
|
2022-11-05 01:49:37 +00:00
|
|
|
.frame(height: emojiSize + 16)
|
2020-10-12 02:14:45 +00:00
|
|
|
}
|
2020-10-18 15:11:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
private var toggleExpandedButton: some View {
|
|
|
|
Button {
|
2022-09-16 01:49:50 +00:00
|
|
|
withAnimation {
|
|
|
|
expanded.toggle()
|
|
|
|
}
|
2020-10-18 15:11:47 +00:00
|
|
|
} label: {
|
2022-09-16 01:49:50 +00:00
|
|
|
Image(systemName: "chevron.down")
|
2020-10-18 15:11:47 +00:00
|
|
|
.resizable()
|
|
|
|
.aspectRatio(contentMode: .fit)
|
2022-09-16 01:49:50 +00:00
|
|
|
.rotationEffect(expanded ? .zero : .degrees(180))
|
2020-10-18 15:11:47 +00:00
|
|
|
}
|
2023-02-05 19:00:08 +00:00
|
|
|
.accessibilityLabel(expanded ? "Collapse" : "Expand")
|
2020-10-18 15:11:47 +00:00
|
|
|
.frame(width: 20, height: 20)
|
2020-10-12 02:14:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
private func queryChanged(_ autocompleteState: ComposeUIState.AutocompleteState?) {
|
2022-09-16 01:49:50 +00:00
|
|
|
guard case let .emoji(query) = autocompleteState else {
|
2020-10-12 02:14:45 +00:00
|
|
|
emojis = []
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
mastodonController.getCustomEmojis { (emojis) in
|
2022-09-16 01:49:50 +00:00
|
|
|
var emojis = emojis
|
|
|
|
if !query.isEmpty {
|
|
|
|
emojis =
|
|
|
|
emojis.map { (emoji) -> (Emoji, (matched: Bool, score: Int)) in
|
|
|
|
(emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode))
|
|
|
|
}
|
|
|
|
.filter(\.1.matched)
|
|
|
|
.sorted { $0.1.score > $1.1.score }
|
|
|
|
.map(\.0)
|
|
|
|
}
|
2022-09-16 01:10:52 +00:00
|
|
|
var shortcodes = Set<String>()
|
|
|
|
self.emojis = []
|
|
|
|
for emoji in emojis where !shortcodes.contains(emoji.shortcode) {
|
|
|
|
self.emojis.append(emoji)
|
|
|
|
shortcodes.insert(emoji.shortcode)
|
|
|
|
}
|
2020-10-12 02:14:45 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
struct ComposeAutocompleteHashtagsView: View {
|
|
|
|
@EnvironmentObject private var mastodonController: MastodonController
|
|
|
|
@EnvironmentObject private var uiState: ComposeUIState
|
|
|
|
|
|
|
|
@State private var hashtags: [Hashtag] = []
|
|
|
|
@State private var trendingRequest: URLSessionTask?
|
|
|
|
@State private var searchRequest: URLSessionTask?
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
ScrollView(.horizontal) {
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
ForEach(hashtags, id: \.name) { (hashtag) in
|
|
|
|
Button {
|
2022-04-09 15:41:27 +00:00
|
|
|
uiState.currentInput?.autocomplete(with: "#\(hashtag.name)")
|
2020-10-12 02:14:45 +00:00
|
|
|
} label: {
|
|
|
|
Text(verbatim: "#\(hashtag.name)")
|
|
|
|
.foregroundColor(Color(UIColor.label))
|
|
|
|
}
|
|
|
|
.frame(height: 30)
|
|
|
|
.padding(.vertical, 8)
|
|
|
|
}
|
2022-07-01 01:41:05 +00:00
|
|
|
.animation(.linear(duration: 0.1), value: hashtags)
|
2020-10-12 02:14:45 +00:00
|
|
|
|
|
|
|
Spacer()
|
|
|
|
}
|
|
|
|
.padding(.horizontal, 8)
|
|
|
|
}
|
|
|
|
.onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged)
|
|
|
|
.onDisappear {
|
|
|
|
trendingRequest?.cancel()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func queryChanged(_ autocompleteState: ComposeUIState.AutocompleteState?) {
|
|
|
|
guard case let .hashtag(query) = autocompleteState,
|
|
|
|
!query.isEmpty else {
|
|
|
|
hashtags = []
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
let onlySavedTagsWorkItem = DispatchWorkItem {
|
|
|
|
self.updateHashtags(searchResults: [], trendingTags: [], query: query)
|
|
|
|
}
|
|
|
|
|
|
|
|
// we only want to do the local-only search if the trends API call takes more than .25sec or it fails
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: onlySavedTagsWorkItem)
|
|
|
|
|
|
|
|
var trendingTags: [Hashtag] = []
|
|
|
|
var searchedTags: [Hashtag] = []
|
|
|
|
|
|
|
|
let group = DispatchGroup()
|
|
|
|
|
|
|
|
group.enter()
|
2022-04-02 14:39:03 +00:00
|
|
|
trendingRequest = mastodonController.run(Client.getTrendingHashtags()) { (response) in
|
2020-10-12 02:14:45 +00:00
|
|
|
defer { group.leave() }
|
|
|
|
guard case let .success(trends, _) = response else { return }
|
|
|
|
trendingTags = trends
|
|
|
|
}
|
|
|
|
|
|
|
|
group.enter()
|
|
|
|
searchRequest = mastodonController.run(Client.search(query: "#\(query)", types: [.hashtags])) { (response) in
|
|
|
|
defer { group.leave() }
|
|
|
|
guard case let .success(results, _) = response else { return }
|
|
|
|
searchedTags = results.hashtags
|
|
|
|
}
|
|
|
|
|
|
|
|
group.notify(queue: .main) {
|
|
|
|
onlySavedTagsWorkItem.cancel()
|
|
|
|
|
|
|
|
// if the query has changed, don't bother loading the now-outdated results
|
|
|
|
if case .hashtag(query) = self.uiState.autocompleteState {
|
|
|
|
self.updateHashtags(searchResults: searchedTags, trendingTags: trendingTags, query: query)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], query: String) {
|
2022-12-19 15:58:14 +00:00
|
|
|
let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!)
|
|
|
|
let savedTags = ((try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? [])
|
2022-05-11 02:57:46 +00:00
|
|
|
.map { Hashtag(name: $0.name, url: $0.url) }
|
2020-10-12 02:14:45 +00:00
|
|
|
|
|
|
|
hashtags = (searchResults + savedTags + trendingTags)
|
|
|
|
.map { (tag) -> (Hashtag, (matched: Bool, score: Int)) in
|
|
|
|
return (tag, FuzzyMatcher.match(pattern: query, str: tag.name))
|
|
|
|
}
|
|
|
|
.filter(\.1.matched)
|
|
|
|
.sorted { $0.1.score > $1.1.score }
|
|
|
|
.map(\.0)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
struct ComposeAutocompleteView_Previews: PreviewProvider {
|
|
|
|
static var previews: some View {
|
|
|
|
ComposeAutocompleteView(autocompleteState: .mention("shadowfacts"))
|
|
|
|
}
|
|
|
|
}
|