diff --git a/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift b/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift new file mode 100644 index 0000000000..d8382bddd1 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift @@ -0,0 +1,333 @@ +// +// MediaLayoutHelper.swift +// +// +// Created by Grishka on 25.03.2023. +// + +import Foundation +import MastodonSDK +import CoreDataStack + +public struct MediaLayoutResult { + let width: Int + let height: Int + let columnSizes: [Int] + let rowSizes: [Int] + let tiles: [Tile] + + public struct Tile { + var colSpan: Int + let rowSpan: Int + var startCol: Int + let startRow: Int + var width: Int = 0 + } +} + +class MediaLayoutHelper { + static let maxWidth: CGFloat = 1000 + static let maxHeight: CGFloat = 1777 + static let minHeight: CGFloat = 563 + static let gap: CGFloat = 1.5 + static let maxRatio = maxWidth / maxHeight + + public static func generateMediaLayout(attachments: [MastodonAttachment]) -> MediaLayoutResult? { + if attachments.count < 2 { + return nil + } + + var ratios: [CGFloat] = [] + var allAreWide = true + var allAreSquare = true + for att in attachments { + let ratio: CGFloat = max(0.45, CGFloat(att.size.width / att.size.height)) + if ratio <= 1.2 { + allAreWide = false + if ratio < 0.8 { + allAreSquare = false + } + } else { + allAreSquare = false + } + ratios.append(ratio) + } + + let avgRatio: CGFloat = ratios.reduce(0.0, +) / CGFloat(ratios.count) + + switch attachments.count { + case 2: + if allAreWide && avgRatio > 1.4 * maxRatio && abs(ratios[1] - ratios[0]) < 0.2 { + // Two wide attachments, one above the other + let h = Int(max(min(maxWidth / ratios[0], min(maxWidth / ratios[1], (maxHeight - gap) / 2.0)), minHeight / 2.0).rounded()) + + return MediaLayoutResult(width: Int(maxWidth), + height: Int((CGFloat(h) * 2.0 + gap).rounded()), + columnSizes: [Int(maxWidth)], + rowSizes: [h, h], + tiles: [ + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 1) + ]) + } else if allAreWide { + // two wide photos, one above the other, different ratios + var h0 = maxWidth / ratios[0] + var h1 = maxWidth / ratios[1] + if h0 + h1 < minHeight { + let prevTotalHeight = h0 + h1 + h0 = minHeight * (h0 / prevTotalHeight) + h1 = minHeight * (h1 / prevTotalHeight) + } + let h0Int = Int(h0.rounded()) + let h1Int = Int(h1.rounded()) + return MediaLayoutResult(width: Int(maxWidth), + height: h0Int + h1Int + Int(gap), + columnSizes: [Int(maxWidth)], + rowSizes: [h0Int, h1Int], + tiles: [ + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 1) + ]) + } else if allAreSquare { + // Next to each other, same ratio + let w: CGFloat = (maxWidth - gap) / 2.0 + let h: CGFloat = max(min(w / ratios[0], min(w / ratios[1], maxHeight)), minHeight) + + let wInt: Int = Int(w.rounded()) + let hInt: Int = Int(h.rounded()) + + return MediaLayoutResult(width: Int(maxWidth), + height: hInt, + columnSizes: [wInt, Int(maxWidth) - wInt], + rowSizes: [hInt], + tiles: [ + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 0) + ]) + } else { + // Next to each other, different ratios + let w0: CGFloat = ((maxWidth - gap) / ratios[1] / (1.0 / ratios[0] + 1.0 / ratios[1])) + let w1: CGFloat = maxWidth - w0 - gap + let h: CGFloat = max(min(maxHeight, min(w0 / ratios[0], w1 / ratios[1])), minHeight) + + let w0Int = Int(w0.rounded()) + let w1Int = Int(w1.rounded()) + let hInt = Int(h.rounded()) + + return MediaLayoutResult(width: Int((w0 + w1 + gap).rounded()), + height: hInt, + columnSizes: [w0Int, w1Int], + rowSizes: [hInt], + tiles: [ + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 0) + ]) + } + case 3: + if ratios[0] > 1.2 * maxRatio || avgRatio > 1.5 * maxRatio || allAreWide { + // One above two smaller ones + var hCover: CGFloat = min(maxWidth / ratios[0], (maxHeight - gap) * 0.66) + let w2: CGFloat = (maxWidth - gap) / 2.0 + var h: CGFloat = min(maxHeight - hCover - gap, min(w2 / ratios[1], w2 / ratios[2])) + if hCover + h < minHeight { + let prevTotalHeight = hCover + h + hCover = minHeight * (hCover / prevTotalHeight) + h = minHeight * (h / prevTotalHeight) + } + + return MediaLayoutResult(width: Int(maxWidth), + height: Int((hCover + h + gap).rounded()), + columnSizes: [Int(w2.rounded()), Int(maxWidth - w2.rounded())], + rowSizes: [Int(hCover.rounded()), Int(h.rounded())], + tiles: [ + MediaLayoutResult.Tile(colSpan: 2, rowSpan: 1, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 1), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 1) + ]) + } else { + // One on the left, two smaller ones on the right + let height: CGFloat = min(maxHeight, maxWidth * 0.66 / avgRatio) + let wCover: CGFloat = min(height * ratios[0], (maxWidth - gap) * 0.66) + let h1: CGFloat = ratios[1] * (height - gap) / (ratios[2] + ratios[1]) + let h0: CGFloat = height - h1 - gap + let w: CGFloat = min(maxWidth - wCover - gap, h1 * ratios[2], h0 * ratios[1]) + + return MediaLayoutResult(width: Int((wCover + w + gap).rounded()), + height: Int(height.rounded()), + columnSizes: [Int(wCover.rounded()), Int(w.rounded())], + rowSizes: [Int(h0.rounded()), Int(h1.rounded())], + tiles: [ + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 2, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 1) + ]) + } + case 4: + if ratios[0] > 1.2 * maxRatio || avgRatio > 1.5 * maxRatio || allAreWide { + // One above three smaller ones + var hCover: CGFloat = min(maxWidth / ratios[0], (maxHeight - gap) * 0.66) + var h: CGFloat = (maxWidth - 2.0 * gap) / (ratios[1] + ratios[2] + ratios[3]) + let w0: CGFloat = h * ratios[1] + let w1: CGFloat = h * ratios[2] + h = min(maxHeight - hCover - gap, h) + if hCover + h < minHeight { + let prevTotalHeight = hCover + h + hCover = minHeight * (hCover / prevTotalHeight) + h = minHeight * (h / prevTotalHeight) + } + + return MediaLayoutResult(width: Int(maxWidth), + height: Int((hCover + h + gap).rounded()), + columnSizes: [Int(w0.rounded()), Int(w1.rounded()), Int(maxWidth - w0.rounded() - w1.rounded())], + rowSizes: [Int(hCover.rounded()), Int(h.rounded())], + tiles: [ + MediaLayoutResult.Tile(colSpan: 3, rowSpan: 1, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 1), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 1), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 2, startRow: 1) + ]) + } else { + // One on the left, three smaller ones on the right + let height: CGFloat = min(maxHeight, maxWidth * 0.66 / avgRatio) + let wCover: CGFloat = min(height * ratios[0], (maxWidth - gap) * 0.66) + var w: CGFloat = (height - 2.0 * gap) / (1.0 / ratios[1] + 1.0 / ratios[2] + 1.0 / ratios[3]) + let h0: CGFloat = w / ratios[1] + let h1: CGFloat = w / ratios[2] + let h2: CGFloat = w / ratios[3] + gap + w = min(maxWidth - wCover - gap, w) + + return MediaLayoutResult(width: Int((wCover + gap + w).rounded()), + height: Int(height.rounded()), + columnSizes: [Int(wCover.rounded()), Int(w.rounded())], + rowSizes: [Int(h0.rounded()), Int(h1.rounded()), Int(h2.rounded())], + tiles: [ + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 3, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 1), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 2) + ]) + } + default: + let cnt = attachments.count + var ratiosCropped: [CGFloat] = [] + if avgRatio > 1.1 { + for ratio in ratios { + ratiosCropped.append(max(1.0, ratio)) + } + } else { + for ratio in ratios { + ratiosCropped.append(min(1.0, ratio)) + } + } + + var tries: [[Int]: [CGFloat]] = [:] + + // One line + tries[[attachments.count]] = [calculateMultiThumbsHeight(ratios: ratiosCropped, width: maxWidth, margin: gap)] + + // Two lines + for firstLine in 1...cnt - 1 { + tries[[firstLine, cnt - firstLine]] = [ + calculateMultiThumbsHeight(ratios: Array(ratiosCropped[.. 1 && (conf[0] > conf[1] || (conf.count > 2 && conf[1] > conf[2])) { + confDiff *= 1.1 + } + if confDiff < optDiff { + optConf = conf + optDiff = confDiff + } + } + + var thumbsRemain: [MastodonAttachment] = Array(attachments) + var ratiosRemain: [CGFloat] = Array(ratiosCropped) + let optHeights = tries[optConf]! + var totalHeight: CGFloat = 0.0 + var rowSizes: [Int] = [] + var gridLineOffsets: [Int] = [] + var rowTiles: [[MediaLayoutResult.Tile]] = [] + + for (i, lineChunksNum) in optConf.enumerated() { + var lineThumbs: [MastodonAttachment] = [] + for _ in 0.. CGFloat { + return (width - (CGFloat(ratios.count) - 1.0) * margin) / ratios.reduce(0.0, +) + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift index b28f63942c..c3baabb32a 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift @@ -18,10 +18,10 @@ public protocol MediaGridContainerViewDelegate: AnyObject { public final class MediaGridContainerView: UIView { static let sensitiveToggleButtonSize = CGSize(width: 34, height: 34) - public static let maxCount = 9 - + public static let maxCount = 10 + let logger = Logger(subsystem: "MediaGridContainerView", category: "UI") - + public weak var delegate: MediaGridContainerViewDelegate? public private(set) lazy var viewModel: ViewModel = { let viewModel = ViewModel() @@ -66,7 +66,7 @@ public final class MediaGridContainerView: UIView { } set { } } - + } extension MediaGridContainerView { @@ -183,11 +183,12 @@ extension MediaGridContainerView { let count: Int let maxSize: CGSize + let layout: MediaLayoutResult - init(count: Int, maxSize: CGSize) { - self.count = min(count, 9) + init(count: Int, maxSize: CGSize, layout: MediaLayoutResult) { + self.count = min(count, 10) self.maxSize = maxSize - + self.layout = layout } private func createStackView(axis: NSLayoutConstraint.Axis) -> UIStackView { @@ -200,70 +201,22 @@ extension MediaGridContainerView { } public func layout(in view: UIView, mediaViews: [MediaView]) { - let containerVerticalStackView = createStackView(axis: .vertical) - containerVerticalStackView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(containerVerticalStackView) - containerVerticalStackView.pinToParent() - let count = mediaViews.count - switch count { - case 1: - assertionFailure("should use Adaptive Layout") - containerVerticalStackView.addArrangedSubview(mediaViews[0]) - case 2: - let horizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(horizontalStackView) - horizontalStackView.addArrangedSubview(mediaViews[0]) - horizontalStackView.addArrangedSubview(mediaViews[1]) - case 3: - let horizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(horizontalStackView) - horizontalStackView.addArrangedSubview(mediaViews[0]) - - let verticalStackView = createStackView(axis: .vertical) - horizontalStackView.addArrangedSubview(verticalStackView) - verticalStackView.addArrangedSubview(mediaViews[1]) - verticalStackView.addArrangedSubview(mediaViews[2]) - case 4: - let topHorizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(topHorizontalStackView) - topHorizontalStackView.addArrangedSubview(mediaViews[0]) - topHorizontalStackView.addArrangedSubview(mediaViews[1]) - - let bottomHorizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(bottomHorizontalStackView) - bottomHorizontalStackView.addArrangedSubview(mediaViews[2]) - bottomHorizontalStackView.addArrangedSubview(mediaViews[3]) - case 5...9: - let topHorizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(topHorizontalStackView) - topHorizontalStackView.addArrangedSubview(mediaViews[0]) - topHorizontalStackView.addArrangedSubview(mediaViews[1]) - topHorizontalStackView.addArrangedSubview(mediaViews[2]) - - func mediaViewOrPlaceholderView(at index: Int) -> UIView { - return index < mediaViews.count ? mediaViews[index] : UIView() - } - let middleHorizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(middleHorizontalStackView) - middleHorizontalStackView.addArrangedSubview(mediaViews[3]) - middleHorizontalStackView.addArrangedSubview(mediaViews[4]) - middleHorizontalStackView.addArrangedSubview(mediaViewOrPlaceholderView(at: 5)) - - if count > 6 { - let bottomHorizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(bottomHorizontalStackView) - bottomHorizontalStackView.addArrangedSubview(mediaViewOrPlaceholderView(at: 6)) - bottomHorizontalStackView.addArrangedSubview(mediaViewOrPlaceholderView(at: 7)) - bottomHorizontalStackView.addArrangedSubview(mediaViewOrPlaceholderView(at: 8)) - } - default: - assertionFailure() - return + + precondition(count >= 2 && count <= maxCount, "Unexpected attachment count \(count)") + + let layoutView = GridLayoutView() + layoutView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(layoutView) + layoutView.pinToParent() + for mediaView in mediaViews { + mediaView.translatesAutoresizingMaskIntoConstraints = true + layoutView.addSubview(mediaView) } + layoutView.prepare(layout: layout, maxSize: maxSize) let containerWidth = maxSize.width - let containerHeight = count > 6 ? containerWidth : containerWidth * 2 / 3 + let containerHeight = CGFloat(layoutView.measuredHeight) NSLayoutConstraint.activate([ view.widthAnchor.constraint(equalToConstant: containerWidth).priority(.required - 1), view.heightAnchor.constraint(equalToConstant: containerHeight).priority(.required - 1), @@ -271,3 +224,77 @@ extension MediaGridContainerView { } } } + +class GridLayoutView: UIView { + private var layout: MediaLayoutResult? + private(set) var measuredHeight = 0 + + private static let maxWidth = 400 + private static let gap = 2 + + public func prepare(layout: MediaLayoutResult, maxSize: CGSize) { + self.layout = layout + let width: CGFloat = min(CGFloat(maxSize.width), CGFloat(GridLayoutView.maxWidth)) + let height: CGFloat = (width * CGFloat(layout.height) / MediaLayoutHelper.maxWidth) + measuredHeight = Int(height.rounded()) + } + + override func layoutSubviews() { + super.layoutSubviews() + guard let layout = layout else { return } + var width: Int = min(GridLayoutView.maxWidth, Int(frame.width)) + let height: Int = Int(frame.height) + if layout.width < Int(MediaLayoutHelper.maxWidth) { + width = Int((CGFloat(width) * (CGFloat(layout.width) / MediaLayoutHelper.maxWidth)).rounded()) + } + + var columnStarts: [Int] = [] + var columnEnds: [Int] = [] + var rowStarts: [Int] = [] + var rowEnds: [Int] = [] + var offset: Int = 0 + + for colSize in layout.columnSizes { + columnStarts.append(offset) + offset += Int((CGFloat(colSize) / CGFloat(layout.width) * CGFloat(width)).rounded()) + columnEnds.append(offset) + offset += GridLayoutView.gap + } + columnEnds.append(width) + offset = 0 + for rowSize in layout.rowSizes { + rowStarts.append(offset) + offset += Int((CGFloat(rowSize) / CGFloat(layout.height) * CGFloat(height)).rounded()) + rowEnds.append(offset) + offset += GridLayoutView.gap + } + rowEnds.append(height) + + var xOffset: Int = 0 + if Int(frame.width) > width { + xOffset = Int((CGFloat(frame.width) / 2.0 - CGFloat(width) / 2.0).rounded()) + } + + var i: Int = 0 + for view in subviews { + if let mediaView = view as? MediaView { + if i >= layout.tiles.count { + break + } + let tile = layout.tiles[i] + let colSpan = max(1, tile.colSpan) - 1 + let rowSpan = max(1, tile.rowSpan) - 1 + let x = columnStarts[tile.startCol] + let y = rowStarts[tile.startRow] + mediaView.layer.removeAllAnimations() + mediaView.container.layer.removeAllAnimations() + mediaView.imageView.layer.removeAllAnimations() + mediaView.frame = CGRect(x: x + xOffset, y: y, width: columnEnds[tile.startCol + colSpan] - x, height: rowEnds[tile.startRow + rowSpan] - y) + mediaView.setNeedsLayout() + mediaView.layoutIfNeeded() + i = i + 1 + } + } + } +} + diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift index 03eff8c27d..c04b9b3e6b 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift @@ -389,6 +389,7 @@ extension StatusView { let configurations = MediaView.configuration(status: status) viewModel.mediaViewConfigurations = configurations + viewModel.mediaLayout = MediaLayoutHelper.generateMediaLayout(attachments: status.attachments) } private func configurePollHistory(statusEdit: StatusEdit) { diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index 0bdcb62433..afff961356 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -65,6 +65,7 @@ extension StatusView { // Media @Published public var mediaViewConfigurations: [MediaView.Configuration] = [] + @Published public var mediaLayout: MediaLayoutResult? = nil // Audio @Published public var audioConfigurations: [MediaView.Configuration] = [] @@ -322,7 +323,7 @@ extension StatusView.ViewModel { } let paragraphStyle = statusView.contentMetaText.paragraphStyle - if let language = language { + if let language = language { if #available(iOS 16, *) { let direction = Locale.Language(identifier: language).characterDirection paragraphStyle.alignment = direction == .rightToLeft ? .right : .left @@ -389,45 +390,50 @@ extension StatusView.ViewModel { } private func bindMedia(statusView: StatusView) { - $mediaViewConfigurations - .sink { [weak self] configurations in - guard let self = self else { return } - self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): configure media") - - statusView.mediaGridContainerView.prepareForReuse() - - let maxSize = CGSize( - width: statusView.contentMaxLayoutWidth, - height: 9999 // fulfill the width + Publishers.CombineLatest( + $mediaViewConfigurations, + $mediaLayout + ) + .sink { [weak self] configurations, mediaLayout in + guard let self = self else { return } + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): configure media") + + statusView.mediaGridContainerView.prepareForReuse() + + let maxSize = CGSize( + width: statusView.contentMaxLayoutWidth, + height: 9999 // fulfill the width + ) + var needsDisplay = true + switch configurations.count { + case 0: + needsDisplay = false + case 1: + let configuration = configurations[0] + let adaptiveLayout = MediaGridContainerView.AdaptiveLayout( + aspectRatio: configuration.aspectRadio, + maxSize: maxSize ) - var needsDisplay = true - switch configurations.count { - case 0: - needsDisplay = false - case 1: - let configuration = configurations[0] - let adaptiveLayout = MediaGridContainerView.AdaptiveLayout( - aspectRatio: configuration.aspectRadio, - maxSize: maxSize - ) - let mediaView = statusView.mediaGridContainerView.dequeueMediaView(adaptiveLayout: adaptiveLayout) + let mediaView = statusView.mediaGridContainerView.dequeueMediaView(adaptiveLayout: adaptiveLayout) + mediaView.setup(configuration: configuration) + default: + guard let mediaLayout = mediaLayout else { return } + let gridLayout = MediaGridContainerView.GridLayout( + count: configurations.count, + maxSize: maxSize, + layout: mediaLayout + ) + let mediaViews = statusView.mediaGridContainerView.dequeueMediaView(gridLayout: gridLayout) + for (i, (configuration, mediaView)) in zip(configurations, mediaViews).enumerated() { + guard i < MediaGridContainerView.maxCount else { break } mediaView.setup(configuration: configuration) - default: - let gridLayout = MediaGridContainerView.GridLayout( - count: configurations.count, - maxSize: maxSize - ) - let mediaViews = statusView.mediaGridContainerView.dequeueMediaView(gridLayout: gridLayout) - for (i, (configuration, mediaView)) in zip(configurations, mediaViews).enumerated() { - guard i < MediaGridContainerView.maxCount else { break } - mediaView.setup(configuration: configuration) - } - } - if needsDisplay { - statusView.setMediaDisplay() } } - .store(in: &disposeBag) + if needsDisplay { + statusView.setMediaDisplay() + } + } + .store(in: &disposeBag) Publishers.CombineLatest( $mediaViewConfigurations, @@ -472,32 +478,32 @@ extension StatusView.ViewModel { $voterCount, $voteCount ) - .map { voterCount, voteCount -> String in - var description = "" - if let voterCount = voterCount { - description += L10n.Plural.Count.voter(voterCount) - } else { - description += L10n.Plural.Count.vote(voteCount) + .map { voterCount, voteCount -> String in + var description = "" + if let voterCount = voterCount { + description += L10n.Plural.Count.voter(voterCount) + } else { + description += L10n.Plural.Count.vote(voteCount) + } + return description } - return description - } let pollCountdownDescription = Publishers.CombineLatest3( $expireAt, $expired, timestampUpdatePublisher.prepend(Date()).eraseToAnyPublisher() ) - .map { expireAt, expired, _ -> String? in - guard !expired else { - return L10n.Common.Controls.Status.Poll.closed - } - - guard let expireAt = expireAt else { - return nil + .map { expireAt, expired, _ -> String? in + guard !expired else { + return L10n.Common.Controls.Status.Poll.closed + } + + guard let expireAt = expireAt else { + return nil + } + let timeLeft = expireAt.localizedTimeLeft() + + return timeLeft } - let timeLeft = expireAt.localizedTimeLeft() - - return timeLeft - } Publishers.CombineLatest( pollVoteDescription, pollCountdownDescription @@ -670,49 +676,49 @@ extension StatusView.ViewModel { publishersTwo.eraseToAnyPublisher(), publishersThree.eraseToAnyPublisher() ).eraseToAnyPublisher() - .sink { tupleOne, tupleTwo, tupleThree in - let (authorName, isMyself) = tupleOne - let (isMuting, isBlocking, isBookmark) = tupleTwo - let (translatedFromLanguage, language) = tupleThree - - guard let name = authorName?.string else { - statusView.authorView.menuButton.menu = nil - return - } - - lazy var instanceConfigurationV2: Mastodon.Entity.V2.Instance.Configuration? = { - guard - let context = self.context, - let authContext = self.authContext - else { - return nil - } + .sink { tupleOne, tupleTwo, tupleThree in + let (authorName, isMyself) = tupleOne + let (isMuting, isBlocking, isBookmark) = tupleTwo + let (translatedFromLanguage, language) = tupleThree - var configuration: Mastodon.Entity.V2.Instance.Configuration? = nil - context.managedObjectContext.performAndWait { - guard let authentication = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext) - else { return } - configuration = authentication.instance?.configurationV2 + guard let name = authorName?.string else { + statusView.authorView.menuButton.menu = nil + return } - return configuration - }() - - let menuContext = StatusAuthorView.AuthorMenuContext( - name: name, - isMuting: isMuting, - isBlocking: isBlocking, - isMyself: isMyself, - isBookmarking: isBookmark, - isTranslationEnabled: instanceConfigurationV2?.translation?.enabled == true, - isTranslated: translatedFromLanguage != nil, - statusLanguage: language - ) - let (menu, actions) = authorView.setupAuthorMenu(menuContext: menuContext) - authorView.menuButton.menu = menu - authorView.authorActions = actions - authorView.menuButton.showsMenuAsPrimaryAction = true - } - .store(in: &disposeBag) + + lazy var instanceConfigurationV2: Mastodon.Entity.V2.Instance.Configuration? = { + guard + let context = self.context, + let authContext = self.authContext + else { + return nil + } + + var configuration: Mastodon.Entity.V2.Instance.Configuration? = nil + context.managedObjectContext.performAndWait { + guard let authentication = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext) + else { return } + configuration = authentication.instance?.configurationV2 + } + return configuration + }() + + let menuContext = StatusAuthorView.AuthorMenuContext( + name: name, + isMuting: isMuting, + isBlocking: isBlocking, + isMyself: isMyself, + isBookmarking: isBookmark, + isTranslationEnabled: instanceConfigurationV2?.translation?.enabled == true, + isTranslated: translatedFromLanguage != nil, + statusLanguage: language + ) + let (menu, actions) = authorView.setupAuthorMenu(menuContext: menuContext) + authorView.menuButton.menu = menu + authorView.authorActions = actions + authorView.menuButton.showsMenuAsPrimaryAction = true + } + .store(in: &disposeBag) } private func bindFilter(statusView: StatusView) { @@ -720,7 +726,7 @@ extension StatusView.ViewModel { .sink { isFiltered in statusView.containerStackView.isHidden = isFiltered if isFiltered { - statusView.setFilterHintLabelDisplay() + statusView.setFilterHintLabelDisplay() } } .store(in: &disposeBag) @@ -733,30 +739,30 @@ extension StatusView.ViewModel { $authorUsername, $timestampText ) - .map { header, authorName, authorUsername, timestamp -> String? in - var strings: [String?] = [] - - switch header { - case .none: - strings.append(authorName?.string) - strings.append(authorUsername) - case .reply(let info): - strings.append(authorName?.string) - strings.append(authorUsername) - strings.append(info.header.string) - case .repost(let info): - strings.append(info.header.string) - strings.append(authorName?.string) - strings.append(authorUsername) - } - - if statusView.style != .editHistory { - strings.append(timestamp) + .map { header, authorName, authorUsername, timestamp -> String? in + var strings: [String?] = [] + + switch header { + case .none: + strings.append(authorName?.string) + strings.append(authorUsername) + case .reply(let info): + strings.append(authorName?.string) + strings.append(authorUsername) + strings.append(info.header.string) + case .repost(let info): + strings.append(info.header.string) + strings.append(authorName?.string) + strings.append(authorUsername) + } + + if statusView.style != .editHistory { + strings.append(timestamp) + } + + return strings.compactMap { $0 }.joined(separator: ", ") } - - return strings.compactMap { $0 }.joined(separator: ", ") - } - + let longTimestampFormatter = DateFormatter() longTimestampFormatter.dateStyle = .medium longTimestampFormatter.timeStyle = .short @@ -792,7 +798,7 @@ extension StatusView.ViewModel { } .assign(to: \.accessibilityLabel, on: statusView.authorView) .store(in: &disposeBag) - + Publishers.CombineLatest3( $isContentReveal, $spoilerContent, @@ -831,17 +837,17 @@ extension StatusView.ViewModel { statusView.spoilerOverlayView.accessibilityLabel = contentAccessibilityLabel } .store(in: &disposeBag) - + let mediaAccessibilityLabel = $mediaViewConfigurations .map { configurations -> String? in let count = configurations.count return L10n.Plural.Count.media(count) } - + let replyLabel = $replyCount .map { [L10n.Common.Controls.Actions.reply, L10n.Plural.Count.reply($0)] } .map { $0.joined(separator: ", ") } - + let reblogLabel = Publishers.CombineLatest($isReblog, $reblogCount) .map { isReblog, reblogCount in [ @@ -904,7 +910,7 @@ extension StatusView.ViewModel { } } .store(in: &disposeBag) - + Publishers.CombineLatest4( shortAuthorAccessibilityLabel, $contentAccessibilityLabel,