diff --git a/IceCubesApp.xcodeproj/project.pbxproj b/IceCubesApp.xcodeproj/project.pbxproj index e0d03ce3..d3a64ba6 100644 --- a/IceCubesApp.xcodeproj/project.pbxproj +++ b/IceCubesApp.xcodeproj/project.pbxproj @@ -411,8 +411,8 @@ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 16.1; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; @@ -457,8 +457,8 @@ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 16.1; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; diff --git a/IceCubesApp/App/AppRouteur.swift b/IceCubesApp/App/AppRouteur.swift index cbbfbe49..11f3c9fe 100644 --- a/IceCubesApp/App/AppRouteur.swift +++ b/IceCubesApp/App/AppRouteur.swift @@ -38,6 +38,8 @@ extension View { StatusEditorView(mode: .new) case let .editStatusEditor(status): StatusEditorView(mode: .edit(status: status)) + case let .quoteStatusEditor(status): + StatusEditorView(mode: .quote(status: status)) } } } diff --git a/Packages/DesignSystem/Sources/DesignSystem/Views/AvatarView.swift b/Packages/DesignSystem/Sources/DesignSystem/Views/AvatarView.swift index 442691b1..52772aa8 100644 --- a/Packages/DesignSystem/Sources/DesignSystem/Views/AvatarView.swift +++ b/Packages/DesignSystem/Sources/DesignSystem/Views/AvatarView.swift @@ -5,7 +5,7 @@ import Nuke public struct AvatarView: View { public enum Size { - case account, status, badge + case account, status, embed, badge var size: CGSize { switch self { @@ -13,6 +13,8 @@ public struct AvatarView: View { return .init(width: 80, height: 80) case .status: return .init(width: 40, height: 40) + case .embed: + return .init(width: 34, height: 34) case .badge: return .init(width: 28, height: 28) } diff --git a/Packages/Env/Sources/Env/Routeur.swift b/Packages/Env/Sources/Env/Routeur.swift index b538d66b..e5e292f9 100644 --- a/Packages/Env/Sources/Env/Routeur.swift +++ b/Packages/Env/Sources/Env/Routeur.swift @@ -15,12 +15,13 @@ public enum RouteurDestinations: Hashable { public enum SheetDestinations: Identifiable { case newStatusEditor - case editStatusEditor(status: Status) - case replyToStatusEditor(status: Status) + case editStatusEditor(status: AnyStatus) + case replyToStatusEditor(status: AnyStatus) + case quoteStatusEditor(status: AnyStatus) public var id: String { switch self { - case .editStatusEditor, .newStatusEditor, .replyToStatusEditor: + case .editStatusEditor, .newStatusEditor, .replyToStatusEditor, .quoteStatusEditor: return "statusEditor" } } diff --git a/Packages/Explore/Sources/Explore/ExploreView.swift b/Packages/Explore/Sources/Explore/ExploreView.swift index d586d846..43ed6a00 100644 --- a/Packages/Explore/Sources/Explore/ExploreView.swift +++ b/Packages/Explore/Sources/Explore/ExploreView.swift @@ -13,8 +13,7 @@ public struct ExploreView: View { @EnvironmentObject private var routeurPath: RouterPath @StateObject private var viewModel = ExploreViewModel() - @State private var searchQuery: String = "" - + public init() { } public var body: some View { @@ -45,7 +44,13 @@ public struct ExploreView: View { } .listStyle(.grouped) .navigationTitle("Explore") - .searchable(text: $searchQuery) + .searchable(text: $viewModel.searchQuery, + tokens: $viewModel.tokens, + suggestedTokens: $viewModel.suggestedToken, + prompt: Text("Search users, posts and tags"), + token: { token in + Text(token.rawValue) + }) } private var suggestedAccountsSection: some View { diff --git a/Packages/Explore/Sources/Explore/ExploreViewModel.swift b/Packages/Explore/Sources/Explore/ExploreViewModel.swift index 242b2646..95d04462 100644 --- a/Packages/Explore/Sources/Explore/ExploreViewModel.swift +++ b/Packages/Explore/Sources/Explore/ExploreViewModel.swift @@ -6,6 +6,28 @@ import Network class ExploreViewModel: ObservableObject { var client: Client? + enum Token: String, Identifiable { + case user = "@user", tag = "#hasgtag" + + var id: String { + rawValue + } + } + + @Published var tokens: [Token] = [] + @Published var suggestedToken: [Token] = [] + @Published var searchQuery = "" { + didSet { + if searchQuery.starts(with: "@") { + suggestedToken = [.user] + } else if searchQuery.starts(with: "#") { + suggestedToken = [.tag] + } else if tokens.isEmpty { + suggestedToken = [] + } + } + } + @Published var results: [String: SearchResults] = [:] @Published var isLoaded = false @Published var suggestedAccounts: [Account] = [] @Published var suggestedAccountsRelationShips: [Relationshionship] = [] @@ -32,4 +54,11 @@ class ExploreViewModel: ObservableObject { isLoaded = true } catch { } } + + func search() async { + guard let client else { return } + do { + results[searchQuery] = try await client.get(endpoint: Search.search(query: searchQuery, type: nil, offset: nil), forceVersion: .v2) + } catch { } + } } diff --git a/Packages/Models/Sources/Models/Alias/HTMLString.swift b/Packages/Models/Sources/Models/Alias/HTMLString.swift index e7d0d47f..e04ec75c 100644 --- a/Packages/Models/Sources/Models/Alias/HTMLString.swift +++ b/Packages/Models/Sources/Models/Alias/HTMLString.swift @@ -24,6 +24,25 @@ extension HTMLString { } } + public func findStatusesIds(instance: String) -> [Int]? { + do { + let document: Document = try SwiftSoup.parse(self) + let links: Elements = try document.select("a") + var ids: [Int] = [] + for link in links { + let href = try link.attr("href") + if href.contains(instance), + let url = URL(string: href), + let statusId = Int(url.lastPathComponent) { + ids.append(statusId) + } + } + return ids + } catch { + return nil + } + } + public var asSafeAttributedString: AttributedString { do { // Add space between hashtags and mentions that follow each other diff --git a/Packages/Models/Sources/Models/SearchResults.swift b/Packages/Models/Sources/Models/SearchResults.swift new file mode 100644 index 00000000..836938c6 --- /dev/null +++ b/Packages/Models/Sources/Models/SearchResults.swift @@ -0,0 +1,7 @@ +import Foundation + +public struct SearchResults: Decodable { + public let accounts: [Account] + public let statuses: [Status] + public let hashtags: [Tag] +} diff --git a/Packages/Network/Sources/Network/Client.swift b/Packages/Network/Sources/Network/Client.swift index 230573b9..e84a4375 100644 --- a/Packages/Network/Sources/Network/Client.swift +++ b/Packages/Network/Sources/Network/Client.swift @@ -10,7 +10,7 @@ public class Client: ObservableObject, Equatable { } public enum Version: String { - case v1 + case v1, v2 } public enum OauthError: Error { @@ -40,14 +40,14 @@ public class Client: ObservableObject, Equatable { self.oauthToken = oauthToken } - private func makeURL(scheme: String = "https", endpoint: Endpoint) -> URL { + private func makeURL(scheme: String = "https", endpoint: Endpoint, forceVersion: Version? = nil) -> URL { var components = URLComponents() components.scheme = scheme components.host = server if type(of: endpoint) == Oauth.self { components.path += "/\(endpoint.path())" } else { - components.path += "/api/\(version.rawValue)/\(endpoint.path())" + components.path += "/api/\(forceVersion?.rawValue ?? version.rawValue)/\(endpoint.path())" } components.queryItems = endpoint.queryItems() return components.url! @@ -67,8 +67,8 @@ public class Client: ObservableObject, Equatable { return makeURLRequest(url: url, httpMethod: "GET") } - public func get(endpoint: Endpoint) async throws -> Entity { - try await makeEntityRequest(endpoint: endpoint, method: "GET") + public func get(endpoint: Endpoint, forceVersion: Version? = nil) async throws -> Entity { + try await makeEntityRequest(endpoint: endpoint, method: "GET", forceVersion: forceVersion) } public func getWithLink(endpoint: Endpoint) async throws -> (Entity, LinkHandler?) { @@ -97,8 +97,10 @@ public class Client: ObservableObject, Equatable { return httpResponse as? HTTPURLResponse } - private func makeEntityRequest(endpoint: Endpoint, method: String) async throws -> Entity { - let url = makeURL(endpoint: endpoint) + private func makeEntityRequest(endpoint: Endpoint, + method: String, + forceVersion: Version? = nil) async throws -> Entity { + let url = makeURL(endpoint: endpoint, forceVersion: forceVersion) let request = makeURLRequest(url: url, httpMethod: method) let (data, httpResponse) = try await urlSession.data(for: request) logResponseOnError(httpResponse: httpResponse, data: data) diff --git a/Packages/Network/Sources/Network/Endpoint/Search.swift b/Packages/Network/Sources/Network/Endpoint/Search.swift new file mode 100644 index 00000000..491f767f --- /dev/null +++ b/Packages/Network/Sources/Network/Endpoint/Search.swift @@ -0,0 +1,26 @@ +import Foundation + +public enum Search: Endpoint { + case search(query: String, type: String?, offset: Int?) + + public func path() -> String { + switch self { + case .search: + return "search" + } + } + + public func queryItems() -> [URLQueryItem]? { + switch self { + case let .search(query, type, offset): + var params: [URLQueryItem] = [.init(name: "q", value: query)] + if let type { + params.append(.init(name: "type", value: type)) + } + if let offset { + params.append(.init(name: "offset", value: String(offset))) + } + return params + } + } +} diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift index 2f2f4565..e27c0716 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift +++ b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift @@ -7,11 +7,12 @@ import PhotosUI @MainActor public class StatusEditorViewModel: ObservableObject { public enum Mode { - case replyTo(status: Status) + case replyTo(status: AnyStatus) case new - case edit(status: Status) + case edit(status: AnyStatus) + case quote(status: AnyStatus) - var replyToStatus: Status? { + var replyToStatus: AnyStatus? { switch self { case let .replyTo(status): return status @@ -28,6 +29,8 @@ public class StatusEditorViewModel: ObservableObject { return "Edit your post" case let .replyTo(status): return "Reply to \(status.account.displayName)" + case let .quote(status): + return "Quote of \(status.account.displayName)" } } } @@ -69,7 +72,7 @@ public class StatusEditorViewModel: ObservableObject { isPosting = true let postStatus: Status? switch mode { - case .new, .replyTo: + case .new, .replyTo, .quote: postStatus = try await client.post(endpoint: Statuses.postStatus(status: statusText.string, inReplyTo: mode.replyToStatus?.id, mediaIds: nil, @@ -96,6 +99,10 @@ public class StatusEditorViewModel: ObservableObject { statusText = .init(string: "@\(status.account.acct) ") case let .edit(status): statusText = .init(string: status.content.asRawText) + case let .quote(status): + if let url = status.url { + statusText = .init(string: "\n\nFrom: @\(status.account.acct)\n\(url)") + } default: break } @@ -107,11 +114,13 @@ public class StatusEditorViewModel: ObservableObject { range: NSMakeRange(0, mutableString.string.utf16.count)) let hashtagPattern = "(#+[a-zA-Z0-9(_)]{1,})" let mentionPattern = "(@+[a-zA-Z0-9(_).]{1,})" + let urlPattern = "(?i)https?://(?:www\\.)?\\S+(?:/|\\b)" var ranges: [NSRange] = [NSRange]() do { let hashtagRegex = try NSRegularExpression(pattern: hashtagPattern, options: []) let mentionRegex = try NSRegularExpression(pattern: mentionPattern, options: []) + let urlRegex = try NSRegularExpression(pattern: urlPattern, options: []) ranges = hashtagRegex.matches(in: mutableString.string, options: [], @@ -119,11 +128,22 @@ public class StatusEditorViewModel: ObservableObject { ranges.append(contentsOf: mentionRegex.matches(in: mutableString.string, options: [], range: NSMakeRange(0, mutableString.string.utf16.count)).map {$0.range}) + + let urlRanges = urlRegex.matches(in: mutableString.string, + options: [], + range: NSMakeRange(0, mutableString.string.utf16.count)).map { $0.range } for range in ranges { mutableString.addAttributes([.foregroundColor: UIColor(Color.brand)], range: NSRange(location: range.location, length: range.length)) } + + for range in urlRanges { + mutableString.addAttributes([.foregroundColor: UIColor(Color.brand), + .underlineStyle: NSUnderlineStyle.single, + .underlineColor: UIColor(Color.brand)], + range: NSRange(location: range.location, length: range.length)) + } internalUpdate = true statusText = mutableString internalUpdate = false diff --git a/Packages/Status/Sources/Status/Row/StatusRowView.swift b/Packages/Status/Sources/Status/Row/StatusRowView.swift index 73429f5d..4345961b 100644 --- a/Packages/Status/Sources/Status/Row/StatusRowView.swift +++ b/Packages/Status/Sources/Status/Row/StatusRowView.swift @@ -35,6 +35,11 @@ public struct StatusRowView: View { } .onAppear { viewModel.client = client + if !viewModel.isEmbed { + Task { + await viewModel.loadEmbededStatus() + } + } } } @@ -87,48 +92,72 @@ public struct StatusRowView: View { menuButton } } - + makeStatusContentView(status: status) + } + } + } + + private func makeStatusContentView(status: AnyStatus) -> some View { + Group { + Text(status.content.asSafeAttributedString) + .font(.body) + .environment(\.openURL, OpenURLAction { url in + routeurPath.handleStatus(status: status, url: url) + }) + + embededStatusView + + if !status.mediaAttachments.isEmpty { + if viewModel.isEmbed { + Image(systemName: "paperclip") + } else { + StatusMediaPreviewView(attachements: status.mediaAttachments) + .padding(.vertical, 4) + } + } + if let card = status.card, !viewModel.isEmbed { + StatusCardView(card: card) + } + } + .contentShape(Rectangle()) + .onTapGesture { + routeurPath.navigate(to: .statusDetail(id: viewModel.status.reblog?.id ?? viewModel.status.id)) + } + } + + @ViewBuilder + private func makeAccountView(status: AnyStatus, size: AvatarView.Size = .status) -> some View { + HStack(alignment: .center) { + AvatarView(url: status.account.avatar, size: size) + VStack(alignment: .leading, spacing: 0) { + status.account.displayNameWithEmojis + .font(size == .embed ? .footnote : .headline) + .fontWeight(.semibold) Group { - Text(status.content.asSafeAttributedString) - .font(.body) - .environment(\.openURL, OpenURLAction { url in - routeurPath.handleStatus(status: status, url: url) - }) - - if !status.mediaAttachments.isEmpty { - if viewModel.isEmbed { - Image(systemName: "paperclip") - } else { - StatusMediaPreviewView(attachements: status.mediaAttachments) - .padding(.vertical, 4) - } - } - if let card = status.card, !viewModel.isEmbed { - StatusCardView(card: card) - } - } - .contentShape(Rectangle()) - .onTapGesture { - routeurPath.navigate(to: .statusDetail(id: viewModel.status.reblog?.id ?? viewModel.status.id)) + Text("@\(status.account.acct)") + + Text(" ⸱ ") + + Text(status.createdAt.formatted) } + .font(size == .embed ? .caption : .footnote) + .foregroundColor(.gray) } } } @ViewBuilder - private func makeAccountView(status: AnyStatus) -> some View { - AvatarView(url: status.account.avatar, size: .status) - VStack(alignment: .leading, spacing: 0) { - status.account.displayNameWithEmojis - .font(.subheadline) - .fontWeight(.semibold) - Group { - Text("@\(status.account.acct)") + - Text(" ⸱ ") + - Text(status.createdAt.formatted) + private var embededStatusView: some View { + if let status = viewModel.embededStatus { + VStack(alignment: .leading) { + makeAccountView(status: status, size: .embed) + StatusRowView(viewModel: .init(status: status, isEmbed: true)) } - .font(.footnote) - .foregroundColor(.gray) + .padding(8) + .background(Color.gray.opacity(0.10)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(.gray.opacity(0.35), lineWidth: 1) + ) + .padding(.top, 8) } } @@ -154,6 +183,21 @@ public struct StatusRowView: View { } } label: { Label(viewModel.isFavourited ? "Unfavorite" : "Favorite", systemImage: "star") } + Button { Task { + if viewModel.isReblogged { + await viewModel.unReblog() + } else { + await viewModel.reblog() + } + } } label: { + Label(viewModel.isReblogged ? "Unboost" : "Boost", systemImage: "arrow.left.arrow.right.circle") + } + Button { + routeurPath.presentedSheet = .quoteStatusEditor(status: viewModel.status.reblog ?? viewModel.status) + } label: { + Label("Quote this status", systemImage: "quote.bubble") + } + if let url = viewModel.status.reblog?.url ?? viewModel.status.url { Button { UIApplication.shared.open(url) } label: { Label("View in Browser", systemImage: "safari") diff --git a/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift b/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift index 4233facd..5d0aead3 100644 --- a/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift +++ b/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift @@ -13,6 +13,7 @@ public class StatusRowViewModel: ObservableObject { @Published var isReblogged: Bool @Published var reblogsCount: Int @Published var repliesCount: Int + @Published var embededStatus: Status? var client: Client? @@ -34,6 +35,16 @@ public class StatusRowViewModel: ObservableObject { self.repliesCount = status.reblog?.repliesCount ?? status.repliesCount } + func loadEmbededStatus() async { + guard let client, + let ids = status.content.findStatusesIds(instance: client.server), + !ids.isEmpty, + let id = ids.first else { return } + do { + self.embededStatus = try await client.get(endpoint: Statuses.status(id: String(id))) + } catch { } + } + func favourite() async { guard let client, client.isAuth else { return } isFavourited = true diff --git a/Packages/Timeline/Sources/Timeline/TimelineView.swift b/Packages/Timeline/Sources/Timeline/TimelineView.swift index c0099fcd..ad0eff0e 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineView.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineView.swift @@ -7,6 +7,10 @@ import DesignSystem import Env public struct TimelineView: View { + private enum Constants { + static let scrollToTop = "top" + } + @Environment(\.scenePhase) private var scenePhase @EnvironmentObject private var account: CurrentAccount @EnvironmentObject private var watcher: StreamWatcher @@ -25,7 +29,7 @@ public struct TimelineView: View { LazyVStack { tagHeaderView .padding(.bottom, 16) - .id("top") + .id(Constants.scrollToTop) StatusesListView(fetcher: viewModel) } .padding(.top, DS.Constants.layoutPadding) @@ -70,7 +74,7 @@ public struct TimelineView: View { private func makePendingNewPostsView(proxy: ScrollViewProxy) -> some View { if !viewModel.pendingStatuses.isEmpty { Button { - proxy.scrollTo("top") + proxy.scrollTo(Constants.scrollToTop) viewModel.displayPendingStatuses() } label: { Text(viewModel.pendingStatusesButtonTitle)