Add user/hashtag autocomplete.

This commit is contained in:
Marcin Czachursk 2023-02-27 16:04:42 +01:00
parent a5ad0a3caa
commit 7721a25903
6 changed files with 388 additions and 195 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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