mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-11-26 10:11:00 +00:00
Editor: Refactor + Add autocomplete for mentions and hashtag
This commit is contained in:
parent
b1c46f2f22
commit
bb47937eb6
9 changed files with 319 additions and 153 deletions
|
@ -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))
|
||||
|
|
|
@ -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 }))
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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? {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue