Quote status + embed status
This commit is contained in:
parent
dcd686a44b
commit
e5fb3acd07
|
@ -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";
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,6 @@ public struct ExploreView: View {
|
|||
@EnvironmentObject private var routeurPath: RouterPath
|
||||
|
||||
@StateObject private var viewModel = ExploreViewModel()
|
||||
@State private var searchQuery: String = ""
|
||||
|
||||
public init() { }
|
||||
|
||||
|
@ -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 {
|
||||
|
|
|
@ -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 { }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import Foundation
|
||||
|
||||
public struct SearchResults: Decodable {
|
||||
public let accounts: [Account]
|
||||
public let statuses: [Status]
|
||||
public let hashtags: [Tag]
|
||||
}
|
|
@ -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<Entity: Decodable>(endpoint: Endpoint) async throws -> Entity {
|
||||
try await makeEntityRequest(endpoint: endpoint, method: "GET")
|
||||
public func get<Entity: Decodable>(endpoint: Endpoint, forceVersion: Version? = nil) async throws -> Entity {
|
||||
try await makeEntityRequest(endpoint: endpoint, method: "GET", forceVersion: forceVersion)
|
||||
}
|
||||
|
||||
public func getWithLink<Entity: Decodable>(endpoint: Endpoint) async throws -> (Entity, LinkHandler?) {
|
||||
|
@ -97,8 +97,10 @@ public class Client: ObservableObject, Equatable {
|
|||
return httpResponse as? HTTPURLResponse
|
||||
}
|
||||
|
||||
private func makeEntityRequest<Entity: Decodable>(endpoint: Endpoint, method: String) async throws -> Entity {
|
||||
let url = makeURL(endpoint: endpoint)
|
||||
private func makeEntityRequest<Entity: Decodable>(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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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: [],
|
||||
|
@ -120,10 +129,21 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
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
|
||||
|
|
|
@ -35,6 +35,11 @@ public struct StatusRowView: View {
|
|||
}
|
||||
.onAppear {
|
||||
viewModel.client = client
|
||||
if !viewModel.isEmbed {
|
||||
Task {
|
||||
await viewModel.loadEmbededStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -87,7 +92,12 @@ public struct StatusRowView: View {
|
|||
menuButton
|
||||
}
|
||||
}
|
||||
makeStatusContentView(status: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeStatusContentView(status: AnyStatus) -> some View {
|
||||
Group {
|
||||
Text(status.content.asSafeAttributedString)
|
||||
.font(.body)
|
||||
|
@ -95,6 +105,8 @@ public struct StatusRowView: View {
|
|||
routeurPath.handleStatus(status: status, url: url)
|
||||
})
|
||||
|
||||
embededStatusView
|
||||
|
||||
if !status.mediaAttachments.isEmpty {
|
||||
if viewModel.isEmbed {
|
||||
Image(systemName: "paperclip")
|
||||
|
@ -112,25 +124,42 @@ public struct StatusRowView: View {
|
|||
routeurPath.navigate(to: .statusDetail(id: viewModel.status.reblog?.id ?? viewModel.status.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeAccountView(status: AnyStatus) -> some View {
|
||||
AvatarView(url: status.account.avatar, size: .status)
|
||||
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(.subheadline)
|
||||
.font(size == .embed ? .footnote : .headline)
|
||||
.fontWeight(.semibold)
|
||||
Group {
|
||||
Text("@\(status.account.acct)") +
|
||||
Text(" ⸱ ") +
|
||||
Text(status.createdAt.formatted)
|
||||
}
|
||||
.font(.footnote)
|
||||
.font(size == .embed ? .caption : .footnote)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
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))
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(.gray.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private var menuButton: some View {
|
||||
Menu {
|
||||
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue