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 {
theme.followSystemColorScheme = true
theme.applySet(set: colorScheme == .dark ? .iceCubeDark : .iceCubeLight)
theme.avatarShape = .rounded
theme.avatarPosition = .top
theme.avatarShape = .circle
theme.avatarPosition = .leading
theme.statusActionsDisplay = .full
theme.displayFullUsername = false
theme.statusDisplayStyle = .large
theme.lineSpacing = 1.2
theme.fontSizeScale = 1.0
localValues.tintColor = theme.tintColor
localValues.primaryBackgroundColor = theme.primaryBackgroundColor

View File

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

View File

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

View File

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

View File

@ -38,7 +38,10 @@ public struct ConsolidatedNotification: Identifiable {
}
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] {
[.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()]
[.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(),
.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()]
}
public var reblogAsAsStatus: Status? {

View File

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

View File

@ -48,7 +48,7 @@ public struct StatusRowView: View {
Spacer(minLength: 8)
}
}
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: .statusComponentSpacing) {
if viewModel.isFiltered, let filter = viewModel.filter {
switch filter.filter.filterAction {
case .warn:
@ -74,7 +74,7 @@ public struct StatusRowView: View {
AvatarView(viewModel.finalStatus.account.avatar)
}
}
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: .statusComponentSpacing) {
if !isCompact, theme.avatarPosition == .top {
StatusRowReblogView(viewModel: viewModel)
StatusRowReplyView(viewModel: viewModel)
@ -82,37 +82,31 @@ public struct StatusRowView: View {
StatusRowTagView(viewModel: viewModel)
}
}
VStack(alignment: .leading, spacing: 8) {
if !isCompact {
StatusRowHeaderView(viewModel: viewModel)
}
StatusRowContentView(viewModel: viewModel)
.contentShape(Rectangle())
.onTapGesture {
guard !isFocused else { return }
viewModel.navigateToDetail()
}
.accessibilityActions {
if isFocused, viewModel.showActions {
accessibilityActions
}
}
if !isCompact {
StatusRowHeaderView(viewModel: viewModel)
}
VStack(alignment: .leading, spacing: 12) {
if viewModel.showActions, isFocused || theme.statusActionsDisplay != .none, !isInCaptureMode {
StatusRowActionsView(viewModel: viewModel)
.padding(.top, 8)
.tint(isFocused ? theme.tintColor : .gray)
.contentShape(Rectangle())
.onTapGesture {
guard !isFocused else { return }
viewModel.navigateToDetail()
}
StatusRowContentView(viewModel: viewModel)
.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 {
StatusRowDetailView(viewModel: viewModel)
}
if isFocused, !isCompact {
StatusRowDetailView(viewModel: viewModel)
}
}
}

View File

@ -13,6 +13,8 @@ struct StatusRowActionsView: View {
@Environment(\.openWindow) private var openWindow
@Environment(\.isStatusFocused) private var isFocused
@State private var showTextForSelection: Bool = false
var viewModel: StatusRowViewModel
@ -22,7 +24,7 @@ struct StatusRowActionsView: View {
@MainActor
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
// inserting `nonisolated`, which leads to a warning:
@ -31,7 +33,7 @@ struct StatusRowActionsView: View {
// satisfy nonisolated protocol requirement
//
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 {
@ -53,6 +55,8 @@ struct StatusRowActionsView: View {
return Image(systemName: dataController.isBookmarked ? "bookmark.fill" : "bookmark")
case .share:
return Image(systemName: "square.and.arrow.up")
case .menu:
return Image(systemName: "ellipsis")
}
}
@ -77,6 +81,8 @@ struct StatusRowActionsView: View {
: "status.action.bookmark"
case .share:
return "status.action.share"
case .menu:
return "status.context.menu"
}
}
@ -91,14 +97,14 @@ struct StatusRowActionsView: View {
return dataController.favoritesCount
case .boost:
return dataController.reblogsCount
case .share, .bookmark:
case .share, .bookmark, .menu:
return nil
}
}
func tintColor(theme: Theme) -> Color? {
switch self {
case .respond, .share:
case .respond, .share, .menu:
nil
case .favorite:
.yellow
@ -111,7 +117,7 @@ struct StatusRowActionsView: View {
func isOn(dataController: StatusDataController) -> Bool {
switch self {
case .respond, .share: false
case .respond, .share, .menu: false
case .favorite: dataController.isFavorited
case .bookmark: dataController.isBookmarked
case .boost: dataController.isReblogged
@ -147,6 +153,22 @@ struct StatusRowActionsView: View {
.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 {
actionButton(action: action)
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 {

View File

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

View File

@ -2,19 +2,19 @@ import DesignSystem
import Env
import Models
import SwiftUI
import Network
@MainActor
struct StatusRowHeaderView: View {
@Environment(\.isInCaptureMode) private var isInCaptureMode: Bool
@Environment(\.isStatusFocused) private var isFocused
@Environment(\.redactionReasons) private var redactionReasons
@Environment(Theme.self) private var theme
let viewModel: StatusRowViewModel
@State private var showTextForSelection: Bool = false
var body: some View {
HStack(alignment: .center) {
HStack(alignment: theme.avatarPosition == .top ? .center : .top) {
Button {
viewModel.navigateToAccountDetail(account: viewModel.finalStatus.account)
} label: {
@ -24,23 +24,13 @@ struct StatusRowHeaderView: View {
Spacer()
if !isInCaptureMode {
threadIcon
contextMenuButton
}
}
.sheet(isPresented: $showTextForSelection) {
let content = viewModel.status.reblog?.content.asSafeMarkdownAttributedString ?? viewModel.status.content.asSafeMarkdownAttributedString
SelectTextView(content: content)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(Text("\(viewModel.finalStatus.account.safeDisplayName)") + Text(", ") + Text(viewModel.finalStatus.createdAt.relativeFormatted))
.accessibilityAction {
viewModel.navigateToAccountDetail(account: viewModel.finalStatus.account)
}
.accessibilityActions {
if isFocused {
StatusRowContextMenu(viewModel: viewModel, showTextForSelection: $showTextForSelection)
}
}
}
@ViewBuilder
@ -63,28 +53,26 @@ struct StatusRowHeaderView: View {
.lineLimit(1)
.accountPopover(viewModel.finalStatus.account)
accountBadgeView
.font(.footnote)
if redactionReasons.isEmpty {
accountBadgeView
.font(.footnote)
}
}
.layoutPriority(1)
if theme.avatarPosition == .leading {
dateView
.font(.scaledFootnote)
.foregroundStyle(.secondary)
.lineLimit(1)
} else {
Text("@\(theme.displayFullUsername ? viewModel.finalStatus.account.acct : viewModel.finalStatus.account.username)")
.font(.scaledFootnote)
.foregroundStyle(.secondary)
.lineLimit(1)
.accountPopover(viewModel.finalStatus.account)
if redactionReasons.isEmpty {
if theme.avatarPosition == .leading {
dateView
} else {
Text("@\(theme.displayFullUsername ? viewModel.finalStatus.account.acct : viewModel.finalStatus.account.username)")
.font(.scaledFootnote)
.foregroundStyle(.secondary)
.lineLimit(1)
.accountPopover(viewModel.finalStatus.account)
}
}
}
if theme.avatarPosition == .top {
dateView
.font(.scaledFootnote)
.foregroundStyle(.secondary)
.lineLimit(1)
} else if theme.displayFullUsername, theme.avatarPosition == .leading {
Text("@\(viewModel.finalStatus.account.acct)")
.font(.scaledFootnote)
@ -106,10 +94,15 @@ struct StatusRowHeaderView: View {
return Text("")
}
private var dateView: Text {
Text(viewModel.finalStatus.createdAt.relativeFormatted) +
Text("") +
Text(Image(systemName: viewModel.finalStatus.visibility.iconName))
private var dateView: some View {
Group {
Text(viewModel.finalStatus.createdAt.relativeFormatted) +
Text("") +
Text(Image(systemName: viewModel.finalStatus.visibility.iconName))
}
.font(.scaledFootnote)
.foregroundStyle(.secondary)
.lineLimit(1)
}
@ViewBuilder
@ -122,22 +115,4 @@ struct StatusRowHeaderView: View {
.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
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: displayData.isLandscape ? imageMaxHeight * 1.2 : imageMaxHeight / 1.5,
height: imageMaxHeight)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(.gray.opacity(0.35), lineWidth: 1)