Editor: Refactor + Add autocomplete for mentions and hashtag

This commit is contained in:
Thomas Ricouard 2022-12-31 09:10:27 +01:00
parent b1c46f2f22
commit bb47937eb6
9 changed files with 319 additions and 153 deletions

View file

@ -57,7 +57,8 @@ public class RouterPath: ObservableObject {
Task {
let results: SearchResults? = try? await client.get(endpoint: Search.search(query: url.absoluteString,
type: "statuses",
offset: nil),
offset: nil,
following: nil),
forceVersion: .v2)
if let status = results?.statuses.first {
navigate(to: .statusDetail(id: status.id))

View file

@ -95,7 +95,8 @@ class ExploreViewModel: ObservableObject {
let apiType = tokens.first?.apiType
var results: SearchResults = try await client.get(endpoint: Search.search(query: searchQuery,
type: apiType,
offset: nil),
offset: nil,
following: nil),
forceVersion: .v2)
let relationships: [Relationshionship] =
try await client.get(endpoint: Accounts.relationships(ids: results.accounts.map{ $0.id }))

View file

@ -1,7 +1,7 @@
import Foundation
public enum Search: Endpoint {
case search(query: String, type: String?, offset: Int?)
case search(query: String, type: String?, offset: Int?, following: Bool?)
public func path() -> String {
switch self {
@ -12,7 +12,7 @@ public enum Search: Endpoint {
public func queryItems() -> [URLQueryItem]? {
switch self {
case let .search(query, type, offset):
case let .search(query, type, offset, following):
var params: [URLQueryItem] = [.init(name: "q", value: query)]
if let type {
params.append(.init(name: "type", value: type))
@ -20,6 +20,9 @@ public enum Search: Endpoint {
if let offset {
params.append(.init(name: "offset", value: String(offset)))
}
if let following {
params.append(.init(name: "following", value: following ? "true": "false"))
}
params.append(.init(name: "resolve", value: "true"))
return params
}

View file

@ -0,0 +1,76 @@
import SwiftUI
import DesignSystem
import PhotosUI
import Models
import Env
struct StatusEditorAccessoryView: View {
@EnvironmentObject private var currentInstance: CurrentInstance
@FocusState<Bool>.Binding var isSpoilerTextFocused: Bool
@ObservedObject var viewModel: StatusEditorViewModel
var body: some View {
VStack(spacing: 0) {
Divider()
HStack(alignment: .center, spacing: 16) {
PhotosPicker(selection: $viewModel.selectedMedias,
matching: .images) {
Image(systemName: "photo.fill.on.rectangle.fill")
}
Button {
viewModel.insertStatusText(text: " @")
} label: {
Image(systemName: "at")
}
Button {
viewModel.insertStatusText(text: " #")
} label: {
Image(systemName: "number")
}
Button {
withAnimation {
viewModel.spoilerOn.toggle()
}
isSpoilerTextFocused.toggle()
} label: {
Image(systemName: viewModel.spoilerOn ? "exclamationmark.triangle.fill": "exclamationmark.triangle")
}
visibilityMenu
Spacer()
characterCountView
}
.frame(height: 20)
.padding(.horizontal, DS.Constants.layoutPadding)
.padding(.vertical, 12)
.background(.ultraThinMaterial)
}
}
private var characterCountView: some View {
Text("\((currentInstance.instance?.configuration.statuses.maxCharacters ?? 500) - viewModel.statusText.string.utf16.count)")
.foregroundColor(.gray)
.font(.callout)
}
private var visibilityMenu: some View {
Menu {
ForEach(Models.Visibility.allCases, id: \.self) { visibility in
Button {
viewModel.visibility = visibility
} label: {
Label(visibility.title, systemImage: visibility.iconName)
}
}
} label: {
Image(systemName: viewModel.visibility.iconName)
}
}
}

View file

@ -0,0 +1,58 @@
import Foundation
import SwiftUI
import DesignSystem
struct StatusEditorAutoCompleteView: View {
@EnvironmentObject private var theme: Theme
@ObservedObject var viewModel: StatusEditorViewModel
var body: some View {
if !viewModel.mentionsSuggestions.isEmpty || !viewModel.tagsSuggestions.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
if !viewModel.mentionsSuggestions.isEmpty {
suggestionsMentionsView
} else {
suggestionsTagView
}
}
.padding(.horizontal, DS.Constants.layoutPadding)
}
.frame(height: 40)
.background(.ultraThinMaterial)
}
}
private var suggestionsMentionsView: some View {
ForEach(viewModel.mentionsSuggestions) { account in
Button {
viewModel.selectMentionSuggestion(account: account)
} label: {
HStack {
AvatarView(url: account.avatar, size: .badge)
VStack(alignment: .leading) {
Text(account.displayName)
.font(.footnote)
.foregroundColor(theme.labelColor)
Text("@\(account.acct)")
.font(.caption)
.foregroundColor(theme.tintColor)
}
}
}
}
}
private var suggestionsTagView: some View {
ForEach(viewModel.tagsSuggestions) { tag in
Button {
viewModel.selectHashtagSuggestion(tag: tag)
} label: {
Text("#\(tag.name)")
.font(.caption)
.foregroundColor(theme.tintColor)
}
}
}
}

View file

@ -0,0 +1,88 @@
import SwiftUI
import Env
import Models
import DesignSystem
import NukeUI
struct StatusEditorMediaView: View {
@ObservedObject var viewModel: StatusEditorViewModel
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(viewModel.mediasImages) { container in
if container.image != nil {
makeLocalImage(container: container)
} else if let url = container.mediaAttachement?.url {
ZStack(alignment: .topTrailing) {
makeLazyImage(url: url)
Button {
withAnimation {
viewModel.mediasImages.removeAll(where: { $0.id == container.id })
}
} label: {
Image(systemName: "xmark.circle")
}
.padding(8)
}
}
}
}
.padding(.horizontal, DS.Constants.layoutPadding)
}
}
private func makeLocalImage(container: StatusEditorViewModel.ImageContainer) -> some View {
ZStack(alignment: .center) {
Image(uiImage: container.image!)
.resizable()
.blur(radius: 20 )
.aspectRatio(contentMode: .fill)
.frame(width: 150, height: 150)
.cornerRadius(8)
if container.error != nil {
VStack {
Text("Error uploading")
Button {
withAnimation {
viewModel.mediasImages.removeAll(where: { $0.id == container.id })
}
} label: {
VStack {
Text("Delete")
}
}
.buttonStyle(.bordered)
Button {
Task {
await viewModel.upload(container: container)
}
} label: {
VStack {
Text("Retry")
}
}
.buttonStyle(.bordered)
}
} else {
ProgressView()
}
}
}
private func makeLazyImage(url: URL?) -> some View {
LazyImage(url: url) { state in
if let image = state.image {
image
.resizingMode(.aspectFill)
.frame(width: 150, height: 150)
} else {
Rectangle()
.frame(width: 150, height: 150)
}
}
.frame(width: 150, height: 150)
.cornerRadius(8)
}
}

View file

@ -10,9 +10,7 @@ import NukeUI
public struct StatusEditorView: View {
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var quicklook: QuickLook
@EnvironmentObject private var client: Client
@EnvironmentObject private var currentInstance: CurrentInstance
@EnvironmentObject private var currentAccount: CurrentAccount
@Environment(\.dismiss) private var dismiss
@ -39,12 +37,17 @@ public struct StatusEditorView: View {
StatusEmbededView(status: status)
.padding(.horizontal, DS.Constants.layoutPadding)
}
mediasView
StatusEditorMediaView(viewModel: viewModel)
Spacer()
}
.padding(.top, 8)
.padding(.bottom, 40)
}
VStack(alignment: .leading, spacing: 0) {
StatusEditorAutoCompleteView(viewModel: viewModel)
StatusEditorAccessoryView(isSpoilerTextFocused: $isSpoilerTextFocused,
viewModel: viewModel)
}
accessoryView
}
.onAppear {
viewModel.client = client
@ -116,146 +119,4 @@ public struct StatusEditorView: View {
}
}
}
private var mediasView: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(viewModel.mediasImages) { container in
if container.image != nil {
makeLocalImage(container: container)
} else if let url = container.mediaAttachement?.url {
ZStack(alignment: .topTrailing) {
makeLazyImage(url: url)
Button {
withAnimation {
viewModel.mediasImages.removeAll(where: { $0.id == container.id })
}
} label: {
Image(systemName: "xmark.circle")
}
.padding(8)
}
}
}
}
.padding(.horizontal, DS.Constants.layoutPadding)
}
}
private func makeLocalImage(container: StatusEditorViewModel.ImageContainer) -> some View {
ZStack(alignment: .center) {
Image(uiImage: container.image!)
.resizable()
.blur(radius: 20 )
.aspectRatio(contentMode: .fill)
.frame(width: 150, height: 150)
.cornerRadius(8)
if container.error != nil {
VStack {
Text("Error uploading")
Button {
withAnimation {
viewModel.mediasImages.removeAll(where: { $0.id == container.id })
}
} label: {
VStack {
Text("Delete")
}
}
.buttonStyle(.bordered)
Button {
Task {
await viewModel.upload(container: container)
}
} label: {
VStack {
Text("Retry")
}
}
.buttonStyle(.bordered)
}
} else {
ProgressView()
}
}
}
private func makeLazyImage(url: URL?) -> some View {
LazyImage(url: url) { state in
if let image = state.image {
image
.resizingMode(.aspectFill)
.frame(width: 150, height: 150)
} else {
Rectangle()
.frame(width: 150, height: 150)
}
}
.frame(width: 150, height: 150)
.cornerRadius(8)
}
private var accessoryView: some View {
VStack(spacing: 0) {
Divider()
HStack(alignment: .center, spacing: 16) {
PhotosPicker(selection: $viewModel.selectedMedias,
matching: .images) {
Image(systemName: "photo.fill.on.rectangle.fill")
}
Button {
viewModel.insertStatusText(text: " @")
} label: {
Image(systemName: "at")
}
Button {
viewModel.insertStatusText(text: " #")
} label: {
Image(systemName: "number")
}
Button {
withAnimation {
viewModel.spoilerOn.toggle()
}
isSpoilerTextFocused.toggle()
} label: {
Image(systemName: viewModel.spoilerOn ? "exclamationmark.triangle.fill": "exclamationmark.triangle")
}
visibilityMenu
Spacer()
characterCountView
}
.frame(height: 20)
.padding(.horizontal, DS.Constants.layoutPadding)
.padding(.vertical, 12)
.background(.ultraThinMaterial)
}
}
private var characterCountView: some View {
Text("\((currentInstance.instance?.configuration.statuses.maxCharacters ?? 500) - viewModel.statusText.string.utf16.count)")
.foregroundColor(.gray)
.font(.callout)
}
private var visibilityMenu: some View {
Menu {
ForEach(Models.Visibility.allCases, id: \.self) { visibility in
Button {
viewModel.visibility = visibility
} label: {
Label(visibility.title, systemImage: visibility.iconName)
}
}
} label: {
Image(systemName: viewModel.visibility.iconName)
}
}
}

