Add user/hashtag autocomplete.
This commit is contained in:
parent
a5ad0a3caa
commit
7721a25903
|
@ -78,6 +78,7 @@
|
|||
F87AEB972986D16D00434FB6 /* AuthorisationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87AEB962986D16D00434FB6 /* AuthorisationError.swift */; };
|
||||
F8864CE929ACAF820020C534 /* TextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8864CE829ACAF820020C534 /* TextView.swift */; };
|
||||
F8864CEB29ACBAA80020C534 /* TextFieldViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8864CEA29ACBAA80020C534 /* TextFieldViewModel.swift */; };
|
||||
F8864CEF29ACE90B0020C534 /* UIFont+Font.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8864CEE29ACE90B0020C534 /* UIFont+Font.swift */; };
|
||||
F886F257297859E300879356 /* CacheImageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F886F256297859E300879356 /* CacheImageService.swift */; };
|
||||
F88ABD9229686F1C004EF61E /* MemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88ABD9129686F1C004EF61E /* MemoryCache.swift */; };
|
||||
F88ABD9429687CA4004EF61E /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88ABD9329687CA4004EF61E /* ComposeView.swift */; };
|
||||
|
@ -222,6 +223,7 @@
|
|||
F87AEB962986D16D00434FB6 /* AuthorisationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorisationError.swift; sourceTree = "<group>"; };
|
||||
F8864CE829ACAF820020C534 /* TextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextView.swift; sourceTree = "<group>"; };
|
||||
F8864CEA29ACBAA80020C534 /* TextFieldViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldViewModel.swift; sourceTree = "<group>"; };
|
||||
F8864CEE29ACE90B0020C534 /* UIFont+Font.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Font.swift"; sourceTree = "<group>"; };
|
||||
F886F256297859E300879356 /* CacheImageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheImageService.swift; sourceTree = "<group>"; };
|
||||
F88ABD9129686F1C004EF61E /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = "<group>"; };
|
||||
F88ABD9329687CA4004EF61E /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
|
||||
|
@ -393,6 +395,7 @@
|
|||
F8CEEDF729ABADDD00DBED66 /* UIImage+Size.swift */,
|
||||
F8996DEA2971D29D0043EEC6 /* View+Transition.swift */,
|
||||
F88E4D43297E82EB0057491A /* Status+MediaAttachmentType.swift */,
|
||||
F8864CEE29ACE90B0020C534 /* UIFont+Font.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
|
@ -886,6 +889,7 @@
|
|||
F85E1320297409CD006A051D /* ErrorsService.swift in Sources */,
|
||||
F88C246C295C37B80006098B /* VernissageApp.swift in Sources */,
|
||||
F8121CA8298A86D600B466C7 /* InstanceRowView.swift in Sources */,
|
||||
F8864CEF29ACE90B0020C534 /* UIFont+Font.swift in Sources */,
|
||||
F8CEEDFA29ABAFD200DBED66 /* ImageFileTranseferable.swift in Sources */,
|
||||
F802884F297AEED5000BDD51 /* DatabaseError.swift in Sources */,
|
||||
F86A4307299AA5E900DF7645 /* ThanksView.swift in Sources */,
|
||||
|
@ -1043,7 +1047,7 @@
|
|||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 38;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Vernissage/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = B2U9FEKYP8;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
|
@ -1080,7 +1084,7 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 37;
|
||||
CURRENT_PROJECT_VERSION = 38;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Vernissage/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = B2U9FEKYP8;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
//
|
||||
// https://mczachurski.dev
|
||||
// Copyright © 2023 Marcin Czachurski and the repository contributors.
|
||||
// Licensed under the MIT License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension UIFont {
|
||||
class func preferredFont(from font: Font) -> UIFont {
|
||||
let uiFont: UIFont
|
||||
|
||||
switch font {
|
||||
case .largeTitle:
|
||||
uiFont = UIFont.preferredFont(forTextStyle: .largeTitle)
|
||||
case .title:
|
||||
uiFont = UIFont.preferredFont(forTextStyle: .title1)
|
||||
case .title2:
|
||||
uiFont = UIFont.preferredFont(forTextStyle: .title2)
|
||||
case .title3:
|
||||
uiFont = UIFont.preferredFont(forTextStyle: .title3)
|
||||
case .headline:
|
||||
uiFont = UIFont.preferredFont(forTextStyle: .headline)
|
||||
case .subheadline:
|
||||
uiFont = UIFont.preferredFont(forTextStyle: .subheadline)
|
||||
case .callout:
|
||||
uiFont = UIFont.preferredFont(forTextStyle: .callout)
|
||||
case .caption:
|
||||
uiFont = UIFont.preferredFont(forTextStyle: .caption1)
|
||||
case .caption2:
|
||||
uiFont = UIFont.preferredFont(forTextStyle: .caption2)
|
||||
case .footnote:
|
||||
uiFont = UIFont.preferredFont(forTextStyle: .footnote)
|
||||
case .body:
|
||||
fallthrough
|
||||
default:
|
||||
uiFont = UIFont.preferredFont(forTextStyle: .body)
|
||||
}
|
||||
|
||||
return uiFont
|
||||
}
|
||||
}
|
|
@ -16,7 +16,7 @@ struct ComposeView: View {
|
|||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State var statusViewModel: StatusModel?
|
||||
@StateObject private var textFieldViewModel: TextFieldViewModel
|
||||
|
||||
@State private var isSensitive = false
|
||||
@State private var spoilerText = String.empty()
|
||||
|
@ -59,172 +59,27 @@ struct ComposeView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private let statusViewModel: StatusModel?
|
||||
private let contentWidth = Int(UIScreen.main.bounds.width) - 50
|
||||
private let keyboardFontSize = 22.0
|
||||
|
||||
@StateObject private var textFieldViewModel: TextFieldViewModel
|
||||
private let keyboardFontImageSize = 20.0
|
||||
private let keyboardFontTextSize = 16.0
|
||||
|
||||
public init(statusViewModel: StatusModel? = nil) {
|
||||
_textFieldViewModel = StateObject(wrappedValue: .init())
|
||||
self.statusViewModel = statusViewModel
|
||||
|
||||
print(self.statusViewModel?.id ?? "<nil>")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
NavigationView {
|
||||
ZStack(alignment: .bottom) {
|
||||
|
||||
ScrollView {
|
||||
VStack (alignment: .leading){
|
||||
if self.isSensitive {
|
||||
TextField("Write content warning", text: $spoilerText, axis: .vertical)
|
||||
.padding(8)
|
||||
.lineLimit(1...2)
|
||||
.focused($focusedField, equals: .spoilerText)
|
||||
.keyboardType(.default)
|
||||
.background(Color.dangerColor.opacity(0.4))
|
||||
}
|
||||
|
||||
if self.commentsDisabled {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("Comments will be disabled")
|
||||
.textCase(.uppercase)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.dangerColor)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
|
||||
if let accountData = applicationState.account {
|
||||
HStack {
|
||||
UsernameRow(
|
||||
accountId: accountData.id,
|
||||
accountAvatar: accountData.avatar,
|
||||
accountDisplayName: accountData.displayName,
|
||||
accountUsername: accountData.username)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Menu {
|
||||
Button {
|
||||
self.visibility = .pub
|
||||
self.visibilityText = "Everyone"
|
||||
self.visibilityImage = "globe.europe.africa"
|
||||
} label: {
|
||||
Label("Everyone", systemImage: "globe.europe.africa")
|
||||
}
|
||||
|
||||
Button {
|
||||
self.visibility = .unlisted
|
||||
self.visibilityText = "Unlisted"
|
||||
self.visibilityImage = "lock.open"
|
||||
} label: {
|
||||
Label("Unlisted", systemImage: "lock.open")
|
||||
}
|
||||
|
||||
Button {
|
||||
self.visibility = .priv
|
||||
self.visibilityText = "Followers"
|
||||
self.visibilityImage = "lock"
|
||||
} label: {
|
||||
Label("Followers", systemImage: "lock")
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Label(self.visibilityText, systemImage: self.visibilityImage)
|
||||
Image(systemName: "chevron.down")
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.accentColor, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let name = self.place?.name, let country = self.place?.country {
|
||||
Group {
|
||||
Image(systemName: "mappin.and.ellipse")
|
||||
Text("\(name), \(country)")
|
||||
}
|
||||
.foregroundColor(.lightGrayColor)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
}
|
||||
.font(.footnote)
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
|
||||
TextView($textFieldViewModel.text, getTextView: { textView in
|
||||
self.textFieldViewModel.textView = textView
|
||||
})
|
||||
.placeholder(self.placeholder())
|
||||
.padding(.horizontal, 8)
|
||||
.focused($focusedField, equals: .content)
|
||||
.onFirstAppear {
|
||||
self.focusedField = .content
|
||||
}
|
||||
|
||||
HStack(alignment: .center) {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum:80))]) {
|
||||
ForEach(self.photosAttachment, id: \.id) { photoAttachment in
|
||||
ImageUploadView(photoAttachment: photoAttachment) {
|
||||
self.showSheet = .photoDetails(photoAttachment)
|
||||
} delete: {
|
||||
self.photosAttachment = self.photosAttachment.filter({ item in
|
||||
item != photoAttachment
|
||||
})
|
||||
|
||||
self.selectedItems = self.selectedItems.filter({ item in
|
||||
item != photoAttachment.photosPickerItem
|
||||
})
|
||||
|
||||
self.refreshScreenState()
|
||||
} upload: {
|
||||
Task {
|
||||
photoAttachment.error = nil
|
||||
await self.upload(photoAttachment)
|
||||
self.refreshScreenState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
|
||||
if let status = self.statusViewModel {
|
||||
HStack (alignment: .top) {
|
||||
UserAvatar(accountAvatar: status.account.avatar, size: .comment)
|
||||
|
||||
VStack (alignment: .leading, spacing: 0) {
|
||||
HStack (alignment: .top) {
|
||||
Text(statusViewModel?.account.displayNameWithoutEmojis ?? "")
|
||||
.foregroundColor(.mainTextColor)
|
||||
.font(.footnote)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
MarkdownFormattedText(status.content.asMarkdown, withFontSize: 14, andWidth: contentWidth)
|
||||
.environment(\.openURL, OpenURLAction { url in .handled })
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.selectedRowColor)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
self.composeBody()
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
self.autocompleteToolbar()
|
||||
self.keyboardToolbar()
|
||||
}
|
||||
|
||||
self.keyboardToolbar()
|
||||
}
|
||||
.frame(alignment: .topLeading)
|
||||
.toolbar {
|
||||
|
@ -244,6 +99,9 @@ struct ComposeView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.textFieldViewModel.client = self.client
|
||||
}
|
||||
.onChange(of: self.textFieldViewModel.text) { newValue in
|
||||
self.refreshScreenState()
|
||||
}
|
||||
|
@ -273,12 +131,244 @@ struct ComposeView: View {
|
|||
.interactiveDismissDisabled(self.interactiveDismissDisabled)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func composeBody() -> some View {
|
||||
ScrollView {
|
||||
VStack (alignment: .leading){
|
||||
// Red content warning.
|
||||
self.contentWarningView()
|
||||
|
||||
// Information that comments are disabled.
|
||||
self.commentsDisabledView()
|
||||
|
||||
// User avatar and name.
|
||||
self.userAvatarView()
|
||||
|
||||
// Incofmation about status visibility.
|
||||
self.visibilityComboView()
|
||||
|
||||
// Text area with new status.
|
||||
self.statusTextView()
|
||||
|
||||
// Grid with images.
|
||||
self.imagesGridView()
|
||||
|
||||
// Status when we are adding new comment.
|
||||
self.statusModelView()
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func imagesGridView() -> some View {
|
||||
HStack(alignment: .center) {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum:80))]) {
|
||||
ForEach(self.photosAttachment, id: \.id) { photoAttachment in
|
||||
ImageUploadView(photoAttachment: photoAttachment) {
|
||||
self.showSheet = .photoDetails(photoAttachment)
|
||||
} delete: {
|
||||
self.photosAttachment = self.photosAttachment.filter({ item in
|
||||
item != photoAttachment
|
||||
})
|
||||
|
||||
self.selectedItems = self.selectedItems.filter({ item in
|
||||
item != photoAttachment.photosPickerItem
|
||||
})
|
||||
|
||||
self.refreshScreenState()
|
||||
} upload: {
|
||||
Task {
|
||||
photoAttachment.error = nil
|
||||
await self.upload(photoAttachment)
|
||||
self.refreshScreenState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusModelView() -> some View {
|
||||
if let status = self.statusViewModel {
|
||||
HStack (alignment: .top) {
|
||||
UserAvatar(accountAvatar: status.account.avatar, size: .comment)
|
||||
|
||||
VStack (alignment: .leading, spacing: 0) {
|
||||
HStack (alignment: .top) {
|
||||
Text(statusViewModel?.account.displayNameWithoutEmojis ?? "")
|
||||
.foregroundColor(.mainTextColor)
|
||||
.font(.footnote)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
MarkdownFormattedText(status.content.asMarkdown, withFontSize: 14, andWidth: contentWidth)
|
||||
.environment(\.openURL, OpenURLAction { url in .handled })
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.selectedRowColor)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusTextView() -> some View {
|
||||
TextView($textFieldViewModel.text, getTextView: { textView in
|
||||
self.textFieldViewModel.textView = textView
|
||||
})
|
||||
.placeholder(self.placeholder())
|
||||
.padding(.horizontal, 8)
|
||||
.focused($focusedField, equals: .content)
|
||||
.onFirstAppear {
|
||||
self.focusedField = .content
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func userAvatarView() -> some View {
|
||||
if let accountData = applicationState.account {
|
||||
HStack {
|
||||
UsernameRow(
|
||||
accountId: accountData.id,
|
||||
accountAvatar: accountData.avatar,
|
||||
accountDisplayName: accountData.displayName,
|
||||
accountUsername: accountData.username)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func contentWarningView() -> some View {
|
||||
if self.isSensitive {
|
||||
TextField("Write content warning", text: $spoilerText, axis: .vertical)
|
||||
.padding(8)
|
||||
.lineLimit(1...2)
|
||||
.focused($focusedField, equals: .spoilerText)
|
||||
.keyboardType(.default)
|
||||
.background(Color.dangerColor.opacity(0.4))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func commentsDisabledView() -> some View {
|
||||
if self.commentsDisabled {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("Comments will be disabled")
|
||||
.textCase(.uppercase)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.dangerColor)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func visibilityComboView() -> some View {
|
||||
HStack {
|
||||
Menu {
|
||||
Button {
|
||||
self.visibility = .pub
|
||||
self.visibilityText = "Everyone"
|
||||
self.visibilityImage = "globe.europe.africa"
|
||||
} label: {
|
||||
Label("Everyone", systemImage: "globe.europe.africa")
|
||||
}
|
||||
|
||||
Button {
|
||||
self.visibility = .unlisted
|
||||
self.visibilityText = "Unlisted"
|
||||
self.visibilityImage = "lock.open"
|
||||
} label: {
|
||||
Label("Unlisted", systemImage: "lock.open")
|
||||
}
|
||||
|
||||
Button {
|
||||
self.visibility = .priv
|
||||
self.visibilityText = "Followers"
|
||||
self.visibilityImage = "lock"
|
||||
} label: {
|
||||
Label("Followers", systemImage: "lock")
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Label(self.visibilityText, systemImage: self.visibilityImage)
|
||||
Image(systemName: "chevron.down")
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.accentColor, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let name = self.place?.name, let country = self.place?.country {
|
||||
Group {
|
||||
Image(systemName: "mappin.and.ellipse")
|
||||
Text("\(name), \(country)")
|
||||
}
|
||||
.foregroundColor(.lightGrayColor)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
}
|
||||
.font(.footnote)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func autocompleteToolbar() -> some View {
|
||||
if !textFieldViewModel.mentionsSuggestions.isEmpty || !textFieldViewModel.tagsSuggestions.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHStack {
|
||||
if !textFieldViewModel.mentionsSuggestions.isEmpty {
|
||||
ForEach(textFieldViewModel.mentionsSuggestions, id: \.id) { account in
|
||||
Button {
|
||||
textFieldViewModel.selectMentionSuggestion(account: account)
|
||||
} label: {
|
||||
UsernameRow(
|
||||
accountId: account.id,
|
||||
accountAvatar: account.avatar,
|
||||
accountDisplayName: account.displayNameWithoutEmojis,
|
||||
accountUsername: account.acct,
|
||||
size: .mini)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ForEach(textFieldViewModel.tagsSuggestions, id: \.url) { tag in
|
||||
Button {
|
||||
textFieldViewModel.selectHashtagSuggestion(tag: tag)
|
||||
} label: {
|
||||
Text("#\(tag.name)")
|
||||
.font(.callout)
|
||||
.foregroundColor(self.applicationState.tintColor.color())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
.frame(height: 40)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func keyboardToolbar() -> some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
HStack(alignment: .center, spacing: 22) {
|
||||
|
||||
|
||||
Button {
|
||||
hideKeyboard()
|
||||
self.focusedField = .unknown
|
||||
|
@ -337,10 +427,10 @@ struct ComposeView: View {
|
|||
|
||||
Text("\(self.applicationState.statusMaxCharacters - textFieldViewModel.text.string.utf16.count)")
|
||||
.foregroundColor(.lightGrayColor)
|
||||
.font(.system(size: 16.0))
|
||||
.font(.system(size: self.keyboardFontTextSize))
|
||||
}
|
||||
.padding(8)
|
||||
.font(.system(size: self.keyboardFontSize))
|
||||
.font(.system(size: self.keyboardFontImageSize))
|
||||
}
|
||||
.background(Color.keyboardToolbarColor)
|
||||
}
|
||||
|
|
|
@ -6,42 +6,47 @@
|
|||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import PixelfedKit
|
||||
|
||||
@MainActor
|
||||
public class TextFieldViewModel: NSObject, ObservableObject {
|
||||
|
||||
var client: Client?
|
||||
var textView: UITextView?
|
||||
|
||||
var selectedRange: NSRange {
|
||||
get {
|
||||
guard let textView else {
|
||||
return .init(location: 0, length: 0)
|
||||
}
|
||||
get {
|
||||
guard let textView else {
|
||||
return .init(location: 0, length: 0)
|
||||
}
|
||||
|
||||
return textView.selectedRange
|
||||
}
|
||||
set {
|
||||
textView?.selectedRange = newValue
|
||||
}
|
||||
return textView.selectedRange
|
||||
}
|
||||
set {
|
||||
textView?.selectedRange = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var markedTextRange: UITextRange? {
|
||||
guard let textView else {
|
||||
return nil
|
||||
}
|
||||
return textView.markedTextRange
|
||||
guard let textView else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return textView.markedTextRange
|
||||
}
|
||||
|
||||
@Published var mentionsSuggestions: [Account] = []
|
||||
@Published var tagsSuggestions: [Tag] = []
|
||||
|
||||
@Published var text = NSMutableAttributedString(string: "") {
|
||||
didSet {
|
||||
let range = selectedRange
|
||||
processText()
|
||||
// checkEmbed()
|
||||
textView?.attributedText = text
|
||||
selectedRange = range
|
||||
}
|
||||
didSet {
|
||||
let range = selectedRange
|
||||
processText()
|
||||
textView?.attributedText = text
|
||||
selectedRange = range
|
||||
}
|
||||
}
|
||||
|
||||
private var currentSuggestionRange: NSRange?
|
||||
private var urlLengthAdjustments: Int = 0
|
||||
private let maxLengthOfUrl = 23
|
||||
|
||||
|
@ -57,7 +62,7 @@ public class TextFieldViewModel: NSObject, ObservableObject {
|
|||
guard markedTextRange == nil else { return }
|
||||
|
||||
text.addAttributes([.foregroundColor: UIColor(Color.label),
|
||||
.font: UIFont.systemFont(ofSize: TextView.bodyFontSize),
|
||||
.font: UIFont.preferredFont(from: .body),
|
||||
.backgroundColor: UIColor.clear,
|
||||
.underlineColor: UIColor.clear],
|
||||
range: NSMakeRange(0, text.string.utf16.count))
|
||||
|
@ -77,21 +82,21 @@ public class TextFieldViewModel: NSObject, ObservableObject {
|
|||
|
||||
let urlRanges = urlRegex.matches(in: text.string, options: [], range: range).map { $0.range }
|
||||
|
||||
// var foundSuggestionRange = false
|
||||
var foundSuggestionRange = false
|
||||
for nsRange in ranges {
|
||||
text.addAttributes([.foregroundColor: UIColor(.accentColor)], range: nsRange)
|
||||
|
||||
// if selectedRange.location == (nsRange.location + nsRange.length),
|
||||
// let range = Range(nsRange, in: text.string) {
|
||||
// foundSuggestionRange = true
|
||||
// currentSuggestionRange = nsRange
|
||||
// loadAutoCompleteResults(query: String(text.string[range]))
|
||||
// }
|
||||
if selectedRange.location == (nsRange.location + nsRange.length),
|
||||
let range = Range(nsRange, in: text.string) {
|
||||
foundSuggestionRange = true
|
||||
currentSuggestionRange = nsRange
|
||||
loadAutoCompleteResults(query: String(text.string[range]))
|
||||
}
|
||||
}
|
||||
|
||||
// if !foundSuggestionRange || ranges.isEmpty {
|
||||
// resetAutoCompletion()
|
||||
// }
|
||||
if !foundSuggestionRange || ranges.isEmpty {
|
||||
resetAutoCompletion()
|
||||
}
|
||||
|
||||
var totalUrlLength = 0
|
||||
var numUrls = 0
|
||||
|
@ -116,7 +121,58 @@ public class TextFieldViewModel: NSObject, ObservableObject {
|
|||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
private func loadAutoCompleteResults(query: String) {
|
||||
guard let client, query.utf8.count > 1 else { return }
|
||||
var query = query
|
||||
Task {
|
||||
do {
|
||||
var results: SearchResults?
|
||||
switch query.first {
|
||||
case "#":
|
||||
query.removeFirst()
|
||||
results = try await client.search?.search(query: query, resultsType: .hashtags)
|
||||
withAnimation {
|
||||
tagsSuggestions = results?.hashtags ?? []
|
||||
}
|
||||
case "@":
|
||||
query.removeFirst()
|
||||
results = try await client.search?.search(query: query, resultsType: .accounts)
|
||||
withAnimation {
|
||||
mentionsSuggestions = results?.accounts ?? []
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private func resetAutoCompletion() {
|
||||
tagsSuggestions = []
|
||||
mentionsSuggestions = []
|
||||
currentSuggestionRange = nil
|
||||
}
|
||||
|
||||
func selectMentionSuggestion(account: Account) {
|
||||
if let range = currentSuggestionRange {
|
||||
replaceTextWith(text: "@\(account.acct) ", inRange: range)
|
||||
}
|
||||
}
|
||||
|
||||
func selectHashtagSuggestion(tag: Tag) {
|
||||
if let range = currentSuggestionRange {
|
||||
replaceTextWith(text: "#\(tag.name) ", inRange: range)
|
||||
}
|
||||
}
|
||||
|
||||
func replaceTextWith(text: String, inRange: NSRange) {
|
||||
let string = self.text
|
||||
string.mutableString.deleteCharacters(in: inRange)
|
||||
string.mutableString.insert(text, at: inRange.location)
|
||||
self.text = string
|
||||
selectedRange = NSRange(location: inRange.location + text.utf16.count, length: 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -130,8 +130,8 @@ extension TextView.Representable {
|
|||
super.init()
|
||||
|
||||
textView.delegate = self
|
||||
|
||||
textView.font = .systemFont(ofSize: TextView.bodyFontSize)
|
||||
|
||||
textView.font = UIFont.preferredFont(from: .body)
|
||||
textView.adjustsFontForContentSizeCategory = true
|
||||
textView.autocapitalizationType = .sentences
|
||||
textView.autocorrectionType = .yes
|
||||
|
|
|
@ -12,10 +12,11 @@ struct UsernameRow: View {
|
|||
@State public var accountAvatar: URL?
|
||||
@State public var accountDisplayName: String?
|
||||
@State public var accountUsername: String
|
||||
@State public var size: UserAvatar.Size?
|
||||
|
||||
var body: some View {
|
||||
HStack (alignment: .center) {
|
||||
UserAvatar(accountAvatar: accountAvatar, size: .list)
|
||||
UserAvatar(accountAvatar: accountAvatar, size: size ?? .list)
|
||||
|
||||
VStack (alignment: .leading) {
|
||||
Text(accountDisplayName ?? accountUsername)
|
||||
|
|
Loading…
Reference in New Issue