New default timeline layout

This commit is contained in:
Thomas Ricouard 2023-12-27 16:07:16 +01:00
parent 2e1652ef53
commit 1a3bded101
12 changed files with 147 additions and 131 deletions

View File

@ -260,9 +260,13 @@ struct DisplaySettingsView: View {
Button { Button {
theme.followSystemColorScheme = true theme.followSystemColorScheme = true
theme.applySet(set: colorScheme == .dark ? .iceCubeDark : .iceCubeLight) theme.applySet(set: colorScheme == .dark ? .iceCubeDark : .iceCubeLight)
theme.avatarShape = .rounded theme.avatarShape = .circle
theme.avatarPosition = .top theme.avatarPosition = .leading
theme.statusActionsDisplay = .full theme.statusActionsDisplay = .full
theme.displayFullUsername = false
theme.statusDisplayStyle = .large
theme.lineSpacing = 1.2
theme.fontSizeScale = 1.0
localValues.tintColor = theme.tintColor localValues.tintColor = theme.tintColor
localValues.primaryBackgroundColor = theme.primaryBackgroundColor localValues.primaryBackgroundColor = theme.primaryBackgroundColor

View File

@ -6,6 +6,7 @@ public extension CGFloat {
static let dividerPadding: CGFloat = 2 static let dividerPadding: CGFloat = 2
static let scrollToViewHeight: CGFloat = 1 static let scrollToViewHeight: CGFloat = 1
static let statusColumnsSpacing: CGFloat = 8 static let statusColumnsSpacing: CGFloat = 8
static let statusComponentSpacing: CGFloat = 6
static let secondaryColumnWidth: CGFloat = 400 static let secondaryColumnWidth: CGFloat = 400
static let sidebarWidth: CGFloat = 90 static let sidebarWidth: CGFloat = 90
static let pollBarHeight: CGFloat = 30 static let pollBarHeight: CGFloat = 30

View File

@ -18,14 +18,14 @@ import SwiftUI
@AppStorage(ThemeKey.primaryBackground.rawValue) public var primaryBackgroundColor: Color = .white @AppStorage(ThemeKey.primaryBackground.rawValue) public var primaryBackgroundColor: Color = .white
@AppStorage(ThemeKey.secondaryBackground.rawValue) public var secondaryBackgroundColor: Color = .gray @AppStorage(ThemeKey.secondaryBackground.rawValue) public var secondaryBackgroundColor: Color = .gray
@AppStorage(ThemeKey.label.rawValue) public var labelColor: Color = .black @AppStorage(ThemeKey.label.rawValue) public var labelColor: Color = .black
@AppStorage(ThemeKey.avatarPosition2.rawValue) var avatarPosition: AvatarPosition = .top @AppStorage(ThemeKey.avatarPosition2.rawValue) var avatarPosition: AvatarPosition = .leading
@AppStorage(ThemeKey.avatarShape2.rawValue) var avatarShape: AvatarShape = .rounded @AppStorage(ThemeKey.avatarShape2.rawValue) var avatarShape: AvatarShape = .circle
@AppStorage(ThemeKey.selectedSet.rawValue) var storedSet: ColorSetName = .iceCubeDark @AppStorage(ThemeKey.selectedSet.rawValue) var storedSet: ColorSetName = .iceCubeDark
@AppStorage(ThemeKey.statusActionsDisplay.rawValue) public var statusActionsDisplay: StatusActionsDisplay = .full @AppStorage(ThemeKey.statusActionsDisplay.rawValue) public var statusActionsDisplay: StatusActionsDisplay = .full
@AppStorage(ThemeKey.statusDisplayStyle.rawValue) public var statusDisplayStyle: StatusDisplayStyle = .large @AppStorage(ThemeKey.statusDisplayStyle.rawValue) public var statusDisplayStyle: StatusDisplayStyle = .large
@AppStorage(ThemeKey.followSystemColorSchme.rawValue) public var followSystemColorScheme: Bool = true @AppStorage(ThemeKey.followSystemColorSchme.rawValue) public var followSystemColorScheme: Bool = true
@AppStorage(ThemeKey.displayFullUsernameTimeline.rawValue) public var displayFullUsername: Bool = true @AppStorage(ThemeKey.displayFullUsernameTimeline.rawValue) public var displayFullUsername: Bool = false
@AppStorage(ThemeKey.lineSpacing.rawValue) public var lineSpacing: Double = 0.8 @AppStorage(ThemeKey.lineSpacing.rawValue) public var lineSpacing: Double = 1.2
@AppStorage("font_size_scale") public var fontSizeScale: Double = 1 @AppStorage("font_size_scale") public var fontSizeScale: Double = 1
@AppStorage("chosen_font") public var chosenFontData: Data? @AppStorage("chosen_font") public var chosenFontData: Data?

View File

@ -10,6 +10,8 @@ class DateFormatterCache: @unchecked Sendable {
init() { init() {
let createdAtRelativeFormatter = RelativeDateTimeFormatter() let createdAtRelativeFormatter = RelativeDateTimeFormatter()
createdAtRelativeFormatter.unitsStyle = .short createdAtRelativeFormatter.unitsStyle = .short
createdAtRelativeFormatter.formattingContext = .listItem
createdAtRelativeFormatter.dateTimeStyle = .numeric
self.createdAtRelativeFormatter = createdAtRelativeFormatter self.createdAtRelativeFormatter = createdAtRelativeFormatter
let createdAtShortDateFormatted = DateFormatter() let createdAtShortDateFormatted = DateFormatter()

View File

@ -38,7 +38,10 @@ public struct ConsolidatedNotification: Identifiable {
} }
public static func placeholders() -> [ConsolidatedNotification] { public static func placeholders() -> [ConsolidatedNotification] {
[.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()] [.placeholder(), .placeholder(), .placeholder(),
.placeholder(), .placeholder(), .placeholder(),
.placeholder(), .placeholder(), .placeholder(),
.placeholder(), .placeholder(), .placeholder()]
} }
} }

View File

@ -148,7 +148,8 @@ public final class Status: AnyStatus, Codable, Identifiable, Equatable, Hashable
} }
public static func placeholders() -> [Status] { public static func placeholders() -> [Status] {
[.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()] [.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(),
.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()]
} }
public var reblogAsAsStatus: Status? { public var reblogAsAsStatus: Status? {

View File

@ -94,6 +94,7 @@ struct NotificationRowView: View {
.frame(height: AvatarView.FrameConfig.status.size.height + 2) .frame(height: AvatarView.FrameConfig.status.size.height + 2)
}.offset(y: -1) }.offset(y: -1)
} }
if reasons.isEmpty {
HStack(spacing: 0) { HStack(spacing: 0) {
EmojiTextApp(.init(stringValue: notification.accounts[0].safeDisplayName), EmojiTextApp(.init(stringValue: notification.accounts[0].safeDisplayName),
emojis: notification.accounts[0].emojis, emojis: notification.accounts[0].emojis,
@ -133,6 +134,11 @@ struct NotificationRowView: View {
} }
Spacer() Spacer()
} }
} else {
Text(" ")
.font(.scaledSubheadline)
.fontWeight(.semibold)
}
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {

View File

@ -48,7 +48,7 @@ public struct StatusRowView: View {
Spacer(minLength: 8) Spacer(minLength: 8)
} }
} }
VStack(alignment: .leading) { VStack(alignment: .leading, spacing: .statusComponentSpacing) {
if viewModel.isFiltered, let filter = viewModel.filter { if viewModel.isFiltered, let filter = viewModel.filter {
switch filter.filter.filterAction { switch filter.filter.filterAction {
case .warn: case .warn:
@ -74,7 +74,7 @@ public struct StatusRowView: View {
AvatarView(viewModel.finalStatus.account.avatar) AvatarView(viewModel.finalStatus.account.avatar)
} }
} }
VStack(alignment: .leading) { VStack(alignment: .leading, spacing: .statusComponentSpacing) {
if !isCompact, theme.avatarPosition == .top { if !isCompact, theme.avatarPosition == .top {
StatusRowReblogView(viewModel: viewModel) StatusRowReblogView(viewModel: viewModel)
StatusRowReplyView(viewModel: viewModel) StatusRowReplyView(viewModel: viewModel)
@ -82,7 +82,6 @@ public struct StatusRowView: View {
StatusRowTagView(viewModel: viewModel) StatusRowTagView(viewModel: viewModel)
} }
} }
VStack(alignment: .leading, spacing: 8) {
if !isCompact { if !isCompact {
StatusRowHeaderView(viewModel: viewModel) StatusRowHeaderView(viewModel: viewModel)
} }
@ -97,17 +96,13 @@ public struct StatusRowView: View {
accessibilityActions accessibilityActions
} }
} }
} if reasons.isEmpty,
VStack(alignment: .leading, spacing: 12) { viewModel.showActions, isFocused || theme.statusActionsDisplay != .none,
if viewModel.showActions, isFocused || theme.statusActionsDisplay != .none, !isInCaptureMode { !isInCaptureMode {
StatusRowActionsView(viewModel: viewModel) StatusRowActionsView(viewModel: viewModel)
.padding(.top, 8)
.tint(isFocused ? theme.tintColor : .gray) .tint(isFocused ? theme.tintColor : .gray)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .padding(.top, 6)
guard !isFocused else { return }
viewModel.navigateToDetail()
}
} }
if isFocused, !isCompact { if isFocused, !isCompact {
@ -118,7 +113,6 @@ public struct StatusRowView: View {
} }
} }
} }
}
.onAppear { .onAppear {
viewModel.markSeen() viewModel.markSeen()
if reasons.isEmpty { if reasons.isEmpty {

View File

@ -14,6 +14,8 @@ struct StatusRowActionsView: View {
@Environment(\.openWindow) private var openWindow @Environment(\.openWindow) private var openWindow
@Environment(\.isStatusFocused) private var isFocused @Environment(\.isStatusFocused) private var isFocused
@State private var showTextForSelection: Bool = false
var viewModel: StatusRowViewModel var viewModel: StatusRowViewModel
func privateBoost() -> Bool { func privateBoost() -> Bool {
@ -22,7 +24,7 @@ struct StatusRowActionsView: View {
@MainActor @MainActor
enum Action: CaseIterable { enum Action: CaseIterable {
case respond, boost, favorite, bookmark, share case respond, boost, favorite, bookmark, share, menu
// Have to implement this manually here due to compiler not implicitly // Have to implement this manually here due to compiler not implicitly
// inserting `nonisolated`, which leads to a warning: // inserting `nonisolated`, which leads to a warning:
@ -31,7 +33,7 @@ struct StatusRowActionsView: View {
// satisfy nonisolated protocol requirement // satisfy nonisolated protocol requirement
// //
public nonisolated static var allCases: [StatusRowActionsView.Action] { public nonisolated static var allCases: [StatusRowActionsView.Action] {
[.respond, .boost, .favorite, .bookmark, .share] [.respond, .boost, .favorite, .share, .menu]
} }
func image(dataController: StatusDataController, privateBoost: Bool = false) -> Image { func image(dataController: StatusDataController, privateBoost: Bool = false) -> Image {
@ -53,6 +55,8 @@ struct StatusRowActionsView: View {
return Image(systemName: dataController.isBookmarked ? "bookmark.fill" : "bookmark") return Image(systemName: dataController.isBookmarked ? "bookmark.fill" : "bookmark")
case .share: case .share:
return Image(systemName: "square.and.arrow.up") return Image(systemName: "square.and.arrow.up")
case .menu:
return Image(systemName: "ellipsis")
} }
} }
@ -77,6 +81,8 @@ struct StatusRowActionsView: View {
: "status.action.bookmark" : "status.action.bookmark"
case .share: case .share:
return "status.action.share" return "status.action.share"
case .menu:
return "status.context.menu"
} }
} }
@ -91,14 +97,14 @@ struct StatusRowActionsView: View {
return dataController.favoritesCount return dataController.favoritesCount
case .boost: case .boost:
return dataController.reblogsCount return dataController.reblogsCount
case .share, .bookmark: case .share, .bookmark, .menu:
return nil return nil
} }
} }
func tintColor(theme: Theme) -> Color? { func tintColor(theme: Theme) -> Color? {
switch self { switch self {
case .respond, .share: case .respond, .share, .menu:
nil nil
case .favorite: case .favorite:
.yellow .yellow
@ -111,7 +117,7 @@ struct StatusRowActionsView: View {
func isOn(dataController: StatusDataController) -> Bool { func isOn(dataController: StatusDataController) -> Bool {
switch self { switch self {
case .respond, .share: false case .respond, .share, .menu: false
case .favorite: dataController.isFavorited case .favorite: dataController.isFavorited
case .bookmark: dataController.isBookmarked case .bookmark: dataController.isBookmarked
case .boost: dataController.isReblogged case .boost: dataController.isReblogged
@ -147,6 +153,22 @@ struct StatusRowActionsView: View {
.accessibilityLabel("status.action.share-link") .accessibilityLabel("status.action.share-link")
} }
} }
Spacer()
} else if action == .menu {
Menu {
StatusRowContextMenu(viewModel: viewModel, showTextForSelection: $showTextForSelection)
.onAppear {
Task {
await viewModel.loadAuthorRelationship()
}
}
} label: {
Label("", systemImage: "ellipsis")
}
.menuStyle(.button)
.foregroundStyle(.secondary)
.contentShape(Rectangle())
.accessibilityLabel("status.action.context-menu")
} else { } else {
actionButton(action: action) actionButton(action: action)
Spacer() Spacer()
@ -154,6 +176,10 @@ struct StatusRowActionsView: View {
} }
} }
} }
.sheet(isPresented: $showTextForSelection) {
let content = viewModel.status.reblog?.content.asSafeMarkdownAttributedString ?? viewModel.status.content.asSafeMarkdownAttributedString
SelectTextView(content: content)
}
} }
private func actionButton(action: Action) -> some View { private func actionButton(action: Action) -> some View {

View File

@ -20,7 +20,9 @@ struct StatusRowContentView: View {
if !viewModel.displaySpoiler { if !viewModel.displaySpoiler {
StatusRowTextView(viewModel: viewModel) StatusRowTextView(viewModel: viewModel)
if reasons.isEmpty {
StatusRowTranslateView(viewModel: viewModel) StatusRowTranslateView(viewModel: viewModel)
}
if let poll = viewModel.finalStatus.poll { if let poll = viewModel.finalStatus.poll {
StatusPollView(poll: poll, status: viewModel.finalStatus) StatusPollView(poll: poll, status: viewModel.finalStatus)
} }

View File

@ -2,19 +2,19 @@ import DesignSystem
import Env import Env
import Models import Models
import SwiftUI import SwiftUI
import Network
@MainActor @MainActor
struct StatusRowHeaderView: View { struct StatusRowHeaderView: View {
@Environment(\.isInCaptureMode) private var isInCaptureMode: Bool @Environment(\.isInCaptureMode) private var isInCaptureMode: Bool
@Environment(\.isStatusFocused) private var isFocused @Environment(\.isStatusFocused) private var isFocused
@Environment(\.redactionReasons) private var redactionReasons
@Environment(Theme.self) private var theme @Environment(Theme.self) private var theme
let viewModel: StatusRowViewModel let viewModel: StatusRowViewModel
@State private var showTextForSelection: Bool = false
var body: some View { var body: some View {
HStack(alignment: .center) { HStack(alignment: theme.avatarPosition == .top ? .center : .top) {
Button { Button {
viewModel.navigateToAccountDetail(account: viewModel.finalStatus.account) viewModel.navigateToAccountDetail(account: viewModel.finalStatus.account)
} label: { } label: {
@ -24,23 +24,13 @@ struct StatusRowHeaderView: View {
Spacer() Spacer()
if !isInCaptureMode { if !isInCaptureMode {
threadIcon threadIcon
contextMenuButton
} }
} }
.sheet(isPresented: $showTextForSelection) {
let content = viewModel.status.reblog?.content.asSafeMarkdownAttributedString ?? viewModel.status.content.asSafeMarkdownAttributedString
SelectTextView(content: content)
}
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
.accessibilityLabel(Text("\(viewModel.finalStatus.account.safeDisplayName)") + Text(", ") + Text(viewModel.finalStatus.createdAt.relativeFormatted)) .accessibilityLabel(Text("\(viewModel.finalStatus.account.safeDisplayName)") + Text(", ") + Text(viewModel.finalStatus.createdAt.relativeFormatted))
.accessibilityAction { .accessibilityAction {
viewModel.navigateToAccountDetail(account: viewModel.finalStatus.account) viewModel.navigateToAccountDetail(account: viewModel.finalStatus.account)
} }
.accessibilityActions {
if isFocused {
StatusRowContextMenu(viewModel: viewModel, showTextForSelection: $showTextForSelection)
}
}
} }
@ViewBuilder @ViewBuilder
@ -63,15 +53,15 @@ struct StatusRowHeaderView: View {
.lineLimit(1) .lineLimit(1)
.accountPopover(viewModel.finalStatus.account) .accountPopover(viewModel.finalStatus.account)
if redactionReasons.isEmpty {
accountBadgeView accountBadgeView
.font(.footnote) .font(.footnote)
} }
}
.layoutPriority(1) .layoutPriority(1)
if redactionReasons.isEmpty {
if theme.avatarPosition == .leading { if theme.avatarPosition == .leading {
dateView dateView
.font(.scaledFootnote)
.foregroundStyle(.secondary)
.lineLimit(1)
} else { } else {
Text("@\(theme.displayFullUsername ? viewModel.finalStatus.account.acct : viewModel.finalStatus.account.username)") Text("@\(theme.displayFullUsername ? viewModel.finalStatus.account.acct : viewModel.finalStatus.account.username)")
.font(.scaledFootnote) .font(.scaledFootnote)
@ -80,11 +70,9 @@ struct StatusRowHeaderView: View {
.accountPopover(viewModel.finalStatus.account) .accountPopover(viewModel.finalStatus.account)
} }
} }
}
if theme.avatarPosition == .top { if theme.avatarPosition == .top {
dateView dateView
.font(.scaledFootnote)
.foregroundStyle(.secondary)
.lineLimit(1)
} else if theme.displayFullUsername, theme.avatarPosition == .leading { } else if theme.displayFullUsername, theme.avatarPosition == .leading {
Text("@\(viewModel.finalStatus.account.acct)") Text("@\(viewModel.finalStatus.account.acct)")
.font(.scaledFootnote) .font(.scaledFootnote)
@ -106,11 +94,16 @@ struct StatusRowHeaderView: View {
return Text("") return Text("")
} }
private var dateView: Text { private var dateView: some View {
Group {
Text(viewModel.finalStatus.createdAt.relativeFormatted) + Text(viewModel.finalStatus.createdAt.relativeFormatted) +
Text("") + Text("") +
Text(Image(systemName: viewModel.finalStatus.visibility.iconName)) Text(Image(systemName: viewModel.finalStatus.visibility.iconName))
} }
.font(.scaledFootnote)
.foregroundStyle(.secondary)
.lineLimit(1)
}
@ViewBuilder @ViewBuilder
private var threadIcon: some View { private var threadIcon: some View {
@ -122,22 +115,4 @@ struct StatusRowHeaderView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
private var contextMenuButton: some View {
Menu {
StatusRowContextMenu(viewModel: viewModel, showTextForSelection: $showTextForSelection)
.onAppear {
Task {
await viewModel.loadAuthorRelationship()
}
}
} label: {
Image(systemName: "ellipsis")
.frame(width: 40, height: 40)
}
.menuStyle(.borderlessButton)
.foregroundStyle(.secondary)
.contentShape(Rectangle())
.accessibilityHidden(true)
}
} }

View File

@ -149,6 +149,8 @@ private struct MediaPreview: View {
image image
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.frame(width: displayData.isLandscape ? imageMaxHeight * 1.2 : imageMaxHeight / 1.5,
height: imageMaxHeight)
.overlay( .overlay(
RoundedRectangle(cornerRadius: 4) RoundedRectangle(cornerRadius: 4)
.stroke(.gray.opacity(0.35), lineWidth: 1) .stroke(.gray.opacity(0.35), lineWidth: 1)