View file

@ -46,6 +46,10 @@ public class StatusEditorViewModel: ObservableObject {
@Published var visibility: Models.Visibility = .pub
@Published var mentionsSuggestions: [Account] = []
@Published var tagsSuggestions: [Tag] = []
private var currentSuggestionRange: NSRange?
private var embededStatusURL: URL? {
return embededStatus?.reblog?.url ?? embededStatus?.url
}
@ -63,6 +67,14 @@ public class StatusEditorViewModel: ObservableObject {
selectedRange = NSRange(location: selectedRange.location + text.utf16.count, length: 0)
}
func replaceTextWith(text: String, inRange: NSRange) {
let string = statusText
string.mutableString.deleteCharacters(in: inRange)
string.mutableString.insert(text, at: inRange.location)
statusText = string
selectedRange = NSRange(location: inRange.location + text.utf16.count, length: 0)
}
func postStatus() async -> Status? {
guard let client else { return nil }
do {
@ -143,9 +155,20 @@ public class StatusEditorViewModel: ObservableObject {
options: [],
range: NSMakeRange(0, statusText.string.utf16.count)).map { $0.range }
for range in ranges {
var foundSuggestionRange: Bool = false
for nsRange in ranges {
statusText.addAttributes([.foregroundColor: UIColor(Color.brand)],
range: NSRange(location: range.location, length: range.length))
range: nsRange)
if selectedRange.location == (nsRange.location + nsRange.length),
let range = Range(nsRange, in: statusText.string) {
foundSuggestionRange = true
currentSuggestionRange = nsRange
loadAutoCompleteResults(query: String(statusText.string[range]))
}
}
if !foundSuggestionRange || ranges.isEmpty{
resetAutoCompletion()
}
for range in urlRanges {
@ -167,6 +190,60 @@ public class StatusEditorViewModel: ObservableObject {
}
}
// MARK: - Autocomplete
private func loadAutoCompleteResults(query: String) {
guard let client, query.utf8.count > 1 else { return }
Task {
do {
var results: SearchResults?
switch query.first {
case "#":
results = try await client.get(endpoint: Search.search(query: query,
type: "hashtags",
offset: 0,
following: nil),
forceVersion: .v2)
withAnimation {
tagsSuggestions = results?.hashtags ?? []
}
case "@":
results = try await client.get(endpoint: Search.search(query: query,
type: "accounts",
offset: 0,
following: true),
forceVersion: .v2)
withAnimation {
mentionsSuggestions = results?.accounts ?? []
}
break
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)
}
}
// MARK: - Media related function
private func indexOf(container: ImageContainer) -> Int? {

View file

@ -56,7 +56,8 @@ public class StatusRowViewModel: ObservableObject {
} else {
let results: SearchResults = try await client.get(endpoint: Search.search(query: url.absoluteString,
type: "statuses",
offset: 0),
offset: 0,
following: nil),
forceVersion: .v2)
embed = results.statuses.first
}