New default timeline layout
This commit is contained in:
parent
2e1652ef53
commit
1a3bded101
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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? {
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue