Quote status + embed status

This commit is contained in:
Thomas Ricouard 2022-12-27 07:51:44 +01:00
parent dcd686a44b
commit e5fb3acd07
14 changed files with 230 additions and 58 deletions

View File

@ -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";

View File

@ -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))
} }
} }
} }

View File

@ -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)
} }

View File

@ -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"
} }
} }

View File

@ -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 {

View File

@ -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 { }
}
} }

View File

@ -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

View File

@ -0,0 +1,7 @@
import Foundation
public struct SearchResults: Decodable {
public let accounts: [Account]
public let statuses: [Status]
public let hashtags: [Tag]
}

View File

@ -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)

View File

@ -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
}
}
}

View File

@ -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

View File

@ -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")

View File

@ -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

View File

@ -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)