Do string manipulation at the decode time to keep the UI smooth fix #178
This commit is contained in:
parent
7a0b635033
commit
d1034cd9a3
|
@ -106,7 +106,7 @@ struct AccountDetailHeaderView: View {
|
|||
accountAvatarView
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
EmojiTextApp(account.safeDisplayName.asMarkdown, emojis: account.emojis)
|
||||
EmojiTextApp(.init(stringValue: account.safeDisplayName), emojis: account.emojis)
|
||||
.font(.scaledHeadline)
|
||||
Text("@\(account.acct)")
|
||||
.font(.scaledCallout)
|
||||
|
@ -121,7 +121,7 @@ struct AccountDetailHeaderView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
EmojiTextApp(account.note.asMarkdown, emojis: account.emojis)
|
||||
EmojiTextApp(account.note, emojis: account.emojis)
|
||||
.font(.scaledBody)
|
||||
.padding(.top, 8)
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
|
|
|
@ -242,7 +242,7 @@ public struct AccountDetailView: View {
|
|||
Image(systemName: "checkmark.seal")
|
||||
.foregroundColor(Color.green.opacity(0.80))
|
||||
}
|
||||
EmojiTextApp(field.value.asMarkdown, emojis: viewModel.account?.emojis ?? [])
|
||||
EmojiTextApp(field.value, emojis: viewModel.account?.emojis ?? [])
|
||||
.foregroundColor(theme.tintColor)
|
||||
}
|
||||
.font(.scaledBody)
|
||||
|
@ -360,7 +360,7 @@ public struct AccountDetailView: View {
|
|||
if scrollOffset < -200 {
|
||||
switch viewModel.accountState {
|
||||
case let .data(account):
|
||||
EmojiTextApp(account.safeDisplayName.asMarkdown, emojis: account.emojis)
|
||||
EmojiTextApp(.init(stringValue: account.safeDisplayName), emojis: account.emojis)
|
||||
.font(.scaledHeadline)
|
||||
default:
|
||||
EmptyView()
|
||||
|
|
|
@ -33,13 +33,13 @@ public struct AccountsListRow: View {
|
|||
HStack(alignment: .top) {
|
||||
AvatarView(url: viewModel.account.avatar, size: .status)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
EmojiTextApp(viewModel.account.safeDisplayName.asMarkdown, emojis: viewModel.account.emojis)
|
||||
EmojiTextApp(.init(stringValue: viewModel.account.safeDisplayName), emojis: viewModel.account.emojis)
|
||||
.font(.scaledSubheadline)
|
||||
.fontWeight(.semibold)
|
||||
Text("@\(viewModel.account.acct)")
|
||||
.font(.scaledFootnote)
|
||||
.foregroundColor(.gray)
|
||||
EmojiTextApp(viewModel.account.note.asMarkdown, emojis: viewModel.account.emojis)
|
||||
EmojiTextApp(viewModel.account.note, emojis: viewModel.account.emojis)
|
||||
.font(.scaledFootnote)
|
||||
.lineLimit(3)
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
|
|
|
@ -50,7 +50,7 @@ public struct AppAccountView: View {
|
|||
}
|
||||
VStack(alignment: .leading) {
|
||||
if let account = viewModel.account {
|
||||
EmojiTextApp(account.safeDisplayName.asMarkdown, emojis: account.emojis)
|
||||
EmojiTextApp(.init(stringValue: account.safeDisplayName), emojis: account.emojis)
|
||||
Text("\(account.username)@\(viewModel.appAccount.server)")
|
||||
.font(.scaledSubheadline)
|
||||
.foregroundColor(.gray)
|
||||
|
|
|
@ -19,7 +19,7 @@ struct ConversationsListRow: View {
|
|||
AvatarView(url: conversation.accounts.first!.avatar)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
EmojiTextApp(conversation.accounts.map { $0.safeDisplayName }.joined(separator: ", "),
|
||||
EmojiTextApp(.init(stringValue: conversation.accounts.map { $0.safeDisplayName }.joined(separator: ", ")),
|
||||
emojis: conversation.accounts.flatMap{ $0.emojis })
|
||||
.font(.scaledSubheadline)
|
||||
.fontWeight(.semibold)
|
||||
|
|
|
@ -5,7 +5,7 @@ import Models
|
|||
import SwiftUI
|
||||
|
||||
public struct EmojiTextApp: View {
|
||||
private let markdown: String
|
||||
private let markdown: HTMLString
|
||||
private let emojis: [any CustomEmoji]
|
||||
private let append: (() -> Text)?
|
||||
|
||||
|
@ -17,14 +17,14 @@ public struct EmojiTextApp: View {
|
|||
|
||||
public var body: some View {
|
||||
if let append {
|
||||
EmojiText(markdown: markdown, emojis: emojis)
|
||||
EmojiText(markdown: markdown.asMarkdown, emojis: emojis)
|
||||
.append {
|
||||
append()
|
||||
}
|
||||
} else if emojis.isEmpty {
|
||||
Text(markdown.asSafeAttributedString)
|
||||
Text(markdown.asSafeMarkdownAttributedString)
|
||||
} else {
|
||||
EmojiText(markdown: markdown, emojis: emojis)
|
||||
EmojiText(markdown: markdown.asMarkdown, emojis: emojis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ public struct ListEditView: View {
|
|||
HStack {
|
||||
AvatarView(url: account.avatar, size: .status)
|
||||
VStack(alignment: .leading) {
|
||||
EmojiTextApp(account.safeDisplayName.asMarkdown,
|
||||
EmojiTextApp(.init(stringValue: account.safeDisplayName),
|
||||
emojis: account.emojis)
|
||||
Text("@\(account.acct)")
|
||||
.foregroundColor(.gray)
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import Foundation
|
||||
|
||||
public struct Account: Codable, Identifiable, Equatable, Hashable {
|
||||
public struct Account: Decodable, Identifiable, Equatable, Hashable {
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
public struct Field: Codable, Equatable, Identifiable {
|
||||
public struct Field: Decodable, Equatable, Identifiable {
|
||||
public var id: String {
|
||||
value + name
|
||||
value.asRawText + name
|
||||
}
|
||||
|
||||
public let name: String
|
||||
|
@ -15,7 +15,7 @@ public struct Account: Codable, Identifiable, Equatable, Hashable {
|
|||
public let verifiedAt: String?
|
||||
}
|
||||
|
||||
public struct Source: Codable, Equatable {
|
||||
public struct Source: Decodable, Equatable {
|
||||
public let privacy: Visibility
|
||||
public let sensitive: Bool
|
||||
public let language: String?
|
||||
|
@ -50,7 +50,7 @@ public struct Account: Codable, Identifiable, Equatable, Hashable {
|
|||
avatar: URL(string: "https://files.mastodon.social/media_attachments/files/003/134/405/original/04060b07ddf7bb0b.png")!,
|
||||
header: URL(string: "https://files.mastodon.social/media_attachments/files/003/134/405/original/04060b07ddf7bb0b.png")!,
|
||||
acct: "account@account.com",
|
||||
note: "Some content",
|
||||
note: .init(stringValue: "Some content"),
|
||||
createdAt: "2022-12-16T10:20:54.000Z",
|
||||
followersCount: 10,
|
||||
followingCount: 10,
|
||||
|
@ -71,7 +71,7 @@ public struct Account: Codable, Identifiable, Equatable, Hashable {
|
|||
}
|
||||
}
|
||||
|
||||
public struct FamiliarAccounts: Codable {
|
||||
public struct FamiliarAccounts: Decodable {
|
||||
public let id: String
|
||||
public let accounts: [Account]
|
||||
}
|
||||
|
|
|
@ -3,55 +3,58 @@ import HTML2Markdown
|
|||
import SwiftSoup
|
||||
import SwiftUI
|
||||
|
||||
public typealias HTMLString = String
|
||||
|
||||
public extension HTMLString {
|
||||
var asMarkdown: String {
|
||||
public struct HTMLString: Decodable, Equatable {
|
||||
public let htmlValue: String
|
||||
public let asMarkdown: String
|
||||
public let asRawText: String
|
||||
public let statusesURLs: [URL]
|
||||
public let asSafeMarkdownAttributedString: AttributedString
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
htmlValue = try container.decode(String.self)
|
||||
|
||||
do {
|
||||
let dom = try HTMLParser().parse(html: self)
|
||||
return dom.toMarkdown()
|
||||
// Add space between hashtags and mentions that follow each other
|
||||
asMarkdown = try HTMLParser().parse(html: htmlValue)
|
||||
.toMarkdown()
|
||||
.replacingOccurrences(of: ")[", with: ") [")
|
||||
} catch {
|
||||
return self
|
||||
asMarkdown = htmlValue
|
||||
}
|
||||
}
|
||||
|
||||
var asRawText: String {
|
||||
|
||||
var statusesURLs: [URL] = []
|
||||
do {
|
||||
let document: Document = try SwiftSoup.parse(self)
|
||||
return try document.text()
|
||||
} catch {
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
func findStatusesURLs() -> [URL]? {
|
||||
do {
|
||||
let document: Document = try SwiftSoup.parse(self)
|
||||
let document: Document = try SwiftSoup.parse(htmlValue)
|
||||
let links: Elements = try document.select("a")
|
||||
var URLs: [URL] = []
|
||||
for link in links {
|
||||
let href = try link.attr("href")
|
||||
if let url = URL(string: href),
|
||||
let _ = Int(url.lastPathComponent)
|
||||
{
|
||||
URLs.append(url)
|
||||
statusesURLs.append(url)
|
||||
}
|
||||
}
|
||||
return URLs
|
||||
asRawText = try document.text()
|
||||
} catch {
|
||||
return nil
|
||||
asRawText = htmlValue
|
||||
}
|
||||
}
|
||||
|
||||
var asSafeAttributedString: AttributedString {
|
||||
|
||||
self.statusesURLs = statusesURLs
|
||||
|
||||
do {
|
||||
let options = AttributedString.MarkdownParsingOptions(allowsExtendedAttributes: true,
|
||||
interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
return try AttributedString(markdown: self, options: options)
|
||||
asSafeMarkdownAttributedString = try AttributedString(markdown: asMarkdown, options: options)
|
||||
} catch {
|
||||
return AttributedString(stringLiteral: self)
|
||||
asSafeMarkdownAttributedString = AttributedString(stringLiteral: htmlValue)
|
||||
}
|
||||
}
|
||||
|
||||
public init(stringValue: String) {
|
||||
htmlValue = stringValue
|
||||
asMarkdown = stringValue
|
||||
asRawText = stringValue
|
||||
statusesURLs = []
|
||||
asSafeMarkdownAttributedString = AttributedString(stringLiteral: htmlValue)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,33 +3,34 @@ import Foundation
|
|||
public typealias ServerDate = String
|
||||
|
||||
extension ServerDate {
|
||||
private static var createdAtDateFormatter: DateFormatter {
|
||||
private static var createdAtDateFormatter: DateFormatter = {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.calendar = .init(identifier: .iso8601)
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX"
|
||||
dateFormatter.timeZone = .init(abbreviation: "UTC")
|
||||
return dateFormatter
|
||||
}
|
||||
}()
|
||||
|
||||
private static var createdAtRelativeFormatter: RelativeDateTimeFormatter {
|
||||
private static var createdAtRelativeFormatter: RelativeDateTimeFormatter = {
|
||||
let dateFormatter = RelativeDateTimeFormatter()
|
||||
dateFormatter.unitsStyle = .abbreviated
|
||||
return dateFormatter
|
||||
}
|
||||
}()
|
||||
|
||||
private static var createdAtShortDateFormatted: DateFormatter {
|
||||
private static var createdAtShortDateFormatted: DateFormatter = {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .medium
|
||||
return dateFormatter
|
||||
}
|
||||
}()
|
||||
|
||||
private static let calendar = Calendar(identifier: .gregorian)
|
||||
|
||||
public var asDate: Date {
|
||||
Self.createdAtDateFormatter.date(from: self)!
|
||||
}
|
||||
|
||||
public var formatted: String {
|
||||
let calendar = Calendar(identifier: .gregorian)
|
||||
if calendar.numberOfDaysBetween(asDate, and: Date()) > 1 {
|
||||
if Self.calendar.numberOfDaysBetween(asDate, and: Date()) > 1 {
|
||||
return Self.createdAtShortDateFormatted.string(from: asDate)
|
||||
} else {
|
||||
return Self.createdAtRelativeFormatter.localizedString(for: asDate, relativeTo: Date())
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import Foundation
|
||||
|
||||
public struct Notification: Codable, Identifiable {
|
||||
public struct Notification: Decodable, Identifiable {
|
||||
public enum NotificationType: String, CaseIterable {
|
||||
case follow, follow_request, mention, reblog, status, favourite, poll, update
|
||||
}
|
||||
|
|
|
@ -48,13 +48,13 @@ public protocol AnyStatus {
|
|||
var inReplyToAccountId: String? { get }
|
||||
var visibility: Visibility { get }
|
||||
var poll: Poll? { get }
|
||||
var spoilerText: String { get }
|
||||
var spoilerText: HTMLString { get }
|
||||
var filtered: [Filtered]? { get }
|
||||
var sensitive: Bool { get }
|
||||
var language: String? { get }
|
||||
}
|
||||
|
||||
public struct Status: AnyStatus, Codable, Identifiable {
|
||||
public struct Status: AnyStatus, Decodable, Identifiable {
|
||||
public var viewId: String {
|
||||
id + createdAt + (editedAt ?? "")
|
||||
}
|
||||
|
@ -81,14 +81,14 @@ public struct Status: AnyStatus, Codable, Identifiable {
|
|||
public let inReplyToAccountId: String?
|
||||
public let visibility: Visibility
|
||||
public let poll: Poll?
|
||||
public let spoilerText: String
|
||||
public let spoilerText: HTMLString
|
||||
public let filtered: [Filtered]?
|
||||
public let sensitive: Bool
|
||||
public let language: String?
|
||||
|
||||
public static func placeholder() -> Status {
|
||||
.init(id: UUID().uuidString,
|
||||
content: "This is a #toot\nWith some @content\nAnd some more content for your #eyes @only",
|
||||
content: .init(stringValue: "This is a #toot\nWith some @content\nAnd some more content for your #eyes @only"),
|
||||
account: .placeholder(),
|
||||
createdAt: "2022-12-16T10:20:54.000Z",
|
||||
editedAt: nil,
|
||||
|
@ -109,7 +109,7 @@ public struct Status: AnyStatus, Codable, Identifiable {
|
|||
inReplyToAccountId: nil,
|
||||
visibility: .pub,
|
||||
poll: nil,
|
||||
spoilerText: "",
|
||||
spoilerText: .init(stringValue: ""),
|
||||
filtered: [],
|
||||
sensitive: false,
|
||||
language: nil)
|
||||
|
@ -120,13 +120,13 @@ public struct Status: AnyStatus, Codable, Identifiable {
|
|||
}
|
||||
}
|
||||
|
||||
public struct ReblogStatus: AnyStatus, Codable, Identifiable {
|
||||
public struct ReblogStatus: AnyStatus, Decodable, Identifiable {
|
||||
public var viewId: String {
|
||||
id + createdAt + (editedAt ?? "")
|
||||
}
|
||||
|
||||
public let id: String
|
||||
public let content: String
|
||||
public let content: HTMLString
|
||||
public let account: Account
|
||||
public let createdAt: String
|
||||
public let editedAt: ServerDate?
|
||||
|
@ -146,7 +146,7 @@ public struct ReblogStatus: AnyStatus, Codable, Identifiable {
|
|||
public let inReplyToAccountId: String?
|
||||
public let visibility: Visibility
|
||||
public let poll: Poll?
|
||||
public let spoilerText: String
|
||||
public let spoilerText: HTMLString
|
||||
public let filtered: [Filtered]?
|
||||
public let sensitive: Bool
|
||||
public let language: String?
|
||||
|
|
|
@ -51,7 +51,7 @@ struct NotificationRowView: View {
|
|||
private func makeMainLabel(type: Models.Notification.NotificationType) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
EmojiTextApp(notification.account.safeDisplayName.asMarkdown,
|
||||
EmojiTextApp(.init(stringValue: notification.account.safeDisplayName),
|
||||
emojis: notification.account.emojis,
|
||||
append: {
|
||||
Text(" ") +
|
||||
|
@ -97,7 +97,7 @@ struct NotificationRowView: View {
|
|||
.foregroundColor(.gray)
|
||||
|
||||
if type == .follow {
|
||||
EmojiTextApp(notification.account.note.asMarkdown,
|
||||
EmojiTextApp(notification.account.note,
|
||||
emojis: notification.account.emojis)
|
||||
.lineLimit(3)
|
||||
.font(.scaledCallout)
|
||||
|
|
|
@ -32,7 +32,7 @@ struct StatusEditorAutoCompleteView: View {
|
|||
HStack {
|
||||
AvatarView(url: account.avatar, size: .badge)
|
||||
VStack(alignment: .leading) {
|
||||
EmojiTextApp(account.safeDisplayName.asMarkdown,
|
||||
EmojiTextApp(.init(stringValue: account.safeDisplayName),
|
||||
emojis: account.emojis)
|
||||
.font(.scaledFootnote)
|
||||
.foregroundColor(theme.labelColor)
|
||||
|
|
|
@ -177,14 +177,14 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
self.visibility = visibility
|
||||
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
|
||||
case let .edit(status):
|
||||
var rawText = NSAttributedString(status.content.asMarkdown.asSafeAttributedString).string
|
||||
var rawText = NSAttributedString(status.content.asSafeMarkdownAttributedString).string
|
||||
for mention in status.mentions {
|
||||
rawText = rawText.replacingOccurrences(of: "@\(mention.username)", with: "@\(mention.acct)")
|
||||
}
|
||||
statusText = .init(string: rawText)
|
||||
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
|
||||
spoilerOn = !status.spoilerText.isEmpty
|
||||
spoilerText = status.spoilerText
|
||||
spoilerOn = !status.spoilerText.asRawText.isEmpty
|
||||
spoilerText = status.spoilerText.asRawText
|
||||
visibility = status.visibility
|
||||
mediasImages = status.mediaAttachments.map { .init(image: nil, mediaAttachment: $0, error: nil) }
|
||||
case let .quote(status):
|
||||
|
|
|
@ -35,7 +35,7 @@ public struct StatusEmbeddedView: View {
|
|||
HStack(alignment: .center) {
|
||||
AvatarView(url: account.avatar, size: .embed)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
EmojiTextApp(status.account.safeDisplayName.asMarkdown, emojis: account.emojis)
|
||||
EmojiTextApp(.init(stringValue: status.account.safeDisplayName), emojis: account.emojis)
|
||||
.font(.scaledFootnote)
|
||||
.fontWeight(.semibold)
|
||||
Group {
|
||||
|
|
|
@ -24,7 +24,7 @@ public struct StatusEditHistoryView: View {
|
|||
if let history {
|
||||
ForEach(history) { edit in
|
||||
VStack(alignment: .leading, spacing: 8){
|
||||
EmojiTextApp(edit.content.asMarkdown, emojis: edit.emojis)
|
||||
EmojiTextApp(edit.content, emojis: edit.emojis)
|
||||
.font(.scaledBody)
|
||||
Group {
|
||||
Text(edit.createdAt.asDate, style: .date) +
|
||||
|
|
|
@ -100,7 +100,7 @@ public struct StatusRowView: View {
|
|||
Image(systemName: "arrow.left.arrow.right.circle.fill")
|
||||
AvatarView(url: viewModel.status.account.avatar, size: .boost)
|
||||
if viewModel.status.account.username != account.account?.username {
|
||||
EmojiTextApp(viewModel.status.account.safeDisplayName.asMarkdown, emojis: viewModel.status.account.emojis)
|
||||
EmojiTextApp(.init(stringValue: viewModel.status.account.safeDisplayName), emojis: viewModel.status.account.emojis)
|
||||
Text("status.row.was-boosted")
|
||||
} else {
|
||||
Text("status.row.you-boosted")
|
||||
|
@ -177,8 +177,8 @@ public struct StatusRowView: View {
|
|||
|
||||
private func makeStatusContentView(status: AnyStatus) -> some View {
|
||||
Group {
|
||||
if !status.spoilerText.isEmpty {
|
||||
EmojiTextApp(status.spoilerText.asMarkdown, emojis: status.emojis)
|
||||
if !status.spoilerText.asRawText.isEmpty {
|
||||
EmojiTextApp(status.spoilerText, emojis: status.emojis)
|
||||
.font(.scaledBody)
|
||||
Button {
|
||||
withAnimation {
|
||||
|
@ -189,9 +189,10 @@ public struct StatusRowView: View {
|
|||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
if !viewModel.displaySpoiler {
|
||||
HStack {
|
||||
EmojiTextApp(status.content.asMarkdown, emojis: status.emojis)
|
||||
EmojiTextApp(status.content, emojis: status.emojis)
|
||||
.font(.scaledBody)
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
routerPath.handleStatus(status: status, url: url)
|
||||
|
@ -248,7 +249,7 @@ public struct StatusRowView: View {
|
|||
AvatarView(url: status.account.avatar, size: .status)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
EmojiTextApp(status.account.safeDisplayName.asMarkdown, emojis: status.account.emojis)
|
||||
EmojiTextApp(.init(stringValue: status.account.safeDisplayName), emojis: status.account.emojis)
|
||||
.font(.scaledSubheadline)
|
||||
.fontWeight(.semibold)
|
||||
Group {
|
||||
|
|
|
@ -54,7 +54,7 @@ public class StatusRowViewModel: ObservableObject {
|
|||
favouritesCount = status.reblog?.favouritesCount ?? status.favouritesCount
|
||||
reblogsCount = status.reblog?.reblogsCount ?? status.reblogsCount
|
||||
repliesCount = status.reblog?.repliesCount ?? status.repliesCount
|
||||
displaySpoiler = !(status.reblog?.spoilerText ?? status.spoilerText).isEmpty
|
||||
displaySpoiler = !(status.reblog?.spoilerText.asRawText ?? status.spoilerText.asRawText).isEmpty
|
||||
|
||||
isFiltered = filter != nil
|
||||
}
|
||||
|
@ -70,9 +70,8 @@ public class StatusRowViewModel: ObservableObject {
|
|||
|
||||
func loadEmbeddedStatus() async {
|
||||
guard let client,
|
||||
let urls = status.content.findStatusesURLs(),
|
||||
!urls.isEmpty,
|
||||
let url = urls.first,
|
||||
!status.content.statusesURLs.isEmpty,
|
||||
let url = status.content.statusesURLs.first,
|
||||
client.hasConnection(with: url)
|
||||
else {
|
||||
isEmbedLoading = false
|
||||
|
|
Loading…
Reference in New Issue