diff --git a/Packages/Env/Sources/Env/Routeur.swift b/Packages/Env/Sources/Env/Routeur.swift index f8077b91..dcf11a58 100644 --- a/Packages/Env/Sources/Env/Routeur.swift +++ b/Packages/Env/Sources/Env/Routeur.swift @@ -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)) diff --git a/Packages/Explore/Sources/Explore/ExploreViewModel.swift b/Packages/Explore/Sources/Explore/ExploreViewModel.swift index efdd40e8..774587c6 100644 --- a/Packages/Explore/Sources/Explore/ExploreViewModel.swift +++ b/Packages/Explore/Sources/Explore/ExploreViewModel.swift @@ -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 })) diff --git a/Packages/Network/Sources/Network/Endpoint/Search.swift b/Packages/Network/Sources/Network/Endpoint/Search.swift index 51f9a4f7..2fc9784c 100644 --- a/Packages/Network/Sources/Network/Endpoint/Search.swift +++ b/Packages/Network/Sources/Network/Endpoint/Search.swift @@ -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 } diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorAccessoryView.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorAccessoryView.swift new file mode 100644 index 00000000..03027743 --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/Components/StatusEditorAccessoryView.swift @@ -0,0 +1,76 @@ +import SwiftUI +import DesignSystem +import PhotosUI +import Models +import Env + +struct StatusEditorAccessoryView: View { + @EnvironmentObject private var currentInstance: CurrentInstance + + @FocusState.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) + } + } +} diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorAutoCompleteView.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorAutoCompleteView.swift new file mode 100644 index 00000000..0168da69 --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/Components/StatusEditorAutoCompleteView.swift @@ -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) + } + } + } + +} diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaView.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaView.swift new file mode 100644 index 00000000..a8bd36bb --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaView.swift @@ -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) + } + +} diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorView.swift b/Packages/Status/Sources/Status/Editor/StatusEditorView.swift index 131dd0df..419c2bf9 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorView.swift +++ b/Packages/Status/Sources/Status/Editor/StatusEditorView.swift @@ -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) - } - } - } diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift index b264211b..f02fe1b8 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift +++ b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift @@ -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? { diff --git a/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift b/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift index 4058dc35..25fc7eaa 100644 --- a/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift +++ b/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift @@ -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 }