Do string manipulation at the decode time to keep the UI smooth fix #178

This commit is contained in:
Thomas Ricouard 2023-01-20 18:27:00 +01:00
parent 7a0b635033
commit d1034cd9a3
19 changed files with 87 additions and 83 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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