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,44 +94,50 @@ 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)
} }
HStack(spacing: 0) { if reasons.isEmpty {
EmojiTextApp(.init(stringValue: notification.accounts[0].safeDisplayName), HStack(spacing: 0) {
emojis: notification.accounts[0].emojis, EmojiTextApp(.init(stringValue: notification.accounts[0].safeDisplayName),
append: { emojis: notification.accounts[0].emojis,
(notification.accounts.count > 1 append: {
? Text("notifications-others-count \(notification.accounts.count - 1)") (notification.accounts.count > 1
.font(.scaledSubheadline) ? Text("notifications-others-count \(notification.accounts.count - 1)")
.fontWeight(.regular) .font(.scaledSubheadline)
: Text(" ")) + .fontWeight(.regular)
Text(type.label(count: notification.accounts.count)) : Text(" ")) +
.font(.scaledSubheadline) Text(type.label(count: notification.accounts.count))
.fontWeight(.regular) + .font(.scaledSubheadline)
Text("") .fontWeight(.regular) +
.font(.scaledFootnote) Text("")
.fontWeight(.regular) .font(.scaledFootnote)
.foregroundStyle(.secondary) + .fontWeight(.regular)
Text(notification.createdAt.relativeFormatted) .foregroundStyle(.secondary) +
.font(.scaledFootnote) Text(notification.createdAt.relativeFormatted)
.fontWeight(.regular) .font(.scaledFootnote)
.foregroundStyle(.secondary) .fontWeight(.regular)
}) .foregroundStyle(.secondary)
.font(.scaledSubheadline) })
.emojiSize(Font.scaledSubheadlineFont.emojiSize) .font(.scaledSubheadline)
.emojiBaselineOffset(Font.scaledSubheadlineFont.emojiBaselineOffset) .emojiSize(Font.scaledSubheadlineFont.emojiSize)
.fontWeight(.semibold) .emojiBaselineOffset(Font.scaledSubheadlineFont.emojiBaselineOffset)
.lineLimit(3) .fontWeight(.semibold)
.fixedSize(horizontal: false, vertical: true) .lineLimit(3)
if let status = notification.status, notification.type == .mention { .fixedSize(horizontal: false, vertical: true)
Group { if let status = notification.status, notification.type == .mention {
Text("") Group {
Text(Image(systemName: status.visibility.iconName)) Text("")
Text(Image(systemName: status.visibility.iconName))
}
.accessibilityHidden(true)
.font(.scaledFootnote)
.fontWeight(.regular)
.foregroundStyle(.secondary)
} }
.accessibilityHidden(true) Spacer()
.font(.scaledFootnote)
.fontWeight(.regular)
.foregroundStyle(.secondary)
} }
Spacer() } else {
Text(" ")
.font(.scaledSubheadline)
.fontWeight(.semibold)
} }
} }
.contentShape(Rectangle()) .contentShape(Rectangle())

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,37 +82,31 @@ 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)
}
StatusRowContentView(viewModel: viewModel)
.contentShape(Rectangle())
.onTapGesture {
guard !isFocused else { return }
viewModel.navigateToDetail()
}
.accessibilityActions {
if isFocused, viewModel.showActions {
accessibilityActions
}
}
} }
VStack(alignment: .leading, spacing: 12) { StatusRowContentView(viewModel: viewModel)
if viewModel.showActions, isFocused || theme.statusActionsDisplay != .none, !isInCaptureMode { .contentShape(Rectangle())
StatusRowActionsView(viewModel: viewModel) .onTapGesture {
.padding(.top, 8) guard !isFocused else { return }
.tint(isFocused ? theme.tintColor : .gray) viewModel.navigateToDetail()
.contentShape(Rectangle())
.onTapGesture {
guard !isFocused else { return }
viewModel.navigateToDetail()
}
} }
.accessibilityActions {
if isFocused, viewModel.showActions {
accessibilityActions
}
}
if reasons.isEmpty,
viewModel.showActions, isFocused || theme.statusActionsDisplay != .none,
!isInCaptureMode {
StatusRowActionsView(viewModel: viewModel)
.tint(isFocused ? theme.tintColor : .gray)
.contentShape(Rectangle())
.padding(.top, 6)
}
if isFocused, !isCompact { if isFocused, !isCompact {
StatusRowDetailView(viewModel: viewModel) StatusRowDetailView(viewModel: viewModel)
}
} }
} }
} }

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)
StatusRowTranslateView(viewModel: viewModel) if reasons.isEmpty {
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,28 +53,26 @@ struct StatusRowHeaderView: View {
.lineLimit(1) .lineLimit(1)
.accountPopover(viewModel.finalStatus.account) .accountPopover(viewModel.finalStatus.account)
accountBadgeView if redactionReasons.isEmpty {
.font(.footnote) accountBadgeView
.font(.footnote)
}
} }
.layoutPriority(1) .layoutPriority(1)
if theme.avatarPosition == .leading { if redactionReasons.isEmpty {
dateView if theme.avatarPosition == .leading {
.font(.scaledFootnote) dateView
.foregroundStyle(.secondary) } else {
.lineLimit(1) Text("@\(theme.displayFullUsername ? viewModel.finalStatus.account.acct : viewModel.finalStatus.account.username)")
} else { .font(.scaledFootnote)
Text("@\(theme.displayFullUsername ? viewModel.finalStatus.account.acct : viewModel.finalStatus.account.username)") .foregroundStyle(.secondary)
.font(.scaledFootnote) .lineLimit(1)
.foregroundStyle(.secondary) .accountPopover(viewModel.finalStatus.account)
.lineLimit(1) }
.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,10 +94,15 @@ struct StatusRowHeaderView: View {
return Text("") return Text("")
} }
private var dateView: Text { private var dateView: some View {
Text(viewModel.finalStatus.createdAt.relativeFormatted) + Group {
Text("") + Text(viewModel.finalStatus.createdAt.relativeFormatted) +
Text(Image(systemName: viewModel.finalStatus.visibility.iconName)) Text("") +
Text(Image(systemName: viewModel.finalStatus.visibility.iconName))
}
.font(.scaledFootnote)
.foregroundStyle(.secondary)
.lineLimit(1)
} }
@ViewBuilder @ViewBuilder
@ -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)