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_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@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_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
|
|
|
@ -38,6 +38,8 @@ extension View {
|
||||||
StatusEditorView(mode: .new)
|
StatusEditorView(mode: .new)
|
||||||
case let .editStatusEditor(status):
|
case let .editStatusEditor(status):
|
||||||
StatusEditorView(mode: .edit(status: 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 struct AvatarView: View {
|
||||||
public enum Size {
|
public enum Size {
|
||||||
case account, status, badge
|
case account, status, embed, badge
|
||||||
|
|
||||||
var size: CGSize {
|
var size: CGSize {
|
||||||
switch self {
|
switch self {
|
||||||
|
@ -13,6 +13,8 @@ public struct AvatarView: View {
|
||||||
return .init(width: 80, height: 80)
|
return .init(width: 80, height: 80)
|
||||||
case .status:
|
case .status:
|
||||||
return .init(width: 40, height: 40)
|
return .init(width: 40, height: 40)
|
||||||
|
case .embed:
|
||||||
|
return .init(width: 34, height: 34)
|
||||||
case .badge:
|
case .badge:
|
||||||
return .init(width: 28, height: 28)
|
return .init(width: 28, height: 28)
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,12 +15,13 @@ public enum RouteurDestinations: Hashable {
|
||||||
|
|
||||||
public enum SheetDestinations: Identifiable {
|
public enum SheetDestinations: Identifiable {
|
||||||
case newStatusEditor
|
case newStatusEditor
|
||||||
case editStatusEditor(status: Status)
|
case editStatusEditor(status: AnyStatus)
|
||||||
case replyToStatusEditor(status: Status)
|
case replyToStatusEditor(status: AnyStatus)
|
||||||
|
case quoteStatusEditor(status: AnyStatus)
|
||||||
|
|
||||||
public var id: String {
|
public var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .editStatusEditor, .newStatusEditor, .replyToStatusEditor:
|
case .editStatusEditor, .newStatusEditor, .replyToStatusEditor, .quoteStatusEditor:
|
||||||
return "statusEditor"
|
return "statusEditor"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,8 +13,7 @@ public struct ExploreView: View {
|
||||||
@EnvironmentObject private var routeurPath: RouterPath
|
@EnvironmentObject private var routeurPath: RouterPath
|
||||||
|
|
||||||
@StateObject private var viewModel = ExploreViewModel()
|
@StateObject private var viewModel = ExploreViewModel()
|
||||||
@State private var searchQuery: String = ""
|
|
||||||
|
|
||||||
public init() { }
|
public init() { }
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
|
@ -45,7 +44,13 @@ public struct ExploreView: View {
|
||||||
}
|
}
|
||||||
.listStyle(.grouped)
|
.listStyle(.grouped)
|
||||||
.navigationTitle("Explore")
|
.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 {
|
private var suggestedAccountsSection: some View {
|
||||||
|
|
|
@ -6,6 +6,28 @@ import Network
|
||||||
class ExploreViewModel: ObservableObject {
|
class ExploreViewModel: ObservableObject {
|
||||||
var client: Client?
|
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 isLoaded = false
|
||||||
@Published var suggestedAccounts: [Account] = []
|
@Published var suggestedAccounts: [Account] = []
|
||||||
@Published var suggestedAccountsRelationShips: [Relationshionship] = []
|
@Published var suggestedAccountsRelationShips: [Relationshionship] = []
|
||||||
|
@ -32,4 +54,11 @@ class ExploreViewModel: ObservableObject {
|
||||||
isLoaded = true
|
isLoaded = true
|
||||||
} catch { }
|
} 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 {
|
public var asSafeAttributedString: AttributedString {
|
||||||
do {
|
do {
|
||||||
// Add space between hashtags and mentions that follow each other
|
// 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 {
|
public enum Version: String {
|
||||||
case v1
|
case v1, v2
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum OauthError: Error {
|
public enum OauthError: Error {
|
||||||
|
@ -40,14 +40,14 @@ public class Client: ObservableObject, Equatable {
|
||||||
self.oauthToken = oauthToken
|
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()
|
var components = URLComponents()
|
||||||
components.scheme = scheme
|
components.scheme = scheme
|
||||||
components.host = server
|
components.host = server
|
||||||
if type(of: endpoint) == Oauth.self {
|
if type(of: endpoint) == Oauth.self {
|
||||||
components.path += "/\(endpoint.path())"
|
components.path += "/\(endpoint.path())"
|
||||||
} else {
|
} else {
|
||||||
components.path += "/api/\(version.rawValue)/\(endpoint.path())"
|
components.path += "/api/\(forceVersion?.rawValue ?? version.rawValue)/\(endpoint.path())"
|
||||||
}
|
}
|
||||||
components.queryItems = endpoint.queryItems()
|
components.queryItems = endpoint.queryItems()
|
||||||
return components.url!
|
return components.url!
|
||||||
|
@ -67,8 +67,8 @@ public class Client: ObservableObject, Equatable {
|
||||||
return makeURLRequest(url: url, httpMethod: "GET")
|
return makeURLRequest(url: url, httpMethod: "GET")
|
||||||
}
|
}
|
||||||
|
|
||||||
public func get<Entity: Decodable>(endpoint: Endpoint) async throws -> Entity {
|
public func get<Entity: Decodable>(endpoint: Endpoint, forceVersion: Version? = nil) async throws -> Entity {
|
||||||
try await makeEntityRequest(endpoint: endpoint, method: "GET")
|
try await makeEntityRequest(endpoint: endpoint, method: "GET", forceVersion: forceVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getWithLink<Entity: Decodable>(endpoint: Endpoint) async throws -> (Entity, LinkHandler?) {
|
public func getWithLink<Entity: Decodable>(endpoint: Endpoint) async throws -> (Entity, LinkHandler?) {
|
||||||
|
@ -97,8 +97,10 @@ public class Client: ObservableObject, Equatable {
|
||||||
return httpResponse as? HTTPURLResponse
|
return httpResponse as? HTTPURLResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeEntityRequest<Entity: Decodable>(endpoint: Endpoint, method: String) async throws -> Entity {
|
private func makeEntityRequest<Entity: Decodable>(endpoint: Endpoint,
|
||||||
let url = makeURL(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 request = makeURLRequest(url: url, httpMethod: method)
|
||||||
let (data, httpResponse) = try await urlSession.data(for: request)
|
let (data, httpResponse) = try await urlSession.data(for: request)
|
||||||
logResponseOnError(httpResponse: httpResponse, data: data)
|
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
|
@MainActor
|
||||||
public class StatusEditorViewModel: ObservableObject {
|
public class StatusEditorViewModel: ObservableObject {
|
||||||
public enum Mode {
|
public enum Mode {
|
||||||
case replyTo(status: Status)
|
case replyTo(status: AnyStatus)
|
||||||
case new
|
case new
|
||||||
case edit(status: Status)
|
case edit(status: AnyStatus)
|
||||||
|
case quote(status: AnyStatus)
|
||||||
|
|
||||||
var replyToStatus: Status? {
|
var replyToStatus: AnyStatus? {
|
||||||
switch self {
|
switch self {
|
||||||
case let .replyTo(status):
|
case let .replyTo(status):
|
||||||
return status
|
return status
|
||||||
|
@ -28,6 +29,8 @@ public class StatusEditorViewModel: ObservableObject {
|
||||||
return "Edit your post"
|
return "Edit your post"
|
||||||
case let .replyTo(status):
|
case let .replyTo(status):
|
||||||
return "Reply to \(status.account.displayName)"
|
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
|
isPosting = true
|
||||||
let postStatus: Status?
|
let postStatus: Status?
|
||||||
switch mode {
|
switch mode {
|
||||||
case .new, .replyTo:
|
case .new, .replyTo, .quote:
|
||||||
postStatus = try await client.post(endpoint: Statuses.postStatus(status: statusText.string,
|
postStatus = try await client.post(endpoint: Statuses.postStatus(status: statusText.string,
|
||||||
inReplyTo: mode.replyToStatus?.id,
|
inReplyTo: mode.replyToStatus?.id,
|
||||||
mediaIds: nil,
|
mediaIds: nil,
|
||||||
|
@ -96,6 +99,10 @@ public class StatusEditorViewModel: ObservableObject {
|
||||||
statusText = .init(string: "@\(status.account.acct) ")
|
statusText = .init(string: "@\(status.account.acct) ")
|
||||||
case let .edit(status):
|
case let .edit(status):
|
||||||
statusText = .init(string: status.content.asRawText)
|
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:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -107,11 +114,13 @@ public class StatusEditorViewModel: ObservableObject {
|
||||||
range: NSMakeRange(0, mutableString.string.utf16.count))
|
range: NSMakeRange(0, mutableString.string.utf16.count))
|
||||||
let hashtagPattern = "(#+[a-zA-Z0-9(_)]{1,})"
|
let hashtagPattern = "(#+[a-zA-Z0-9(_)]{1,})"
|
||||||
let mentionPattern = "(@+[a-zA-Z0-9(_).]{1,})"
|
let mentionPattern = "(@+[a-zA-Z0-9(_).]{1,})"
|
||||||
|
let urlPattern = "(?i)https?://(?:www\\.)?\\S+(?:/|\\b)"
|
||||||
var ranges: [NSRange] = [NSRange]()
|
var ranges: [NSRange] = [NSRange]()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let hashtagRegex = try NSRegularExpression(pattern: hashtagPattern, options: [])
|
let hashtagRegex = try NSRegularExpression(pattern: hashtagPattern, options: [])
|
||||||
let mentionRegex = try NSRegularExpression(pattern: mentionPattern, options: [])
|
let mentionRegex = try NSRegularExpression(pattern: mentionPattern, options: [])
|
||||||
|
let urlRegex = try NSRegularExpression(pattern: urlPattern, options: [])
|
||||||
|
|
||||||
ranges = hashtagRegex.matches(in: mutableString.string,
|
ranges = hashtagRegex.matches(in: mutableString.string,
|
||||||
options: [],
|
options: [],
|
||||||
|
@ -119,11 +128,22 @@ public class StatusEditorViewModel: ObservableObject {
|
||||||
ranges.append(contentsOf: mentionRegex.matches(in: mutableString.string,
|
ranges.append(contentsOf: mentionRegex.matches(in: mutableString.string,
|
||||||
options: [],
|
options: [],
|
||||||
range: NSMakeRange(0, mutableString.string.utf16.count)).map {$0.range})
|
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 {
|
for range in ranges {
|
||||||
mutableString.addAttributes([.foregroundColor: UIColor(Color.brand)],
|
mutableString.addAttributes([.foregroundColor: UIColor(Color.brand)],
|
||||||
range: NSRange(location: range.location, length: range.length))
|
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
|
internalUpdate = true
|
||||||
statusText = mutableString
|
statusText = mutableString
|
||||||
internalUpdate = false
|
internalUpdate = false
|
||||||
|
|
|
@ -35,6 +35,11 @@ public struct StatusRowView: View {
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
viewModel.client = client
|
viewModel.client = client
|
||||||
|
if !viewModel.isEmbed {
|
||||||
|
Task {
|
||||||
|
await viewModel.loadEmbededStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -87,48 +92,72 @@ public struct StatusRowView: View {
|
||||||
menuButton
|
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 {
|
Group {
|
||||||
Text(status.content.asSafeAttributedString)
|
Text("@\(status.account.acct)") +
|
||||||
.font(.body)
|
Text(" ⸱ ") +
|
||||||
.environment(\.openURL, OpenURLAction { url in
|
Text(status.createdAt.formatted)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
.font(size == .embed ? .caption : .footnote)
|
||||||
|
.foregroundColor(.gray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func makeAccountView(status: AnyStatus) -> some View {
|
private var embededStatusView: some View {
|
||||||
AvatarView(url: status.account.avatar, size: .status)
|
if let status = viewModel.embededStatus {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading) {
|
||||||
status.account.displayNameWithEmojis
|
makeAccountView(status: status, size: .embed)
|
||||||
.font(.subheadline)
|
StatusRowView(viewModel: .init(status: status, isEmbed: true))
|
||||||
.fontWeight(.semibold)
|
|
||||||
Group {
|
|
||||||
Text("@\(status.account.acct)") +
|
|
||||||
Text(" ⸱ ") +
|
|
||||||
Text(status.createdAt.formatted)
|
|
||||||
}
|
}
|
||||||
.font(.footnote)
|
.padding(8)
|
||||||
.foregroundColor(.gray)
|
.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: {
|
||||||
Label(viewModel.isFavourited ? "Unfavorite" : "Favorite", systemImage: "star")
|
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 {
|
if let url = viewModel.status.reblog?.url ?? viewModel.status.url {
|
||||||
Button { UIApplication.shared.open(url) } label: {
|
Button { UIApplication.shared.open(url) } label: {
|
||||||
Label("View in Browser", systemImage: "safari")
|
Label("View in Browser", systemImage: "safari")
|
||||||
|
|
|
@ -13,6 +13,7 @@ public class StatusRowViewModel: ObservableObject {
|
||||||
@Published var isReblogged: Bool
|
@Published var isReblogged: Bool
|
||||||
@Published var reblogsCount: Int
|
@Published var reblogsCount: Int
|
||||||
@Published var repliesCount: Int
|
@Published var repliesCount: Int
|
||||||
|
@Published var embededStatus: Status?
|
||||||
|
|
||||||
var client: Client?
|
var client: Client?
|
||||||
|
|
||||||
|
@ -34,6 +35,16 @@ public class StatusRowViewModel: ObservableObject {
|
||||||
self.repliesCount = status.reblog?.repliesCount ?? status.repliesCount
|
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 {
|
func favourite() async {
|
||||||
guard let client, client.isAuth else { return }
|
guard let client, client.isAuth else { return }
|
||||||
isFavourited = true
|
isFavourited = true
|
||||||
|
|
|
@ -7,6 +7,10 @@ import DesignSystem
|
||||||
import Env
|
import Env
|
||||||
|
|
||||||
public struct TimelineView: View {
|
public struct TimelineView: View {
|
||||||
|
private enum Constants {
|
||||||
|
static let scrollToTop = "top"
|
||||||
|
}
|
||||||
|
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@EnvironmentObject private var account: CurrentAccount
|
@EnvironmentObject private var account: CurrentAccount
|
||||||
@EnvironmentObject private var watcher: StreamWatcher
|
@EnvironmentObject private var watcher: StreamWatcher
|
||||||
|
@ -25,7 +29,7 @@ public struct TimelineView: View {
|
||||||
LazyVStack {
|
LazyVStack {
|
||||||
tagHeaderView
|
tagHeaderView
|
||||||
.padding(.bottom, 16)
|
.padding(.bottom, 16)
|
||||||
.id("top")
|
.id(Constants.scrollToTop)
|
||||||
StatusesListView(fetcher: viewModel)
|
StatusesListView(fetcher: viewModel)
|
||||||
}
|
}
|
||||||
.padding(.top, DS.Constants.layoutPadding)
|
.padding(.top, DS.Constants.layoutPadding)
|
||||||
|
@ -70,7 +74,7 @@ public struct TimelineView: View {
|
||||||
private func makePendingNewPostsView(proxy: ScrollViewProxy) -> some View {
|
private func makePendingNewPostsView(proxy: ScrollViewProxy) -> some View {
|
||||||
if !viewModel.pendingStatuses.isEmpty {
|
if !viewModel.pendingStatuses.isEmpty {
|
||||||
Button {
|
Button {
|
||||||
proxy.scrollTo("top")
|
proxy.scrollTo(Constants.scrollToTop)
|
||||||
viewModel.displayPendingStatuses()
|
viewModel.displayPendingStatuses()
|
||||||
} label: {
|
} label: {
|
||||||
Text(viewModel.pendingStatusesButtonTitle)
|
Text(viewModel.pendingStatusesButtonTitle)
|
||||||
|
|
Loading…
Reference in New Issue