Swiftformat

This commit is contained in:
Thomas Ricouard 2023-02-12 16:29:41 +01:00
parent 9fd1b4ef73
commit eb6050a38f
48 changed files with 304 additions and 314 deletions

View file

@ -64,7 +64,7 @@ struct AboutView: View {
[KeychainSwift](https://github.com/evgenyneu/keychain-swift) [KeychainSwift](https://github.com/evgenyneu/keychain-swift)
[LRUCache](https://github.com/nicklockwood/LRUCache) [LRUCache](https://github.com/nicklockwood/LRUCache)
[Boutique](https://github.com/mergesort/Boutique) [Boutique](https://github.com/mergesort/Boutique)
[Nuke](https://github.com/kean/Nuke) [Nuke](https://github.com/kean/Nuke)

View file

@ -3,9 +3,9 @@ import AppAccount
import DesignSystem import DesignSystem
import Env import Env
import Models import Models
import Network
import SwiftUI import SwiftUI
import Timeline import Timeline
import Network
struct AccountSettingsView: View { struct AccountSettingsView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss

View file

@ -38,12 +38,12 @@ struct DisplaySettingsView: View {
} }
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
Section("settings.display.section.display") { Section("settings.display.section.display") {
Picker("settings.display.font", selection: .init(get: { () -> FontState in Picker("settings.display.font", selection: .init(get: { () -> FontState in
if userPreferences.chosenFont?.fontName == "OpenDyslexic-Regular" { if userPreferences.chosenFont?.fontName == "OpenDyslexic-Regular" {
return FontState.openDyslexic return FontState.openDyslexic
} else if userPreferences.chosenFont?.fontName == "AtkinsonHyperlegible-Regular" { } else if userPreferences.chosenFont?.fontName == "AtkinsonHyperlegible-Regular" {
return FontState.hyperLegible return FontState.hyperLegible
} }
return userPreferences.chosenFontData != nil ? FontState.custom : FontState.system return userPreferences.chosenFontData != nil ? FontState.custom : FontState.system
@ -79,7 +79,7 @@ struct DisplaySettingsView: View {
Text(buttonStyle.description).tag(buttonStyle) Text(buttonStyle.description).tag(buttonStyle)
} }
} }
Picker("settings.display.status.media-style", selection: $theme.statusDisplayStyle) { Picker("settings.display.status.media-style", selection: $theme.statusDisplayStyle) {
ForEach(Theme.StatusDisplayStyle.allCases, id: \.rawValue) { buttonStyle in ForEach(Theme.StatusDisplayStyle.allCases, id: \.rawValue) { buttonStyle in
Text(buttonStyle.description).tag(buttonStyle) Text(buttonStyle.description).tag(buttonStyle)

View file

@ -45,8 +45,8 @@ struct IconSelectorView: View {
static let items = [ static let items = [
IconSelector(title: "Official icons", icons: [.primary, .alt1, .alt2, .alt3, .alt4, .alt5, .alt6, .alt7, .alt8, IconSelector(title: "Official icons", icons: [.primary, .alt1, .alt2, .alt3, .alt4, .alt5, .alt6, .alt7, .alt8,
.alt9, .alt10, .alt11, .alt12, .alt13, .alt14, .alt9, .alt10, .alt11, .alt12, .alt13, .alt14,
.alt15, .alt16, .alt17, .alt18, .alt19, .alt25]), .alt15, .alt16, .alt17, .alt18, .alt19, .alt25]),
IconSelector(title: "Icons by Albert Kinng", icons: [.alt20, .alt21, .alt22, .alt23, .alt24]), IconSelector(title: "Icons by Albert Kinng", icons: [.alt20, .alt21, .alt22, .alt23, .alt24]),
IconSelector(title: "Icons by Dan van Moll", icons: [.alt26, .alt27, .alt28]), IconSelector(title: "Icons by Dan van Moll", icons: [.alt26, .alt27, .alt28]),
IconSelector(title: "Icons by @te6-in (GitHub)", icons: [.alt29, .alt30, .alt31, .alt32]), IconSelector(title: "Icons by @te6-in (GitHub)", icons: [.alt29, .alt30, .alt31, .alt32]),

View file

@ -102,11 +102,11 @@ struct SettingsTabs: View {
NavigationLink(destination: DisplaySettingsView()) { NavigationLink(destination: DisplaySettingsView()) {
Label("settings.general.display", systemImage: "paintpalette") Label("settings.general.display", systemImage: "paintpalette")
} }
if HapticManager.shared.supportsHaptics { if HapticManager.shared.supportsHaptics {
NavigationLink(destination: HapticSettingsView()) { NavigationLink(destination: HapticSettingsView()) {
Label("settings.general.haptic", systemImage: "waveform.path") Label("settings.general.haptic", systemImage: "waveform.path")
}
} }
}
NavigationLink(destination: remoteLocalTimelinesView) { NavigationLink(destination: remoteLocalTimelinesView) {
Label("settings.general.remote-timelines", systemImage: "dot.radiowaves.right") Label("settings.general.remote-timelines", systemImage: "dot.radiowaves.right")
} }

View file

@ -5,7 +5,7 @@ import SwiftUI
struct SwipeActionsSettingsView: View { struct SwipeActionsSettingsView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var userPreferences: UserPreferences @EnvironmentObject private var userPreferences: UserPreferences
var body: some View { var body: some View {
Form { Form {
Section("settings.swipeactions.status") { Section("settings.swipeactions.status") {
@ -16,7 +16,7 @@ struct SwipeActionsSettingsView: View {
} }
Section { Section {
ForEach(StatusAction.allCases) { action in ForEach(StatusAction.allCases) { action in
if (action != .none) { if action != .none {
Label(action.displayName(), systemImage: action.iconName()).tag(action) Label(action.displayName(), systemImage: action.iconName()).tag(action)
} }
} }
@ -28,20 +28,20 @@ struct SwipeActionsSettingsView: View {
} }
Section { Section {
ForEach(StatusAction.allCases) { action in ForEach(StatusAction.allCases) { action in
if (action != .none) { if action != .none {
Label(action.displayName(), systemImage: action.iconName()).tag(action) Label(action.displayName(), systemImage: action.iconName()).tag(action)
} }
} }
} }
} }
Label("settings.swipeactions.status.trailing", systemImage: "arrow.left.circle") Label("settings.swipeactions.status.trailing", systemImage: "arrow.left.circle")
Picker(selection: $userPreferences.swipeActionsStatusTrailingLeft, label: makeSwipeLabel(left: true, text: "settings.swipeactions.status.trailing.left")) { Picker(selection: $userPreferences.swipeActionsStatusTrailingLeft, label: makeSwipeLabel(left: true, text: "settings.swipeactions.status.trailing.left")) {
Section { Section {
Label(StatusAction.none.displayName(), systemImage: StatusAction.none.iconName()).tag(StatusAction.none) Label(StatusAction.none.displayName(), systemImage: StatusAction.none.iconName()).tag(StatusAction.none)
} }
Section { Section {
ForEach(StatusAction.allCases) { action in ForEach(StatusAction.allCases) { action in
if (action != .none) { if action != .none {
Label(action.displayName(), systemImage: action.iconName()).tag(action) Label(action.displayName(), systemImage: action.iconName()).tag(action)
} }
} }
@ -53,7 +53,7 @@ struct SwipeActionsSettingsView: View {
} }
Section { Section {
ForEach(StatusAction.allCases) { action in ForEach(StatusAction.allCases) { action in
if (action != .none) { if action != .none {
Label(action.displayName(), systemImage: action.iconName()).tag(action) Label(action.displayName(), systemImage: action.iconName()).tag(action)
} }
} }
@ -66,9 +66,9 @@ struct SwipeActionsSettingsView: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor) .background(theme.secondaryBackgroundColor)
} }
private func makeSwipeLabel(left: Bool, text: LocalizedStringKey) -> some View { private func makeSwipeLabel(left: Bool, text: LocalizedStringKey) -> some View {
return Label(text, systemImage: left ? "rectangle.lefthalf.filled" : "rectangle.righthalf.filled") return Label(text, systemImage: left ? "rectangle.lefthalf.filled" : "rectangle.righthalf.filled")
.padding(.leading, 16) .padding(.leading, 16)
} }
} }

View file

@ -19,8 +19,8 @@ struct TimelineTab: View {
@State private var didAppear: Bool = false @State private var didAppear: Bool = false
@State private var timeline: TimelineFilter @State private var timeline: TimelineFilter
@State private var scrollToTopSignal: Int = 0 @State private var scrollToTopSignal: Int = 0
@AppStorage("last_timeline_filter") public var lastTimelineFilter: TimelineFilter = TimelineFilter.home @AppStorage("last_timeline_filter") public var lastTimelineFilter: TimelineFilter = .home
private let canFilterTimeline: Bool private let canFilterTimeline: Bool
@ -45,10 +45,10 @@ struct TimelineTab: View {
routerPath.client = client routerPath.client = client
if !didAppear && canFilterTimeline { if !didAppear && canFilterTimeline {
didAppear = true didAppear = true
if(client.isAuth) { if client.isAuth {
timeline = lastTimelineFilter timeline = lastTimelineFilter
} else { } else {
timeline = .federated timeline = .federated
} }
} }
Task { Task {
@ -58,18 +58,18 @@ struct TimelineTab: View {
routerPath.presentedSheet = .addAccount routerPath.presentedSheet = .addAccount
} }
} }
.onChange(of: client.isAuth, perform: { isAuth in .onChange(of: client.isAuth, perform: { _ in
if(client.isAuth) { if client.isAuth {
timeline = lastTimelineFilter timeline = lastTimelineFilter
} else { } else {
timeline = .federated timeline = .federated
} }
}) })
.onChange(of: currentAccount.account?.id, perform: { _ in .onChange(of: currentAccount.account?.id, perform: { _ in
if(client.isAuth && canFilterTimeline) { if client.isAuth, canFilterTimeline {
timeline = lastTimelineFilter timeline = lastTimelineFilter
} else { } else {
timeline = .federated timeline = .federated
} }
}) })
.onChange(of: $popToRootTab.wrappedValue) { popToRootTab in .onChange(of: $popToRootTab.wrappedValue) { popToRootTab in
@ -85,7 +85,7 @@ struct TimelineTab: View {
routerPath.path = [] routerPath.path = []
} }
.onChange(of: timeline) { timeline in .onChange(of: timeline) { timeline in
if(timeline == .home || timeline == .federated || timeline == .local) { if timeline == .home || timeline == .federated || timeline == .local {
lastTimelineFilter = timeline lastTimelineFilter = timeline
} }
} }

View file

@ -10,7 +10,7 @@ struct AccountDetailHeaderView: View {
enum Constants { enum Constants {
static let headerHeight: CGFloat = 200 static let headerHeight: CGFloat = 200
} }
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var quickLook: QuickLook @EnvironmentObject private var quickLook: QuickLook
@EnvironmentObject private var routerPath: RouterPath @EnvironmentObject private var routerPath: RouterPath
@ -100,7 +100,7 @@ struct AccountDetailHeaderView: View {
makeCustomInfoLabel(title: "account.posts", count: account.statusesCount) makeCustomInfoLabel(title: "account.posts", count: account.statusesCount)
} }
.buttonStyle(.borderless) .buttonStyle(.borderless)
Button { Button {
routerPath.navigate(to: .following(id: account.id)) routerPath.navigate(to: .following(id: account.id))
} label: { } label: {
@ -118,7 +118,7 @@ struct AccountDetailHeaderView: View {
) )
} }
.buttonStyle(.borderless) .buttonStyle(.borderless)
}.offset(y: 20) }.offset(y: 20)
} }
} }

View file

@ -36,7 +36,7 @@ public struct AccountDetailView: View {
public init(account: Account) { public init(account: Account) {
_viewModel = StateObject(wrappedValue: .init(account: account)) _viewModel = StateObject(wrappedValue: .init(account: account))
} }
public var body: some View { public var body: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
List { List {
@ -48,7 +48,7 @@ public struct AccountDetailView: View {
.listRowInsets(.init()) .listRowInsets(.init())
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
Picker("", selection: $viewModel.selectedTab) { Picker("", selection: $viewModel.selectedTab) {
ForEach(isCurrentUser ? AccountDetailViewModel.Tab.currentAccountTabs : AccountDetailViewModel.Tab.accountTabs, ForEach(isCurrentUser ? AccountDetailViewModel.Tab.currentAccountTabs : AccountDetailViewModel.Tab.accountTabs,
id: \.self) { tab in id: \.self) { tab in
@ -283,8 +283,8 @@ public struct AccountDetailView: View {
ForEach(currentAccount.sortedLists) { list in ForEach(currentAccount.sortedLists) { list in
NavigationLink(value: RouterDestinations.list(list: list)) { NavigationLink(value: RouterDestinations.list(list: list)) {
Text(list.title) Text(list.title)
.font(.scaledHeadline) .font(.scaledHeadline)
.foregroundColor(theme.labelColor) .foregroundColor(theme.labelColor)
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
.contextMenu { .contextMenu {
@ -389,7 +389,7 @@ public struct AccountDetailView: View {
Label("account.action.block", systemImage: "person.crop.circle.badge.xmark") Label("account.action.block", systemImage: "person.crop.circle.badge.xmark")
} }
} }
if viewModel.relationship?.muting == true { if viewModel.relationship?.muting == true {
Button { Button {
Task { Task {
@ -405,7 +405,7 @@ public struct AccountDetailView: View {
} else { } else {
Menu { Menu {
ForEach(MutingDurations.allCases, id: \.rawValue) { duration in ForEach(MutingDurations.allCases, id: \.rawValue) { duration in
Button (duration.description) { Button(duration.description) {
Task { Task {
do { do {
viewModel.relationship = try await client.post(endpoint: Accounts.mute(id: account.id, json: MuteData(duration: duration.rawValue))) viewModel.relationship = try await client.post(endpoint: Accounts.mute(id: account.id, json: MuteData(duration: duration.rawValue)))

View file

@ -3,13 +3,13 @@ import SwiftUI
enum MutingDurations: Int, CaseIterable { enum MutingDurations: Int, CaseIterable {
case infinite = 0 case infinite = 0
case fiveMinutes = 300 case fiveMinutes = 300
case thirtyMinutes = 1_800 case thirtyMinutes = 1800
case oneHour = 3_600 case oneHour = 3600
case sixHours = 21_600 case sixHours = 21600
case oneDay = 86_400 case oneDay = 86400
case threeDays = 259_200 case threeDays = 259_200
case sevenDays = 604_800 case sevenDays = 604_800
public var description: LocalizedStringKey { public var description: LocalizedStringKey {
switch self { switch self {
case .infinite: case .infinite:

View file

@ -1,19 +1,19 @@
import DesignSystem
import Models import Models
import Network import Network
import SwiftUI import SwiftUI
import DesignSystem
@MainActor @MainActor
public class AppAccountViewModel: ObservableObject { public class AppAccountViewModel: ObservableObject {
private static var avatarsCache: [String: UIImage] = [:] private static var avatarsCache: [String: UIImage] = [:]
var appAccount: AppAccount var appAccount: AppAccount
let client: Client let client: Client
let isCompact: Bool let isCompact: Bool
@Published var account: Account? @Published var account: Account?
@Published var roundedAvatar: UIImage? @Published var roundedAvatar: UIImage?
var acct: String { var acct: String {
if let acct = appAccount.accountName { if let acct = appAccount.accountName {
return acct return acct
@ -35,17 +35,18 @@ public class AppAccountViewModel: ObservableObject {
appAccount.accountName = "\(account.acct)@\(appAccount.server)" appAccount.accountName = "\(account.acct)@\(appAccount.server)"
try appAccount.save() try appAccount.save()
} }
if let account { if let account {
if let image = Self.avatarsCache[account.id] { if let image = Self.avatarsCache[account.id] {
self.roundedAvatar = image roundedAvatar = image
} else if let (data, _) = try? await URLSession.shared.data(from: account.avatar), } else if let (data, _) = try? await URLSession.shared.data(from: account.avatar),
let image = UIImage(data: data)?.roundedImage { let image = UIImage(data: data)?.roundedImage
self.roundedAvatar = image {
roundedAvatar = image
Self.avatarsCache[account.id] = image Self.avatarsCache[account.id] = image
} }
} }
} catch { } } catch {}
} }
} }

View file

@ -17,7 +17,7 @@ class ConversationDetailViewModel: ObservableObject {
init(conversation: Conversation) { init(conversation: Conversation) {
self.conversation = conversation self.conversation = conversation
messages = conversation.lastStatus != nil ? [conversation.lastStatus!] : [] messages = conversation.lastStatus != nil ? [conversation.lastStatus!] : []
} }
func fetchMessages() async { func fetchMessages() async {

View file

@ -60,8 +60,8 @@ struct ConversationMessageView: View {
.padding(.leading, isOwnMessage ? 24 : 0) .padding(.leading, isOwnMessage ? 24 : 0)
.padding(.trailing, isOwnMessage ? 0 : 24) .padding(.trailing, isOwnMessage ? 0 : 24)
} }
if message.id == String(conversation.lastStatus?.id ?? "") { if message.id == String(conversation.lastStatus?.id ?? "") {
HStack { HStack {
if isOwnMessage { if isOwnMessage {
Spacer() Spacer()

View file

@ -1,16 +1,15 @@
import UIKit import UIKit
extension UIImage{ public extension UIImage {
public var roundedImage: UIImage? { var roundedImage: UIImage? {
let rect = CGRect(origin:CGPoint(x: 0, y: 0), size: self.size) let rect = CGRect(origin: CGPoint(x: 0, y: 0), size: size)
UIGraphicsBeginImageContextWithOptions(self.size, false, 1) UIGraphicsBeginImageContextWithOptions(size, false, 1)
defer { UIGraphicsEndImageContext() } defer { UIGraphicsEndImageContext() }
UIBezierPath( UIBezierPath(
roundedRect: rect, roundedRect: rect,
cornerRadius: self.size.height cornerRadius: size.height
).addClip() ).addClip()
self.draw(in: rect) draw(in: rect)
return UIGraphicsGetImageFromCurrentImageContext() return UIGraphicsGetImageFromCurrentImageContext()
} }
} }

View file

@ -9,7 +9,7 @@ public class CurrentInstance: ObservableObject {
private var client: Client? private var client: Client?
public static let shared = CurrentInstance() public static let shared = CurrentInstance()
private var version: Float { private var version: Float {
if let stringVersion = instance?.version { if let stringVersion = instance?.version {
if stringVersion.utf8.count > 2 { if stringVersion.utf8.count > 2 {
@ -20,7 +20,6 @@ public class CurrentInstance: ObservableObject {
} }
return 0 return 0
} }
public var isFiltersSupported: Bool { public var isFiltersSupported: Bool {
version >= 4 version >= 4
@ -29,7 +28,7 @@ public class CurrentInstance: ObservableObject {
public var isEditSupported: Bool { public var isEditSupported: Bool {
version >= 4 version >= 4
} }
public var isEditAltTextSupported: Bool { public var isEditAltTextSupported: Bool {
version >= 4.1 version >= 4.1
} }

View file

@ -1,12 +1,12 @@
import SwiftUI import SwiftUI
public enum StatusAction : String, CaseIterable, Identifiable { public enum StatusAction: String, CaseIterable, Identifiable {
public var id: String { public var id: String {
"\(rawValue)" "\(rawValue)"
} }
case none, reply, boost, favorite, bookmark, quote case none, reply, boost, favorite, bookmark, quote
public func displayName(isReblogged: Bool = false, isFavorited: Bool = false, isBookmarked: Bool = false) -> LocalizedStringKey { public func displayName(isReblogged: Bool = false, isFavorited: Bool = false, isBookmarked: Bool = false) -> LocalizedStringKey {
switch self { switch self {
case .none: case .none:
@ -23,7 +23,7 @@ public enum StatusAction : String, CaseIterable, Identifiable {
return isBookmarked ? "status.action.unbookmark" : "settings.swipeactions.status.action.bookmark" return isBookmarked ? "status.action.unbookmark" : "settings.swipeactions.status.action.bookmark"
} }
} }
public func iconName(isReblogged: Bool = false, isFavorited: Bool = false, isBookmarked: Bool = false) -> String { public func iconName(isReblogged: Bool = false, isFavorited: Bool = false, isBookmarked: Bool = false) -> String {
switch self { switch self {
case .none: case .none:
@ -40,7 +40,7 @@ public enum StatusAction : String, CaseIterable, Identifiable {
return isBookmarked ? "bookmark.fill" : "bookmark" return isBookmarked ? "bookmark.fill" : "bookmark"
} }
} }
public func color(themeTintColor: Color) -> Color { public func color(themeTintColor: Color) -> Color {
switch self { switch self {
case .none: case .none:

View file

@ -31,13 +31,13 @@ public class UserPreferences: ObservableObject {
@AppStorage("suppress_dupe_reblogs") public var suppressDupeReblogs: Bool = false @AppStorage("suppress_dupe_reblogs") public var suppressDupeReblogs: Bool = false
@AppStorage("inAppBrowserReaderView") public var inAppBrowserReaderView = false @AppStorage("inAppBrowserReaderView") public var inAppBrowserReaderView = false
@AppStorage("haptic_tab") public var hapticTabSelectionEnabled = true @AppStorage("haptic_tab") public var hapticTabSelectionEnabled = true
@AppStorage("haptic_timeline") public var hapticTimelineEnabled = true @AppStorage("haptic_timeline") public var hapticTimelineEnabled = true
@AppStorage("haptic_button_press") public var hapticButtonPressEnabled = true @AppStorage("haptic_button_press") public var hapticButtonPressEnabled = true
@AppStorage("show_tab_label_iphone") public var showiPhoneTabLabel = true @AppStorage("show_tab_label_iphone") public var showiPhoneTabLabel = true
@AppStorage("show_second_column_ipad") public var showiPadSecondaryColumn = true @AppStorage("show_second_column_ipad") public var showiPadSecondaryColumn = true
@AppStorage("swipeactions-status-trailing-right") public var swipeActionsStatusTrailingRight = StatusAction.favorite @AppStorage("swipeactions-status-trailing-right") public var swipeActionsStatusTrailingRight = StatusAction.favorite

View file

@ -2,7 +2,7 @@ import Foundation
import SwiftSoup import SwiftSoup
import SwiftUI import SwiftUI
fileprivate enum CodingKeys: CodingKey { private enum CodingKeys: CodingKey {
case htmlValue, asMarkdown, asRawText, statusesURLs case htmlValue, asMarkdown, asRawText, statusesURLs
} }
@ -11,12 +11,12 @@ public struct HTMLString: Codable, Equatable, Hashable {
public var asMarkdown: String = "" public var asMarkdown: String = ""
public var asRawText: String = "" public var asRawText: String = ""
public var statusesURLs = [URL]() public var statusesURLs = [URL]()
public var asSafeMarkdownAttributedString: AttributedString = .init() public var asSafeMarkdownAttributedString: AttributedString = .init()
private var main_regex: NSRegularExpression? private var main_regex: NSRegularExpression?
private var underscore_regex: NSRegularExpression? private var underscore_regex: NSRegularExpression?
public init(from decoder: Decoder) { public init(from decoder: Decoder) {
var alreadyDecoded: Bool = false var alreadyDecoded = false
do { do {
let container = try decoder.singleValueContainer() let container = try decoder.singleValueContainer()
htmlValue = try container.decode(String.self) htmlValue = try container.decode(String.self)
@ -67,7 +67,7 @@ public struct HTMLString: Codable, Equatable, Hashable {
asRawText = htmlValue asRawText = htmlValue
} }
} }
do { do {
let options = AttributedString.MarkdownParsingOptions(allowsExtendedAttributes: true, let options = AttributedString.MarkdownParsingOptions(allowsExtendedAttributes: true,
interpretedSyntax: .inlineOnlyPreservingWhitespace) interpretedSyntax: .inlineOnlyPreservingWhitespace)
@ -77,12 +77,12 @@ public struct HTMLString: Codable, Equatable, Hashable {
} }
} }
public init(stringValue: String, parseMarkdown:Bool = false) { public init(stringValue: String, parseMarkdown: Bool = false) {
htmlValue = stringValue htmlValue = stringValue
asMarkdown = stringValue asMarkdown = stringValue
asRawText = stringValue asRawText = stringValue
statusesURLs = [] statusesURLs = []
if parseMarkdown { if parseMarkdown {
do { do {
let options = AttributedString.MarkdownParsingOptions(allowsExtendedAttributes: true, let options = AttributedString.MarkdownParsingOptions(allowsExtendedAttributes: true,
@ -91,8 +91,7 @@ public struct HTMLString: Codable, Equatable, Hashable {
} catch { } catch {
asSafeMarkdownAttributedString = AttributedString(stringLiteral: htmlValue) asSafeMarkdownAttributedString = AttributedString(stringLiteral: htmlValue)
} }
} } else {
else {
asSafeMarkdownAttributedString = AttributedString(stringLiteral: htmlValue) asSafeMarkdownAttributedString = AttributedString(stringLiteral: htmlValue)
} }
} }

View file

@ -1,12 +1,12 @@
import Foundation import Foundation
fileprivate enum CodingKeys: CodingKey { private enum CodingKeys: CodingKey {
case asDate case asDate
} }
public struct ServerDate: Codable, Hashable, Equatable { public struct ServerDate: Codable, Hashable, Equatable {
public let asDate: Date public let asDate: Date
public var relativeFormatted: String { public var relativeFormatted: String {
Self.createdAtRelativeFormatter.localizedString(for: asDate, relativeTo: Date()) Self.createdAtRelativeFormatter.localizedString(for: asDate, relativeTo: Date())
} }
@ -14,9 +14,9 @@ public struct ServerDate: Codable, Hashable, Equatable {
public var shortDateFormatted: String { public var shortDateFormatted: String {
Self.createdAtShortDateFormatted.string(from: asDate) Self.createdAtShortDateFormatted.string(from: asDate)
} }
private static let calendar = Calendar(identifier: .gregorian) private static let calendar = Calendar(identifier: .gregorian)
private static var createdAtDateFormatter: DateFormatter = { private static var createdAtDateFormatter: DateFormatter = {
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.calendar = .init(identifier: .iso8601) dateFormatter.calendar = .init(identifier: .iso8601)
@ -24,24 +24,24 @@ public struct ServerDate: Codable, Hashable, Equatable {
dateFormatter.timeZone = .init(abbreviation: "UTC") dateFormatter.timeZone = .init(abbreviation: "UTC")
return dateFormatter return dateFormatter
}() }()
private static var createdAtRelativeFormatter: RelativeDateTimeFormatter = { private static var createdAtRelativeFormatter: RelativeDateTimeFormatter = {
let dateFormatter = RelativeDateTimeFormatter() let dateFormatter = RelativeDateTimeFormatter()
dateFormatter.unitsStyle = .short dateFormatter.unitsStyle = .short
return dateFormatter return dateFormatter
}() }()
private static var createdAtShortDateFormatted: DateFormatter = { private static var createdAtShortDateFormatted: DateFormatter = {
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .short dateFormatter.dateStyle = .short
dateFormatter.timeStyle = .none dateFormatter.timeStyle = .none
return dateFormatter return dateFormatter
}() }()
public init() { public init() {
asDate = Date()-100 asDate = Date() - 100
} }
public init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
do { do {
// Decode from server // Decode from server
@ -54,7 +54,7 @@ public struct ServerDate: Codable, Hashable, Equatable {
asDate = try container.decode(Date.self, forKey: .asDate) asDate = try container.decode(Date.self, forKey: .asDate)
} }
} }
public func encode(to encoder: Encoder) throws { public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(asDate, forKey: .asDate) try container.encode(asDate, forKey: .asDate)

View file

@ -54,14 +54,12 @@ public protocol AnyStatus {
var language: String? { get } var language: String? { get }
} }
extension AnyStatus { public extension AnyStatus {
public var viewId: String { var viewId: String {
get { if let editedAt {
if let editedAt { return "\(id)\(editedAt.asDate.description)"
return "\(id)\(editedAt.asDate.description)"
}
return id
} }
return id
} }
} }
@ -107,41 +105,40 @@ public struct Status: AnyStatus, Codable, Identifiable, Equatable, Hashable, Sta
public let sensitive: Bool public let sensitive: Bool
public let language: String? public let language: String?
public static func placeholder(forSettings:Bool = false, language:String? = nil) -> Status { public static func placeholder(forSettings: Bool = false, language: String? = nil) -> Status {
.init(id: UUID().uuidString, .init(id: UUID().uuidString,
content: .init(stringValue: "Lorem ipsum [#dolor](#) sit amet\nconsectetur [@adipiscing](#) elit\nAsed do eiusmod tempor incididunt ut labore.", parseMarkdown: forSettings), content: .init(stringValue: "Lorem ipsum [#dolor](#) sit amet\nconsectetur [@adipiscing](#) elit\nAsed do eiusmod tempor incididunt ut labore.", parseMarkdown: forSettings),
account: .placeholder(), account: .placeholder(),
createdAt: ServerDate(), createdAt: ServerDate(),
editedAt: nil, editedAt: nil,
reblog: nil, reblog: nil,
mediaAttachments: [], mediaAttachments: [],
mentions: [], mentions: [],
repliesCount: 0, repliesCount: 0,
reblogsCount: 0, reblogsCount: 0,
favouritesCount: 0, favouritesCount: 0,
card: nil, card: nil,
favourited: false, favourited: false,
reblogged: false, reblogged: false,
pinned: false, pinned: false,
bookmarked: false, bookmarked: false,
emojis: [], emojis: [],
url: "https://example.com", url: "https://example.com",
application: nil, application: nil,
inReplyToAccountId: nil, inReplyToAccountId: nil,
visibility: .pub, visibility: .pub,
poll: nil, poll: nil,
spoilerText: .init(stringValue: ""), spoilerText: .init(stringValue: ""),
filtered: [], filtered: [],
sensitive: false, sensitive: false,
language: language) language: language)
} }
public static func placeholders() -> [Status] { public static func placeholders() -> [Status] {
[.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()] [.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()]
} }
public var reblogAsAsStatus: Status? { public var reblogAsAsStatus: Status? {
if let reblog { if let reblog {
return .init(id: reblog.id, return .init(id: reblog.id,

View file

@ -3,7 +3,7 @@ import Foundation
public struct StatusContext: Decodable { public struct StatusContext: Decodable {
public let ancestors: [Status] public let ancestors: [Status]
public let descendants: [Status] public let descendants: [Status]
public static func empty() -> StatusContext { public static func empty() -> StatusContext {
.init(ancestors: [], descendants: []) .init(ancestors: [], descendants: [])
} }

View file

@ -4,7 +4,7 @@ public struct StatusTranslation: Decodable {
public let content: HTMLString public let content: HTMLString
public let detectedSourceLanguage: String public let detectedSourceLanguage: String
public let provider: String public let provider: String
public init(content: String, detectedSourceLanguage: String, provider: String) { public init(content: String, detectedSourceLanguage: String, provider: String) {
self.content = .init(stringValue: content) self.content = .init(stringValue: content)
self.detectedSourceLanguage = detectedSourceLanguage self.detectedSourceLanguage = detectedSourceLanguage

View file

@ -5,7 +5,7 @@ public struct DeepLClient {
public enum DeepLError: Error { public enum DeepLError: Error {
case notFound case notFound
} }
private let endpoint = "https://api.deepl.com/v2/translate" private let endpoint = "https://api.deepl.com/v2/translate"
private var APIKey: String { private var APIKey: String {

View file

@ -34,7 +34,7 @@ public enum Accounts: Endpoint {
case unblock(id: String) case unblock(id: String)
case mute(id: String, json: MuteData) case mute(id: String, json: MuteData)
case unmute(id: String) case unmute(id: String)
public func path() -> String { public func path() -> String {
switch self { switch self {
case let .accounts(id): case let .accounts(id):
@ -81,7 +81,7 @@ public enum Accounts: Endpoint {
return "accounts/\(id)/unmute" return "accounts/\(id)/unmute"
} }
} }
public func queryItems() -> [URLQueryItem]? { public func queryItems() -> [URLQueryItem]? {
switch self { switch self {
case let .statuses(_, sinceId, tag, onlyMedia, excludeReplies, pinned): case let .statuses(_, sinceId, tag, onlyMedia, excludeReplies, pinned):
@ -138,7 +138,7 @@ public enum Accounts: Endpoint {
return nil return nil
} }
} }
public var jsonValue: Encodable? { public var jsonValue: Encodable? {
switch self { switch self {
case let .mute(_, json): case let .mute(_, json):
@ -151,7 +151,7 @@ public enum Accounts: Endpoint {
public struct MuteData: Encodable { public struct MuteData: Encodable {
public let duration: Int public let duration: Int
public init(duration: Int) { public init(duration: Int) {
self.duration = duration self.duration = duration
} }

View file

@ -105,14 +105,14 @@ public struct StatusData: Encodable {
self.expires_in = expires_in self.expires_in = expires_in
} }
} }
public struct MediaAttribute: Encodable { public struct MediaAttribute: Encodable {
public let id: String public let id: String
public let description: String? public let description: String?
public let thumbnail: String? public let thumbnail: String?
public let focus: String? public let focus: String?
public init(id: String, description: String?, thumbnail: String?, focus: String?) { public init(id: String, description: String?, thumbnail: String?, focus: String?) {
self.id = id self.id = id
self.description = description self.description = description
self.thumbnail = thumbnail self.thumbnail = thumbnail

View file

@ -98,8 +98,8 @@ public struct NotificationsListView: View {
EmptyView(iconName: "bell.slash", EmptyView(iconName: "bell.slash",
title: "notifications.empty.title", title: "notifications.empty.title",
message: "notifications.empty.message") message: "notifications.empty.message")
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
.listSectionSeparator(.hidden) .listSectionSeparator(.hidden)
} else { } else {
ForEach(notifications) { notification in ForEach(notifications) { notification in
NotificationRowView(notification: notification) NotificationRowView(notification: notification)

View file

@ -15,7 +15,7 @@ class NotificationsViewModel: ObservableObject {
case display(notifications: [ConsolidatedNotification], nextPageState: State.PagingState) case display(notifications: [ConsolidatedNotification], nextPageState: State.PagingState)
case error(error: Error) case error(error: Error)
} }
enum Constants { enum Constants {
static let notificationLimit: Int = 30 static let notificationLimit: Int = 30
} }
@ -32,6 +32,7 @@ class NotificationsViewModel: ObservableObject {
} }
} }
} }
var currentAccount: CurrentAccount? var currentAccount: CurrentAccount?
@Published var state: State = .loading @Published var state: State = .loading
@ -92,7 +93,7 @@ class NotificationsViewModel: ObservableObject {
state = .error(error: error) state = .error(error: error)
} }
} }
private func fetchNewPages(minId: String, maxPages: Int) async -> [Models.Notification] { private func fetchNewPages(minId: String, maxPages: Int) async -> [Models.Notification] {
guard let client else { return [] } guard let client else { return [] }
var pagesLoaded = 0 var pagesLoaded = 0
@ -100,10 +101,10 @@ class NotificationsViewModel: ObservableObject {
var latestMinId = minId var latestMinId = minId
do { do {
while let newNotifications: [Models.Notification] = while let newNotifications: [Models.Notification] =
try await client.get(endpoint: Notifications.notifications(minId: latestMinId, try await client.get(endpoint: Notifications.notifications(minId: latestMinId,
maxId: nil, maxId: nil,
types: queryTypes, types: queryTypes,
limit: Constants.notificationLimit)), limit: Constants.notificationLimit)),
!newNotifications.isEmpty, !newNotifications.isEmpty,
pagesLoaded < maxPages pagesLoaded < maxPages
{ {

View file

@ -4,7 +4,6 @@ import Models
import Network import Network
import Shimmer import Shimmer
import SwiftUI import SwiftUI
import DesignSystem
public struct StatusDetailView: View { public struct StatusDetailView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@ -16,19 +15,19 @@ public struct StatusDetailView: View {
@StateObject private var viewModel: StatusDetailViewModel @StateObject private var viewModel: StatusDetailViewModel
@State private var isLoaded: Bool = false @State private var isLoaded: Bool = false
@State private var statusHeight: CGFloat = 0 @State private var statusHeight: CGFloat = 0
public init(statusId: String) { public init(statusId: String) {
_viewModel = StateObject(wrappedValue: .init(statusId: statusId)) _viewModel = StateObject(wrappedValue: .init(statusId: statusId))
} }
public init(status: Status) { public init(status: Status) {
_viewModel = StateObject(wrappedValue: .init(status: status)) _viewModel = StateObject(wrappedValue: .init(status: status))
} }
public init(remoteStatusURL: URL) { public init(remoteStatusURL: URL) {
_viewModel = StateObject(wrappedValue: .init(remoteStatusURL: remoteStatusURL)) _viewModel = StateObject(wrappedValue: .init(remoteStatusURL: remoteStatusURL))
} }
public var body: some View { public var body: some View {
GeometryReader { reader in GeometryReader { reader in
ScrollViewReader { proxy in ScrollViewReader { proxy in
@ -36,38 +35,38 @@ public struct StatusDetailView: View {
if isLoaded { if isLoaded {
topPaddingView topPaddingView
} }
switch viewModel.state { switch viewModel.state {
case .loading: case .loading:
loadingDetailView loadingDetailView
case let .display(status, context, date): case let .display(status, context, date):
if !context.ancestors.isEmpty { if !context.ancestors.isEmpty {
ForEach(context.ancestors) { ancestor in ForEach(context.ancestors) { ancestor in
StatusRowView(viewModel: .init(status: ancestor, isCompact: false)) StatusRowView(viewModel: .init(status: ancestor, isCompact: false))
} }
} }
makeCurrentStatusView(status: status) makeCurrentStatusView(status: status)
.id(date) .id(date)
if !context.descendants.isEmpty { if !context.descendants.isEmpty {
ForEach(context.descendants) { descendant in ForEach(context.descendants) { descendant in
StatusRowView(viewModel: .init(status: descendant, isCompact: false)) StatusRowView(viewModel: .init(status: descendant, isCompact: false))
} }
} }
if !isLoaded { if !isLoaded {
loadingContextView loadingContextView
} }
Rectangle() Rectangle()
.foregroundColor(theme.secondaryBackgroundColor) .foregroundColor(theme.secondaryBackgroundColor)
.frame(minHeight: reader.frame(in: .local).size.height - statusHeight) .frame(minHeight: reader.frame(in: .local).size.height - statusHeight)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowBackground(theme.secondaryBackgroundColor) .listRowBackground(theme.secondaryBackgroundColor)
.listRowInsets(.init()) .listRowInsets(.init())
case .error: case .error:
errorView errorView
} }
@ -87,7 +86,7 @@ public struct StatusDetailView: View {
viewModel.client = client viewModel.client = client
let result = await viewModel.fetch() let result = await viewModel.fetch()
isLoaded = true isLoaded = true
if !result { if !result {
if let url = viewModel.remoteStatusURL { if let url = viewModel.remoteStatusURL {
openURL(url) openURL(url)
@ -106,22 +105,22 @@ public struct StatusDetailView: View {
.navigationTitle(viewModel.title) .navigationTitle(viewModel.title)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} }
private func makeCurrentStatusView(status: Status) -> some View { private func makeCurrentStatusView(status: Status) -> some View {
StatusRowView(viewModel: .init(status: status, StatusRowView(viewModel: .init(status: status,
isCompact: false, isCompact: false,
isFocused: true)) isFocused: true))
.overlay { .overlay {
GeometryReader { reader in GeometryReader { reader in
VStack{} VStack {}
.onAppear { .onAppear {
statusHeight = reader.size.height statusHeight = reader.size.height
}
} }
} }
} .id(status.id)
.id(status.id)
} }
private var errorView: some View { private var errorView: some View {
ErrorView(title: "status.error.title", ErrorView(title: "status.error.title",
message: "status.error.message", message: "status.error.message",
@ -133,14 +132,14 @@ public struct StatusDetailView: View {
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
} }
private var loadingDetailView: some View { private var loadingDetailView: some View {
ForEach(Status.placeholders()) { status in ForEach(Status.placeholders()) { status in
StatusRowView(viewModel: .init(status: status, isCompact: false)) StatusRowView(viewModel: .init(status: status, isCompact: false))
.redacted(reason: .placeholder) .redacted(reason: .placeholder)
} }
} }
private var loadingContextView: some View { private var loadingContextView: some View {
HStack { HStack {
Spacer() Spacer()
@ -152,7 +151,7 @@ public struct StatusDetailView: View {
.listRowBackground(theme.secondaryBackgroundColor) .listRowBackground(theme.secondaryBackgroundColor)
.listRowInsets(.init()) .listRowInsets(.init())
} }
private var topPaddingView: some View { private var topPaddingView: some View {
HStack { EmptyView() } HStack { EmptyView() }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)

View file

@ -23,7 +23,7 @@ class StatusDetailViewModel: ObservableObject {
self.statusId = statusId self.statusId = statusId
remoteStatusURL = nil remoteStatusURL = nil
} }
init(status: Status) { init(status: Status) {
state = .display(status: status, context: .empty(), date: Date()) state = .display(status: status, context: .empty(), date: Date())
title = "status.post-from-\(status.account.displayNameWithoutEmojis)" title = "status.post-from-\(status.account.displayNameWithoutEmojis)"
@ -78,7 +78,7 @@ class StatusDetailViewModel: ObservableObject {
state = .display(status: data.status, context: data.context, date: Date()) state = .display(status: data.status, context: data.context, date: Date())
} }
} else { } else {
state = .display(status: data.status, context: data.context,date: Date()) state = .display(status: data.status, context: data.context, date: Date())
scrollToId = statusId scrollToId = statusId
} }
} catch { } catch {

View file

@ -33,7 +33,7 @@ struct StatusEditorAccessoryView: View {
Image(systemName: "photo.fill.on.rectangle.fill") Image(systemName: "photo.fill.on.rectangle.fill")
} }
} }
.accessibilityLabel("accessibility.editor.button.attach-photo") .accessibilityLabel("accessibility.editor.button.attach-photo")
.disabled(viewModel.showPoll) .disabled(viewModel.showPoll)
Button { Button {
@ -85,7 +85,7 @@ struct StatusEditorAccessoryView: View {
} }
.accessibilityLabel("accessibility.editor.button.language") .accessibilityLabel("accessibility.editor.button.language")
if preferences.isOpenAIEnabled { if preferences.isOpenAIEnabled {
AIMenu.disabled(!viewModel.canPost) AIMenu.disabled(!viewModel.canPost)
} }
} }

View file

@ -34,7 +34,7 @@ enum StatusEditorUTTypeSupported: String, CaseIterable {
return false return false
} }
} }
var isGif: Bool { var isGif: Bool {
switch self { switch self {
case .gif, .gif2: case .gif, .gif2:
@ -54,12 +54,14 @@ enum StatusEditorUTTypeSupported: String, CaseIterable {
if self == .jpeg || self == .png || self == .tiff || self == .image { if self == .jpeg || self == .png || self == .tiff || self == .image {
if let imageURL = result as? URL, if let imageURL = result as? URL,
let data = try? Data(contentsOf: imageURL), let data = try? Data(contentsOf: imageURL),
let image = UIImage(data: data) { let image = UIImage(data: data)
{
return image return image
} else if let data = result as? Data, } else if let data = result as? Data,
let image = UIImage(data: data) { let image = UIImage(data: data)
{
return image return image
} else if let transferable = await getImageTansferable(item: item){ } else if let transferable = await getImageTansferable(item: item) {
return transferable return transferable
} }
} }
@ -86,7 +88,7 @@ enum StatusEditorUTTypeSupported: String, CaseIterable {
} }
} }
} }
private func getGifTransferable(item: NSItemProvider) async -> GifFileTranseferable? { private func getGifTransferable(item: NSItemProvider) async -> GifFileTranseferable? {
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
_ = item.loadTransferable(type: GifFileTranseferable.self) { result in _ = item.loadTransferable(type: GifFileTranseferable.self) { result in
@ -99,7 +101,7 @@ enum StatusEditorUTTypeSupported: String, CaseIterable {
} }
} }
} }
private func getImageTansferable(item: NSItemProvider) async -> ImageFileTranseferable? { private func getImageTansferable(item: NSItemProvider) async -> ImageFileTranseferable? {
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
_ = item.loadTransferable(type: ImageFileTranseferable.self) { result in _ = item.loadTransferable(type: ImageFileTranseferable.self) { result in
@ -139,7 +141,7 @@ struct MovieFileTranseferable: Transferable {
FileRepresentation(contentType: .movie) { movie in FileRepresentation(contentType: .movie) { movie in
SentTransferredFile(movie.url) SentTransferredFile(movie.url)
} importing: { received in } importing: { received in
return Self(url: localURLFor(received: received)) Self(url: localURLFor(received: received))
} }
} }
} }
@ -155,7 +157,7 @@ struct ImageFileTranseferable: Transferable {
FileRepresentation(contentType: .image) { image in FileRepresentation(contentType: .image) { image in
SentTransferredFile(image.url) SentTransferredFile(image.url)
} importing: { received in } importing: { received in
return Self(url: localURLFor(received: received)) Self(url: localURLFor(received: received))
} }
} }
} }
@ -171,12 +173,12 @@ struct GifFileTranseferable: Transferable {
FileRepresentation(contentType: .gif) { gif in FileRepresentation(contentType: .gif) { gif in
SentTransferredFile(gif.url) SentTransferredFile(gif.url)
} importing: { received in } importing: { received in
return Self(url: localURLFor(received: received)) Self(url: localURLFor(received: received))
} }
} }
} }
fileprivate func localURLFor(received: ReceivedTransferredFile) -> URL { private func localURLFor(received: ReceivedTransferredFile) -> URL {
let copy = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).\(received.file.pathExtension)") let copy = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).\(received.file.pathExtension)")
try? FileManager.default.copyItem(at: received.file, to: copy) try? FileManager.default.copyItem(at: received.file, to: copy)
return copy return copy

View file

@ -37,11 +37,11 @@ public struct StatusEditorView: View {
.padding(.horizontal, .layoutPadding) .padding(.horizontal, .layoutPadding)
TextView($viewModel.statusText, TextView($viewModel.statusText,
getTextView: { textView in getTextView: { textView in
viewModel.textView = textView viewModel.textView = textView
}) })
.placeholder(String(localized: "status.editor.text.placeholder")) .placeholder(String(localized: "status.editor.text.placeholder"))
.setKeyboardType(preferences.isSocialKeyboardEnabled ? .twitter : .default) .setKeyboardType(preferences.isSocialKeyboardEnabled ? .twitter : .default)
.padding(.horizontal, .layoutPadding) .padding(.horizontal, .layoutPadding)
StatusEditorMediaView(viewModel: viewModel) StatusEditorMediaView(viewModel: viewModel)
if let status = viewModel.embeddedStatus { if let status = viewModel.embeddedStatus {
StatusEmbeddedView(status: status) StatusEmbeddedView(status: status)

View file

@ -20,7 +20,7 @@ public class StatusEditorViewModel: NSObject, ObservableObject {
textView?.pasteDelegate = self textView?.pasteDelegate = self
} }
} }
var selectedRange: NSRange { var selectedRange: NSRange {
get { get {
guard let textView else { guard let textView else {
@ -32,16 +32,14 @@ public class StatusEditorViewModel: NSObject, ObservableObject {
textView?.selectedRange = newValue textView?.selectedRange = newValue
} }
} }
var markedTextRange: UITextRange? { var markedTextRange: UITextRange? {
get { guard let textView else {
guard let textView else { return nil
return nil
}
return textView.markedTextRange
} }
return textView.markedTextRange
} }
@Published var statusText = NSMutableAttributedString(string: "") { @Published var statusText = NSMutableAttributedString(string: "") {
didSet { didSet {
let range = selectedRange let range = selectedRange
@ -372,7 +370,8 @@ public class StatusEditorViewModel: NSObject, ObservableObject {
mediaAttachment: nil, mediaAttachment: nil,
error: nil)) error: nil))
} else if var content = content as? ImageFileTranseferable, } else if var content = content as? ImageFileTranseferable,
let image = content.image { let image = content.image
{
mediasImages.append(.init(image: image, mediasImages.append(.init(image: image,
movieTransferable: nil, movieTransferable: nil,
gifTransferable: nil, gifTransferable: nil,
@ -507,7 +506,7 @@ public class StatusEditorViewModel: NSObject, ObservableObject {
for media in selectedMedias { for media in selectedMedias {
print(media.supportedContentTypes) print(media.supportedContentTypes)
var file: (any Transferable)? var file: (any Transferable)?
if file == nil { if file == nil {
file = try? await media.loadTransferable(type: GifFileTranseferable.self) file = try? await media.loadTransferable(type: GifFileTranseferable.self)
} }
@ -682,6 +681,7 @@ public class StatusEditorViewModel: NSObject, ObservableObject {
} }
// MARK: - Custom emojis // MARK: - Custom emojis
func fetchCustomEmojis() async { func fetchCustomEmojis() async {
guard let client else { return } guard let client else { return }
do { do {
@ -690,7 +690,8 @@ public class StatusEditorViewModel: NSObject, ObservableObject {
} }
} }
//MARK: - DropDelegate // MARK: - DropDelegate
extension StatusEditorViewModel: DropDelegate { extension StatusEditorViewModel: DropDelegate {
public func performDrop(info: DropInfo) -> Bool { public func performDrop(info: DropInfo) -> Bool {
let item = info.itemProviders(for: StatusEditorUTTypeSupported.types()) let item = info.itemProviders(for: StatusEditorUTTypeSupported.types())
@ -700,17 +701,20 @@ extension StatusEditorViewModel: DropDelegate {
} }
// MARK: - UITextPasteDelegate // MARK: - UITextPasteDelegate
extension StatusEditorViewModel: UITextPasteDelegate { extension StatusEditorViewModel: UITextPasteDelegate {
public func textPasteConfigurationSupporting( public func textPasteConfigurationSupporting(
_ textPasteConfigurationSupporting: UITextPasteConfigurationSupporting, _: UITextPasteConfigurationSupporting,
transform item: UITextPasteItem) { transform item: UITextPasteItem
if !item.itemProvider.registeredContentTypes(conformingTo: .image).isEmpty || ) {
!item.itemProvider.registeredContentTypes(conformingTo: .video).isEmpty || if !item.itemProvider.registeredContentTypes(conformingTo: .image).isEmpty ||
!item.itemProvider.registeredContentTypes(conformingTo: .gif).isEmpty { !item.itemProvider.registeredContentTypes(conformingTo: .video).isEmpty ||
processItemsProvider(items: [item.itemProvider]) !item.itemProvider.registeredContentTypes(conformingTo: .gif).isEmpty
item.setNoResult() {
} else { processItemsProvider(items: [item.itemProvider])
item.setDefaultResult() item.setNoResult()
} } else {
item.setDefaultResult()
} }
}
} }

View file

@ -1,38 +1,37 @@
import SwiftUI
import DesignSystem import DesignSystem
import SwiftUI
extension TextView.Representable { extension TextView.Representable {
final class Coordinator: NSObject, UITextViewDelegate { final class Coordinator: NSObject, UITextViewDelegate {
internal let textView: UIKitTextView internal let textView: UIKitTextView
private var originalText: NSMutableAttributedString = .init() private var originalText: NSMutableAttributedString = .init()
private var text: Binding<NSMutableAttributedString> private var text: Binding<NSMutableAttributedString>
private var calculatedHeight: Binding<CGFloat> private var calculatedHeight: Binding<CGFloat>
var didBecomeFirstResponder = false var didBecomeFirstResponder = false
var getTextView: ((UITextView) -> Void)? var getTextView: ((UITextView) -> Void)?
init(text: Binding<NSMutableAttributedString>, init(text: Binding<NSMutableAttributedString>,
calculatedHeight: Binding<CGFloat>, calculatedHeight: Binding<CGFloat>,
getTextView: ((UITextView) -> Void)? getTextView: ((UITextView) -> Void)?)
) { {
textView = UIKitTextView() textView = UIKitTextView()
textView.backgroundColor = .clear textView.backgroundColor = .clear
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textView.isScrollEnabled = false textView.isScrollEnabled = false
textView.textContainer.lineFragmentPadding = 0 textView.textContainer.lineFragmentPadding = 0
textView.textContainerInset = .zero textView.textContainerInset = .zero
self.text = text self.text = text
self.calculatedHeight = calculatedHeight self.calculatedHeight = calculatedHeight
self.getTextView = getTextView self.getTextView = getTextView
super.init() super.init()
textView.delegate = self textView.delegate = self
textView.font = Font.scaledBodyUIFont textView.font = Font.scaledBodyUIFont
textView.adjustsFontForContentSizeCategory = true textView.adjustsFontForContentSizeCategory = true
textView.autocapitalizationType = .sentences textView.autocapitalizationType = .sentences
@ -43,46 +42,43 @@ extension TextView.Representable {
textView.allowsEditingTextAttributes = false textView.allowsEditingTextAttributes = false
textView.returnKeyType = .default textView.returnKeyType = .default
textView.allowsEditingTextAttributes = true textView.allowsEditingTextAttributes = true
self.getTextView?(textView) self.getTextView?(textView)
} }
func textViewDidBeginEditing(_ textView: UITextView) { func textViewDidBeginEditing(_: UITextView) {
originalText = text.wrappedValue originalText = text.wrappedValue
DispatchQueue.main.async { DispatchQueue.main.async {
self.recalculateHeight() self.recalculateHeight()
} }
} }
func textViewDidChange(_ textView: UITextView) { func textViewDidChange(_ textView: UITextView) {
DispatchQueue.main.async { DispatchQueue.main.async {
self.text.wrappedValue = NSMutableAttributedString(attributedString: textView.attributedText) self.text.wrappedValue = NSMutableAttributedString(attributedString: textView.attributedText)
self.recalculateHeight() self.recalculateHeight()
} }
} }
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { func textView(_: UITextView, shouldChangeTextIn _: NSRange, replacementText _: String) -> Bool {
return true return true
} }
} }
} }
extension TextView.Representable.Coordinator { extension TextView.Representable.Coordinator {
func update(representable: TextView.Representable) { func update(representable: TextView.Representable) {
textView.keyboardType = representable.keyboard textView.keyboardType = representable.keyboard
recalculateHeight() recalculateHeight()
textView.setNeedsDisplay() textView.setNeedsDisplay()
} }
private func recalculateHeight() { private func recalculateHeight() {
let newSize = textView.sizeThatFits(CGSize(width: textView.frame.width, height: .greatestFiniteMagnitude)) let newSize = textView.sizeThatFits(CGSize(width: textView.frame.width, height: .greatestFiniteMagnitude))
guard calculatedHeight.wrappedValue != newSize.height else { return } guard calculatedHeight.wrappedValue != newSize.height else { return }
DispatchQueue.main.async { // call in next render cycle. DispatchQueue.main.async { // call in next render cycle.
self.calculatedHeight.wrappedValue = newSize.height self.calculatedHeight.wrappedValue = newSize.height
} }
} }
} }

View file

@ -1,13 +1,12 @@
import SwiftUI import SwiftUI
public extension TextView { public extension TextView {
/// Specify a placeholder text /// Specify a placeholder text
/// - Parameter placeholder: The placeholder text /// - Parameter placeholder: The placeholder text
func placeholder(_ placeholder: String) -> TextView { func placeholder(_ placeholder: String) -> TextView {
self.placeholder(placeholder) { $0 } self.placeholder(placeholder) { $0 }
} }
/// Specify a placeholder with the specified configuration /// Specify a placeholder with the specified configuration
/// ///
/// Example: /// Example:
@ -22,18 +21,17 @@ public extension TextView {
view.placeholderView = AnyView(configure(text)) view.placeholderView = AnyView(configure(text))
return view return view
} }
/// Specify a custom placeholder view /// Specify a custom placeholder view
func placeholder<V: View>(_ placeholder: V) -> TextView { func placeholder<V: View>(_ placeholder: V) -> TextView {
var view = self var view = self
view.placeholderView = AnyView(placeholder) view.placeholderView = AnyView(placeholder)
return view return view
} }
func setKeyboardType(_ keyboardType: UIKeyboardType) -> TextView { func setKeyboardType(_ keyboardType: UIKeyboardType) -> TextView {
var view = self var view = self
view.keyboard = keyboardType view.keyboard = keyboardType
return view return view
} }
} }

View file

@ -2,25 +2,24 @@ import SwiftUI
extension TextView { extension TextView {
struct Representable: UIViewRepresentable { struct Representable: UIViewRepresentable {
@Binding var text: NSMutableAttributedString @Binding var text: NSMutableAttributedString
@Binding var calculatedHeight: CGFloat @Binding var calculatedHeight: CGFloat
let keyboard: UIKeyboardType let keyboard: UIKeyboardType
var getTextView: ((UITextView) -> Void)? var getTextView: ((UITextView) -> Void)?
func makeUIView(context: Context) -> UIKitTextView { func makeUIView(context: Context) -> UIKitTextView {
context.coordinator.textView context.coordinator.textView
} }
func updateUIView(_ view: UIKitTextView, context: Context) { func updateUIView(_: UIKitTextView, context: Context) {
context.coordinator.update(representable: self) context.coordinator.update(representable: self)
if !context.coordinator.didBecomeFirstResponder { if !context.coordinator.didBecomeFirstResponder {
context.coordinator.textView.becomeFirstResponder() context.coordinator.textView.becomeFirstResponder()
context.coordinator.didBecomeFirstResponder = true context.coordinator.didBecomeFirstResponder = true
} }
} }
@discardableResult func makeCoordinator() -> Coordinator { @discardableResult func makeCoordinator() -> Coordinator {
Coordinator( Coordinator(
text: $text, text: $text,
@ -28,7 +27,5 @@ extension TextView {
getTextView: getTextView getTextView: getTextView
) )
} }
} }
} }

View file

@ -1,36 +1,35 @@
import SwiftUI
import DesignSystem import DesignSystem
import SwiftUI
/// A SwiftUI TextView implementation that supports both scrolling and auto-sizing layouts /// A SwiftUI TextView implementation that supports both scrolling and auto-sizing layouts
public struct TextView: View { public struct TextView: View {
@Environment(\.layoutDirection) private var layoutDirection @Environment(\.layoutDirection) private var layoutDirection
@Binding private var text: NSMutableAttributedString @Binding private var text: NSMutableAttributedString
@Binding private var isEmpty: Bool @Binding private var isEmpty: Bool
@State private var calculatedHeight: CGFloat = 44 @State private var calculatedHeight: CGFloat = 44
private var getTextView: ((UITextView) -> Void)? private var getTextView: ((UITextView) -> Void)?
var placeholderView: AnyView? var placeholderView: AnyView?
var keyboard: UIKeyboardType = .default var keyboard: UIKeyboardType = .default
/// Makes a new TextView that supports `NSAttributedString` /// Makes a new TextView that supports `NSAttributedString`
/// - Parameters: /// - Parameters:
/// - text: A binding to the attributed text /// - text: A binding to the attributed text
public init(_ text: Binding<NSMutableAttributedString>, public init(_ text: Binding<NSMutableAttributedString>,
getTextView: ((UITextView) -> Void)? = nil getTextView: ((UITextView) -> Void)? = nil)
) { {
_text = text _text = text
_isEmpty = Binding( _isEmpty = Binding(
get: { text.wrappedValue.string.isEmpty }, get: { text.wrappedValue.string.isEmpty },
set: { _ in } set: { _ in }
) )
self.getTextView = getTextView self.getTextView = getTextView
} }
public var body: some View { public var body: some View {
Representable( Representable(
text: $text, text: $text,
@ -53,19 +52,16 @@ public struct TextView: View {
alignment: .topLeading alignment: .topLeading
) )
} }
} }
final class UIKitTextView: UITextView { final class UIKitTextView: UITextView {
override var keyCommands: [UIKeyCommand]? { override var keyCommands: [UIKeyCommand]? {
return (super.keyCommands ?? []) + [ return (super.keyCommands ?? []) + [
UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(escape(_:))) UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(escape(_:))),
] ]
} }
@objc private func escape(_ sender: Any) { @objc private func escape(_: Any) {
resignFirstResponder() resignFirstResponder()
} }
} }

View file

@ -37,7 +37,8 @@ public struct StatusPollView: View {
private func isSelected(option: Poll.Option) -> Bool { private func isSelected(option: Poll.Option) -> Bool {
if let optionIndex = viewModel.poll.options.firstIndex(where: { $0.id == option.id }), if let optionIndex = viewModel.poll.options.firstIndex(where: { $0.id == option.id }),
let _ = viewModel.votes.firstIndex(of: optionIndex) { let _ = viewModel.votes.firstIndex(of: optionIndex)
{
return true return true
} }
return false return false
@ -62,7 +63,7 @@ public struct StatusPollView: View {
return Image(systemName: imageName) return Image(systemName: imageName)
.foregroundColor(theme.labelColor) .foregroundColor(theme.labelColor)
} }
public var body: some View { public var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
ForEach(viewModel.poll.options) { option in ForEach(viewModel.poll.options) { option in

View file

@ -38,7 +38,7 @@ public class StatusPollViewModel: ObservableObject {
print(error) print(error)
} }
} }
public func handleSelection(_ pollIndex: Int) { public func handleSelection(_ pollIndex: Int) {
if poll.multiple { if poll.multiple {
if let voterIndex = votes.firstIndex(of: pollIndex) { if let voterIndex = votes.firstIndex(of: pollIndex) {

View file

@ -94,7 +94,7 @@ struct StatusActionsView: View {
} }
} }
} }
private func handleAction(action: Actions) { private func handleAction(action: Actions) {
Task { Task {
HapticManager.shared.fireHaptic(of: .notification(.success)) HapticManager.shared.fireHaptic(of: .notification(.success))

View file

@ -163,7 +163,7 @@ public struct StatusMediaPreviewView: View {
.frame(width: newSize.width, height: newSize.height) .frame(width: newSize.width, height: newSize.height)
} }
} }
case .gifv, .video, .audio: case .gifv, .video, .audio:
if let url = attachment.url { if let url = attachment.url {
VideoPlayerView(viewModel: .init(url: url)) VideoPlayerView(viewModel: .init(url: url))

View file

@ -83,7 +83,8 @@ struct StatusRowContextMenu: View {
} }
} label: { } label: {
if let statusLang = viewModel.status.language, if let statusLang = viewModel.status.language,
let languageName = Locale.current.localizedString(forLanguageCode: statusLang) { let languageName = Locale.current.localizedString(forLanguageCode: statusLang)
{
Label("status.action.translate-from-\(languageName)", systemImage: "captions.bubble") Label("status.action.translate-from-\(languageName)", systemImage: "captions.bubble")
} else { } else {
Label("status.action.translate", systemImage: "captions.bubble") Label("status.action.translate", systemImage: "captions.bubble")
@ -113,8 +114,7 @@ struct StatusRowContextMenu: View {
} }
Button(role: .destructive, Button(role: .destructive,
action: { viewModel.showDeleteAlert = true }, action: { viewModel.showDeleteAlert = true },
label: { Label("status.action.delete", systemImage: "trash") } label: { Label("status.action.delete", systemImage: "trash") })
)
} }
} else if !viewModel.isRemote { } else if !viewModel.isRemote {
Section(viewModel.status.account.acct) { Section(viewModel.status.account.acct) {

View file

@ -1,14 +1,14 @@
import SwiftUI
import Env
import DesignSystem import DesignSystem
import Env
import Models import Models
import SwiftUI
struct StatusRowDetailView: View { struct StatusRowDetailView: View {
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@EnvironmentObject private var routerPath: RouterPath @EnvironmentObject private var routerPath: RouterPath
@ObservedObject var viewModel: StatusRowViewModel @ObservedObject var viewModel: StatusRowViewModel
var body: some View { var body: some View {
Group { Group {
Divider() Divider()
@ -84,7 +84,7 @@ struct StatusRowDetailView: View {
await viewModel.fetchActionsAccounts() await viewModel.fetchActionsAccounts()
} }
} }
private func makeAccountsScrollView(accounts: [Account]) -> some View { private func makeAccountsScrollView(accounts: [Account]) -> some View {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 0) { LazyHStack(spacing: 0) {
@ -96,5 +96,4 @@ struct StatusRowDetailView: View {
.padding(.leading, .layoutPadding) .padding(.leading, .layoutPadding)
} }
} }
} }

View file

@ -118,19 +118,21 @@ public struct StatusRowView: View {
title: Text("status.action.delete.confirm.title"), title: Text("status.action.delete.confirm.title"),
message: Text("status.action.delete.confirm.message"), message: Text("status.action.delete.confirm.message"),
primaryButton: .destructive( primaryButton: .destructive(
Text("status.action.delete")) { Text("status.action.delete"))
Task { {
await viewModel.delete() Task {
} await viewModel.delete()
}, }
secondaryButton: .cancel()) },
secondaryButton: .cancel()
)
}) })
.alignmentGuide(.listRowSeparatorLeading) { _ in .alignmentGuide(.listRowSeparatorLeading) { _ in
-100 -100
} }
} }
} }
@ViewBuilder @ViewBuilder
private var accesibilityActions: some View { private var accesibilityActions: some View {
// Add the individual mentions as accessibility actions // Add the individual mentions as accessibility actions
@ -139,17 +141,17 @@ public struct StatusRowView: View {
routerPath.navigate(to: .accountDetail(id: mention.id)) routerPath.navigate(to: .accountDetail(id: mention.id))
} }
} }
Button(viewModel.displaySpoiler ? "status.show-more" : "status.show-less") { Button(viewModel.displaySpoiler ? "status.show-more" : "status.show-less") {
withAnimation { withAnimation {
viewModel.displaySpoiler.toggle() viewModel.displaySpoiler.toggle()
} }
} }
Button("@\(viewModel.status.account.username)") { Button("@\(viewModel.status.account.username)") {
routerPath.navigate(to: .accountDetail(id: viewModel.status.account.id)) routerPath.navigate(to: .accountDetail(id: viewModel.status.account.id))
} }
contextMenu contextMenu
} }
@ -329,7 +331,7 @@ public struct StatusRowView: View {
.foregroundColor(.gray) .foregroundColor(.gray)
} }
} }
private var contextMenuButton: some View { private var contextMenuButton: some View {
Menu { Menu {
contextMenu contextMenu
@ -470,7 +472,7 @@ public struct StatusRowView: View {
makeSwipeButton(action: preferences.swipeActionsStatusLeadingRight) makeSwipeButton(action: preferences.swipeActionsStatusLeadingRight)
} }
} }
@ViewBuilder @ViewBuilder
private func makeSwipeButton(action: StatusAction) -> some View { private func makeSwipeButton(action: StatusAction) -> some View {
switch action { switch action {
@ -500,14 +502,14 @@ public struct StatusRowView: View {
await viewModel.unbookmark() await viewModel.unbookmark()
} else { } else {
await await
viewModel.bookmark() viewModel.bookmark()
} }
} }
case .none: case .none:
EmptyView() EmptyView()
} }
} }
@ViewBuilder @ViewBuilder
private func makeSwipeButtonForRouterPath(action: StatusAction, destination: SheetDestinations) -> some View { private func makeSwipeButtonForRouterPath(action: StatusAction, destination: SheetDestinations) -> some View {
Button { Button {
@ -520,9 +522,9 @@ public struct StatusRowView: View {
} }
.tint(action.color(themeTintColor: theme.tintColor)) .tint(action.color(themeTintColor: theme.tintColor))
} }
@ViewBuilder @ViewBuilder
private func makeSwipeButtonForTask(action: StatusAction, task: @escaping () async -> Void ) -> some View { private func makeSwipeButtonForTask(action: StatusAction, task: @escaping () async -> Void) -> some View {
Button { Button {
Task { Task {
HapticManager.shared.fireHaptic(of: .notification(.success)) HapticManager.shared.fireHaptic(of: .notification(.success))

View file

@ -34,7 +34,7 @@ public class StatusRowViewModel: ObservableObject {
@Published var rebloggers: [Account] = [] @Published var rebloggers: [Account] = []
private let theme = Theme.shared private let theme = Theme.shared
var seen = false var seen = false
var filter: Filtered? { var filter: Filtered? {
@ -42,7 +42,6 @@ public class StatusRowViewModel: ObservableObject {
} }
var highlightRowColor: Color { var highlightRowColor: Color {
if status.visibility == .direct { if status.visibility == .direct {
return theme.tintColor.opacity(0.15) return theme.tintColor.opacity(0.15)
} else if status.userMentioned != nil { } else if status.userMentioned != nil {
@ -265,13 +264,13 @@ public class StatusRowViewModel: ObservableObject {
_ = try await client.delete(endpoint: Statuses.status(id: status.id)) _ = try await client.delete(endpoint: Statuses.status(id: status.id))
} catch {} } catch {}
} }
func fetchActionsAccounts() async { func fetchActionsAccounts() async {
guard let client else { return } guard let client else { return }
do { do {
favoriters = try await client.get(endpoint: Statuses.favoritedBy(id: status.id, maxId: nil)) favoriters = try await client.get(endpoint: Statuses.favoritedBy(id: status.id, maxId: nil))
rebloggers = try await client.get(endpoint: Statuses.rebloggedBy(id: status.id, maxId: nil)) rebloggers = try await client.get(endpoint: Statuses.rebloggedBy(id: status.id, maxId: nil))
} catch { } } catch {}
} }
private func updateFromStatus(status: Status) { private func updateFromStatus(status: Status) {

View file

@ -5,7 +5,7 @@ import SwiftUI
public enum RemoteTimelineFilter: String, CaseIterable, Hashable, Equatable { public enum RemoteTimelineFilter: String, CaseIterable, Hashable, Equatable {
case local, federated, trending case local, federated, trending
public func localizedTitle() -> LocalizedStringKey { public func localizedTitle() -> LocalizedStringKey {
switch self { switch self {
case .federated: case .federated:
@ -16,7 +16,7 @@ public enum RemoteTimelineFilter: String, CaseIterable, Hashable, Equatable {
return "timeline.trending" return "timeline.trending"
} }
} }
public func iconName() -> String { public func iconName() -> String {
switch self { switch self {
case .federated: case .federated:
@ -196,7 +196,7 @@ extension TimelineFilter: Codable {
} }
public func encode(to encoder: Encoder) throws { public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
switch self { switch self {
case .home: case .home:
try container.encode(CodingKeys.home.rawValue, forKey: .home) try container.encode(CodingKeys.home.rawValue, forKey: .home)
@ -206,13 +206,13 @@ extension TimelineFilter: Codable {
try container.encode(CodingKeys.federated.rawValue, forKey: .federated) try container.encode(CodingKeys.federated.rawValue, forKey: .federated)
case .trending: case .trending:
try container.encode(CodingKeys.trending.rawValue, forKey: .trending) try container.encode(CodingKeys.trending.rawValue, forKey: .trending)
case .hashtag(let tag, let accountId): case let .hashtag(tag, accountId):
var nestedContainer = container.nestedUnkeyedContainer(forKey: .hashtag) var nestedContainer = container.nestedUnkeyedContainer(forKey: .hashtag)
try nestedContainer.encode(tag) try nestedContainer.encode(tag)
try nestedContainer.encode(accountId) try nestedContainer.encode(accountId)
case .list(let list): case let .list(list):
try container.encode(list, forKey: .list) try container.encode(list, forKey: .list)
case .remoteLocal(let server, let filter): case let .remoteLocal(server, filter):
var nestedContainer = container.nestedUnkeyedContainer(forKey: .hashtag) var nestedContainer = container.nestedUnkeyedContainer(forKey: .hashtag)
try nestedContainer.encode(server) try nestedContainer.encode(server)
try nestedContainer.encode(filter) try nestedContainer.encode(filter)
@ -260,17 +260,17 @@ extension RemoteTimelineFilter: Codable {
case .trending: case .trending:
self = .trending self = .trending
default: default:
throw DecodingError.dataCorrupted( throw DecodingError.dataCorrupted(
DecodingError.Context( DecodingError.Context(
codingPath: container.codingPath, codingPath: container.codingPath,
debugDescription: "Unabled to decode enum." debugDescription: "Unabled to decode enum."
)
) )
)
} }
} }
public func encode(to encoder: Encoder) throws { public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
switch self { switch self {
case .local: case .local:
try container.encode(CodingKeys.local.rawValue, forKey: .local) try container.encode(CodingKeys.local.rawValue, forKey: .local)

View file

@ -65,7 +65,8 @@ public struct TimelineView: View {
if let collectionView, if let collectionView,
let index, let index,
let rows = collectionView.dataSource?.collectionView(collectionView, numberOfItemsInSection: 0), let rows = collectionView.dataSource?.collectionView(collectionView, numberOfItemsInSection: 0),
rows > index { rows > index
{
collectionView.scrollToItem(at: .init(row: index, section: 0), collectionView.scrollToItem(at: .init(row: index, section: 0),
at: .top, at: .top,
animated: viewModel.scrollToIndexAnimated) animated: viewModel.scrollToIndexAnimated)

View file

@ -34,7 +34,7 @@ class TimelineViewModel: ObservableObject {
} }
} }
} }
private var timelineTask: Task<Void, Never>? private var timelineTask: Task<Void, Never>?
@Published var tag: Tag? @Published var tag: Tag?
@ -170,7 +170,7 @@ extension TimelineViewModel: StatusesFetcher {
// Hydrate statuses in the Timeline when statuses are empty. // Hydrate statuses in the Timeline when statuses are empty.
private func fetchFirstPage(client: Client) async throws { private func fetchFirstPage(client: Client) async throws {
pendingStatusesObserver.pendingStatuses = [] pendingStatusesObserver.pendingStatuses = []
if statuses.isEmpty { if statuses.isEmpty {
statusesState = .loading statusesState = .loading
} }
@ -208,7 +208,7 @@ extension TimelineViewModel: StatusesFetcher {
ReblogCache.shared.removeDuplicateReblogs(&statuses) ReblogCache.shared.removeDuplicateReblogs(&statuses)
await cacheHome() await cacheHome()
withAnimation { withAnimation {
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage) statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
} }
@ -238,7 +238,7 @@ extension TimelineViewModel: StatusesFetcher {
canStreamEvents = true canStreamEvents = true
return return
} }
// Return if task has been cancelled. // Return if task has been cancelled.
guard !Task.isCancelled else { guard !Task.isCancelled else {
canStreamEvents = true canStreamEvents = true
@ -283,7 +283,7 @@ extension TimelineViewModel: StatusesFetcher {
canStreamEvents = true canStreamEvents = true
} }
} }
// We trigger a new fetch so we can get the next new statuses if any. // We trigger a new fetch so we can get the next new statuses if any.
// If none, it'll stop there. // If none, it'll stop there.
if !Task.isCancelled, let latest = statuses.first, let client { if !Task.isCancelled, let latest = statuses.first, let client {