diff --git a/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryContextViewController.swift b/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryContextViewController.swift index 64e15a7bcd..dce8a25f12 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryContextViewController.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryContextViewController.swift @@ -62,16 +62,15 @@ class StoryContextViewController: OWSViewController { updateProgressState() } else { // If there's an unviewed story, we always want to present that first. - if let firstUnviewedStory = items.first(where: { item in - guard case .incoming(_, let viewedTimestamp) = item.message.manifest else { return false } - return viewedTimestamp == nil + if let firstUnviewedStory = items.first(where: { + $0.message.localUserViewedTimestamp == nil }) { currentItem = firstUnviewedStory } else { switch loadPositionIfRead { - case .newest, .default: + case .newest: currentItem = items.last - case .oldest: + case .oldest, .default: currentItem = items.first } } @@ -82,6 +81,7 @@ class StoryContextViewController: OWSViewController { playbackProgressView.alpha = 1 closeButton.alpha = 1 + replyButton.alpha = 1 } func transitionToNextItem(nextContextLoadPositionIfRead: LoadPosition = .default) { @@ -252,29 +252,34 @@ class StoryContextViewController: OWSViewController { if let currentItem = currentItem { let itemView = StoryItemMediaView(item: currentItem) + itemView.delegate = self self.currentItemMediaView = itemView mediaViewContainer.addSubview(itemView) itemView.autoPinEdgesToSuperviewEdges() - replyButton.isHidden = false + if currentItem.message.localUserAllowedToReply { + replyButton.isHidden = false - let replyButtonText: String - switch currentItem.numberOfReplies { - case 0: - replyButtonText = NSLocalizedString("STORY_REPLY_BUTTON_WITH_NO_REPLIES", comment: "Button for replying to a story with no existing replies.") - case 1: - replyButtonText = NSLocalizedString("STORY_REPLY_BUTTON_WITH_1_REPLY", comment: "Button for replying to a story with a single existing reply.") - default: - let format = NSLocalizedString("STORY_REPLY_BUTTON_WITH_N_REPLIES_FORMAT", comment: "Button for replying to a story with N existing replies. {embeds the number of replies}") - replyButtonText = String(format: format, "\(currentItem.numberOfReplies)") + let replyButtonText: String + switch currentItem.numberOfReplies { + case 0: + replyButtonText = NSLocalizedString("STORY_REPLY_BUTTON_WITH_NO_REPLIES", comment: "Button for replying to a story with no existing replies.") + case 1: + replyButtonText = NSLocalizedString("STORY_REPLY_BUTTON_WITH_1_REPLY", comment: "Button for replying to a story with a single existing reply.") + default: + let format = NSLocalizedString("STORY_REPLY_BUTTON_WITH_N_REPLIES_FORMAT", comment: "Button for replying to a story with N existing replies. {embeds the number of replies}") + replyButtonText = String(format: format, "\(currentItem.numberOfReplies)") + } + + let semiboldStyle = StringStyle(.font(.systemFont(ofSize: 17, weight: .semibold))) + let attributedReplyButtonText = replyButtonText.styled( + with: .font(.systemFont(ofSize: 17, weight: .regular)), + .xmlRules([.style("bold", semiboldStyle)]) + ) + replyButton.setAttributedTitle(attributedReplyButtonText) + } else { + replyButton.isHidden = true } - - let semiboldStyle = StringStyle(.font(.systemFont(ofSize: 17, weight: .semibold))) - let attributedReplyButtonText = replyButtonText.styled( - with: .font(.systemFont(ofSize: 17, weight: .regular)), - .xmlRules([.style("bold", semiboldStyle)]) - ) - replyButton.setAttributedTitle(attributedReplyButtonText) } else { replyButton.isHidden = true } @@ -294,15 +299,17 @@ class StoryContextViewController: OWSViewController { AssertIsOnMainThread() playbackProgressView.numberOfItems = items.count if let currentItemView = currentItemMediaView, let idx = items.firstIndex(of: currentItemView.item) { - // When we present a story, mark it as viewed if it's not already. - if !currentItemView.isDownloading, case .incoming(_, let viewedTimestamp) = currentItemView.item.message.manifest, viewedTimestamp == nil { + // When we present a story, mark it as viewed if it's not already, as long as it's downloaded. + if !currentItemView.isPendingDownload, currentItemView.item.message.localUserViewedTimestamp == nil { databaseStorage.write { transaction in currentItemView.item.message.markAsViewed(at: Date.ows_millisecondTimestamp(), circumstance: .onThisDevice, transaction: transaction) } } currentItemView.updateTimestampText() - if currentItemView.isDownloading { + + if currentItemView.isPendingDownload { + // Don't progress stories that are pending download. lastTransitionTime = CACurrentMediaTime() playbackProgressView.itemState = .init(index: idx, value: 0) } else if let lastTransitionTime = lastTransitionTime { @@ -436,6 +443,7 @@ extension StoryContextViewController: UIGestureRecognizerDelegate { if hideChrome { self.playbackProgressView.alpha = 0 self.closeButton.alpha = 0 + self.replyButton.alpha = 0 } } } @@ -449,21 +457,35 @@ extension StoryContextViewController: UIGestureRecognizerDelegate { currentItemMediaView?.play { self.playbackProgressView.alpha = 1 self.closeButton.alpha = 1 + self.replyButton.alpha = 1 } delegate?.storyContextViewControllerDidResume(self) } func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + let nextFrameWidth = mediaViewContainer.width * 0.8 + let previousFrameWidth = mediaViewContainer.width * 0.2 + + let leftFrameWidth: CGFloat + let rightFrameWidth: CGFloat + if CurrentAppContext().isRTL { + leftFrameWidth = nextFrameWidth + rightFrameWidth = previousFrameWidth + } else { + leftFrameWidth = previousFrameWidth + rightFrameWidth = nextFrameWidth + } + let touchLocation = gestureRecognizer.location(in: view) if gestureRecognizer == leftTapGestureRecognizer { - var previousFrame = mediaViewContainer.frame - previousFrame.width = previousFrame.width / 2 - return previousFrame.contains(touchLocation) + var leftFrame = mediaViewContainer.frame + leftFrame.width = leftFrameWidth + return leftFrame.contains(touchLocation) } else if gestureRecognizer == rightTapGestureRecognizer { - var nextFrame = mediaViewContainer.frame - nextFrame.width = nextFrame.width / 2 - nextFrame.x += nextFrame.width - return nextFrame.contains(touchLocation) + var rightFrame = mediaViewContainer.frame + rightFrame.width = rightFrameWidth + rightFrame.x += leftFrameWidth + return rightFrame.contains(touchLocation) } else { return true } @@ -517,3 +539,13 @@ extension StoryContextViewController: DatabaseChangeDelegate { func databaseChangesDidReset() {} } + +extension StoryContextViewController: StoryItemMediaViewDelegate { + func storyItemMediaViewWantsToPlay(_ storyItemMediaView: StoryItemMediaView) { + play() + } + + func storyItemMediaViewWantsToPause(_ storyItemMediaView: StoryItemMediaView) { + pause() + } +} diff --git a/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryItemMediaView.swift b/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryItemMediaView.swift index 28ec15fdb4..ba29d14329 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryItemMediaView.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryItemMediaView.swift @@ -8,9 +8,32 @@ import YYImage import UIKit import SignalUI import SafariServices +import CoreMedia + +protocol StoryItemMediaViewDelegate: AnyObject { + func storyItemMediaViewWantsToPause(_ storyItemMediaView: StoryItemMediaView) + func storyItemMediaViewWantsToPlay(_ storyItemMediaView: StoryItemMediaView) +} class StoryItemMediaView: UIView { + weak var delegate: StoryItemMediaViewDelegate? let item: StoryItem + + private lazy var gradientProtectionView: UIView = { + let gradientLayer = CAGradientLayer() + gradientLayer.colors = [ + UIColor.black.withAlphaComponent(0).cgColor, + UIColor.black.withAlphaComponent(0.5).cgColor + ] + let view = OWSLayerView(frame: .zero) { view in + gradientLayer.frame = view.bounds + } + view.layer.addSublayer(gradientLayer) + return view + }() + + private let bottomContentVStack = UIStackView() + init(item: StoryItem) { self.item = item @@ -30,6 +53,25 @@ class StoryItemMediaView: UIView { gradientProtectionView.autoPinEdge(toSuperviewEdge: .bottom) gradientProtectionView.autoMatch(.height, to: .height, of: self, withMultiplier: 0.4) + bottomContentVStack.axis = .vertical + bottomContentVStack.spacing = 24 + addSubview(bottomContentVStack) + + bottomContentVStack.autoPinWidthToSuperview(withMargin: OWSTableViewController2.defaultHOuterMargin) + + if UIDevice.current.hasIPhoneXNotch || UIDevice.current.isIPad { + // iPhone with notch or iPad (views/replies rendered below media, media is in a card) + bottomContentVStack.autoPinEdge(toSuperviewEdge: .bottom, withInset: OWSTableViewController2.defaultHOuterMargin + 16) + } else { + // iPhone with home button (views/replies rendered on top of media, media is fullscreen) + bottomContentVStack.autoPinEdge(toSuperviewEdge: .bottom, withInset: 80) + } + + bottomContentVStack.autoPinEdge(toSuperviewEdge: .top, withInset: OWSTableViewController2.defaultHOuterMargin) + + bottomContentVStack.addArrangedSubview(.vStretchingSpacer()) + + createCaptionIfNecessary() createAuthorRow() } @@ -38,19 +80,47 @@ class StoryItemMediaView: UIView { } func reset() { + videoPlayerLoopCount = 0 videoPlayer?.seek(to: .zero) videoPlayer?.play() updateTimestampText() - authorRow.alpha = 1 + bottomContentVStack.alpha = 1 gradientProtectionView.alpha = 1 } + func updateTimestampText() { + timestampLabel.text = DateUtil.formatTimestampRelatively(item.message.timestamp) + } + + func willHandleTapGesture(_ gesture: UITapGestureRecognizer) -> Bool { + if startAttachmentDownloadIfNecessary(gesture) { return true } + if toggleCaptionExpansionIfNecessary(gesture) { return true } + + if let textAttachmentView = mediaView as? TextAttachmentView { + let didHandle = textAttachmentView.willHandleTapGesture(gesture) + if didHandle { + if textAttachmentView.isPresentingLinkTooltip { + // If we presented a link, pause playback + delegate?.storyItemMediaViewWantsToPause(self) + } else { + // If we dismissed a link, resume playback + delegate?.storyItemMediaViewWantsToPlay(self) + } + } + return didHandle + } + + return false + } + + // MARK: - Playback + func pause(hideChrome: Bool = false, animateAlongside: @escaping () -> Void) { videoPlayer?.pause() if hideChrome { UIView.animate(withDuration: 0.15, delay: 0, options: [.beginFromCurrentState, .curveEaseInOut]) { - self.authorRow.alpha = 0 + self.bottomContentVStack.alpha = 0 self.gradientProtectionView.alpha = 0 animateAlongside() } completion: { _ in } @@ -63,7 +133,7 @@ class StoryItemMediaView: UIView { videoPlayer?.play() UIView.animate(withDuration: 0.15, delay: 0, options: [.beginFromCurrentState, .curveEaseInOut]) { - self.authorRow.alpha = 1 + self.bottomContentVStack.alpha = 1 self.gradientProtectionView.alpha = 1 animateAlongside() } completion: { _ in @@ -71,22 +141,64 @@ class StoryItemMediaView: UIView { } } - func updateTimestampText() { - timestampLabel.text = DateUtil.formatTimestampShort(item.message.timestamp) - } + var duration: CFTimeInterval { + switch item.attachment { + case .pointer: + owsFailDebug("Undownloaded attachments should not progress.") + return 0 + case .stream(let stream): + if let asset = videoPlayer?.avPlayer.currentItem?.asset { + let videoDuration = CMTimeGetSeconds(asset.duration) + if stream.isLoopingVideo { + // GIFs should loop 3 times, or play for 5 seconds + // whichever is longer. + return max(5, videoDuration * 3) + } else { + return videoDuration + } + } else { + // Images should play for 5 seconds + return 5 + } + case .text(let attachment): + // As a base, all text attachments play for at least 3s, + // even if they have no text. + var duration: CFTimeInterval = 3 - func willHandleTapGesture(_ gesture: UITapGestureRecognizer) -> Bool { - if startAttachmentDownloadIfNecessary() { return true } + if let text = attachment.text { + // For each bucket of glyphs after the first 15, + // add an additional 1s of playback time. + let fifteenGlyphBuckets = (max(0, CGFloat(text.glyphCount) - 15) / 15).rounded(.up) + duration += fifteenGlyphBuckets + } - if let textAttachmentView = mediaView as? TextAttachmentView { - return textAttachmentView.willHandleTapGesture(gesture) + // If a text attachment includes a link preview, play + // for an additional 2s + if attachment.preview != nil { duration += 2 } + + return duration } - - return false } - private func startAttachmentDownloadIfNecessary() -> Bool { + var elapsedTime: CFTimeInterval? { + guard let currentTime = videoPlayer?.avPlayer.currentTime(), + let asset = videoPlayer?.avPlayer.currentItem?.asset else { return nil } + let loopedElapsedTime = Double(videoPlayerLoopCount) * CMTimeGetSeconds(asset.duration) + return CMTimeGetSeconds(currentTime) + loopedElapsedTime + } + + // MARK: - Downloading + + private func startAttachmentDownloadIfNecessary(_ gesture: UITapGestureRecognizer) -> Bool { guard case .pointer(let pointer) = item.attachment, ![.enqueued, .downloading].contains(pointer.state) else { return false } + + // Only start downloads when the user taps in the center of the view. + let downloadHitRegion = CGRect( + origin: CGPoint(x: frame.center.x - 30, y: frame.center.y - 30), + size: CGSize(square: 60) + ) + guard downloadHitRegion.contains(gesture.location(in: self)) else { return false } + attachmentDownloads.enqueueDownloadOfAttachments( forStoryMessageId: item.message.uniqueId, attachmentGroup: .allAttachmentsIncoming, @@ -98,39 +210,16 @@ class StoryItemMediaView: UIView { Logger.warn("Failed to redownload attachment with error: \(error)") DispatchQueue.main.async { self?.updateMediaView() } } + return true } - var isDownloading: Bool { - guard case .pointer(let pointer) = item.attachment else { return false } - return [.enqueued, .downloading].contains(pointer.state) + var isPendingDownload: Bool { + guard case .pointer = item.attachment else { return false } + return true } - var duration: CFTimeInterval { - if let asset = videoPlayer?.avPlayer.currentItem?.asset { - return CMTimeGetSeconds(asset.duration) - } else { - return 5 - } - } - - var elapsedTime: CFTimeInterval? { - guard let currentTime = videoPlayer?.avPlayer.currentTime() else { return nil } - return CMTimeGetSeconds(currentTime) - } - - private lazy var gradientProtectionView: UIView = { - let gradientLayer = CAGradientLayer() - gradientLayer.colors = [ - UIColor.black.withAlphaComponent(0).cgColor, - UIColor.black.withAlphaComponent(0.5).cgColor - ] - let view = OWSLayerView(frame: .zero) { view in - gradientLayer.frame = view.bounds - } - view.layer.addSublayer(gradientLayer) - return view - }() + // MARK: - Author Row private lazy var timestampLabel = UILabel() private lazy var authorRow = UIStackView() @@ -157,16 +246,7 @@ class StoryItemMediaView: UIView { timestampLabel.textColor = Theme.darkThemeSecondaryTextAndIconColor updateTimestampText() - addSubview(authorRow) - authorRow.autoPinWidthToSuperview(withMargin: OWSTableViewController2.defaultHOuterMargin) - - if UIDevice.current.hasIPhoneXNotch || UIDevice.current.isIPad { - // iPhone with notch or iPad (views/replies rendered below media, media is in a card) - authorRow.autoPinEdge(toSuperviewEdge: .bottom, withInset: OWSTableViewController2.defaultHOuterMargin + 16) - } else { - // iPhone with home button (views/replies rendered on top of media, media is fullscreen) - authorRow.autoPinEdge(toSuperviewEdge: .bottom, withInset: 80) - } + bottomContentVStack.addArrangedSubview(authorRow) } private func buildAvatarView(transaction: SDSAnyReadTransaction) -> UIView { @@ -248,6 +328,181 @@ class StoryItemMediaView: UIView { return label } + // MARK: - Caption + + private lazy var captionLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 17) + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 15/17 + label.textColor = Theme.darkThemePrimaryColor + return label + }() + + private var fullCaptionText: String? + private var truncatedCaptionText: NSAttributedString? + private var isCaptionTruncated: Bool { truncatedCaptionText != nil } + private var hasCaption: Bool { fullCaptionText != nil } + + private var maxCaptionLines = 5 + private func createCaptionIfNecessary() { + guard let captionText: String = { + switch item.attachment { + case .stream(let attachment): return attachment.caption?.nilIfEmpty + case .pointer(let attachment): return attachment.caption?.nilIfEmpty + case .text: return nil + } + }() else { return } + + fullCaptionText = captionText + + captionLabel.text = captionText + + bottomContentVStack.addArrangedSubview(captionLabel) + } + + private var isCaptionExpanded = false + private var captionBackdrop: UIView? + private func toggleCaptionExpansionIfNecessary(_ gesture: UIGestureRecognizer) -> Bool { + guard hasCaption, isCaptionTruncated else { return false } + + if !isCaptionExpanded { + guard captionLabel.bounds.contains(gesture.location(in: captionLabel)) else { return false } + } else if let captionBackdrop = captionBackdrop { + guard captionBackdrop.bounds.contains(gesture.location(in: captionBackdrop)) else { return false } + } else { + owsFailDebug("Unexpectedly missing caption backdrop") + } + + let isExpanding = !isCaptionExpanded + isCaptionExpanded = isExpanding + + if isExpanding { + self.captionBackdrop?.removeFromSuperview() + let captionBackdrop = UIView() + captionBackdrop.backgroundColor = .ows_blackAlpha60 + captionBackdrop.alpha = 0 + self.captionBackdrop = captionBackdrop + insertSubview(captionBackdrop, belowSubview: bottomContentVStack) + captionBackdrop.autoPinEdgesToSuperviewEdges() + + captionLabel.numberOfLines = 0 + captionLabel.text = fullCaptionText + delegate?.storyItemMediaViewWantsToPause(self) + } else { + captionLabel.numberOfLines = maxCaptionLines + captionLabel.attributedText = truncatedCaptionText + delegate?.storyItemMediaViewWantsToPlay(self) + updateCaptionTruncation() + } + + UIView.animate(withDuration: 0.2) { + self.captionBackdrop?.alpha = isExpanding ? 1 : 0 + self.captionLabel.layoutIfNeeded() + } completion: { _ in + if !isExpanding { + self.captionBackdrop?.removeFromSuperview() + self.captionBackdrop = nil + } + } + + return true + } + + private var lastTruncationWidth: CGFloat? + private func updateCaptionTruncation() { + guard let fullCaptionText = fullCaptionText, !isCaptionExpanded else { return } + + // Only update truncation if the view's width has changed. + guard width != lastTruncationWidth else { return } + lastTruncationWidth = width + + captionLabel.numberOfLines = maxCaptionLines + captionLabel.text = fullCaptionText + bottomContentVStack.layoutIfNeeded() + + let labelMinimumScaledFont = captionLabel.font + .withSize(captionLabel.font.pointSize * captionLabel.minimumScaleFactor) + + let layoutManager = NSLayoutManager() + let textContainer = NSTextContainer(size: captionLabel.bounds.size) + let textStorage = NSTextStorage() + + layoutManager.addTextContainer(textContainer) + textStorage.addLayoutManager(layoutManager) + textStorage.setAttributedString(fullCaptionText.styled(with: .font(labelMinimumScaledFont))) + + textContainer.lineFragmentPadding = 0 + textContainer.lineBreakMode = .byWordWrapping + textContainer.maximumNumberOfLines = 5 + + func visibleCaptionRange() -> NSRange { + layoutManager.glyphRange(for: textContainer) + } + + var visibleCharacterRangeUpperBound = visibleCaptionRange().upperBound + + // Check if we're displaying less than the full length of the caption text. + guard visibleCharacterRangeUpperBound < fullCaptionText.utf16.count else { + truncatedCaptionText = nil + return + } + + let readMoreText = NSLocalizedString( + "STORIES_CAPTION_READ_MORE", + comment: "Text indication a story caption can be tapped to read more." + ).styled(with: .font(labelMinimumScaledFont.ows_semibold)) + + var potentialTruncatedCaptionText = fullCaptionText + func truncatePotentialCaptionText(to index: Int) { + potentialTruncatedCaptionText = (potentialTruncatedCaptionText as NSString).substring(to: index) + textStorage.setAttributedString(buildTruncatedCaptionText().styled(with: .font(labelMinimumScaledFont))) + } + + func buildTruncatedCaptionText() -> NSAttributedString { + .composed(of: [ + potentialTruncatedCaptionText.stripped, "…", " ", readMoreText + ]) + } + + defer { + truncatedCaptionText = buildTruncatedCaptionText() + captionLabel.attributedText = truncatedCaptionText + } + + // We might fit without further truncation, for example if the caption + // contains new line characters, so set the possible new text immediately. + truncatePotentialCaptionText(to: visibleCharacterRangeUpperBound) + + visibleCharacterRangeUpperBound = visibleCaptionRange().upperBound - readMoreText.string.utf16.count - 2 + + // If we're still truncated, trim down the visible text until + // we have space to fit the read more text without truncation. + // This should only take a few iterations. + var iterationCount = 0 + while visibleCharacterRangeUpperBound < potentialTruncatedCaptionText.utf16.count { + let truncateToIndex = max(0, visibleCharacterRangeUpperBound) + guard truncateToIndex > 0 else { break } + + truncatePotentialCaptionText(to: truncateToIndex) + + visibleCharacterRangeUpperBound = visibleCaptionRange().upperBound - readMoreText.string.utf16.count - 2 + + iterationCount += 1 + if iterationCount >= 5 { + owsFailDebug("Failed to calculate visible range for caption text. Bailing.") + break + } + } + } + + override func layoutSubviews() { + super.layoutSubviews() + updateCaptionTruncation() + } + + // MARK: - Media + private weak var mediaView: UIView? private func updateMediaView() { mediaView?.removeFromSuperview() @@ -278,7 +533,7 @@ class StoryItemMediaView: UIView { backgroundImageView.autoPinEdgesToSuperviewEdges() if stream.isVideo { - let videoView = buildVideoView(originalMediaUrl: originalMediaUrl) + let videoView = buildVideoView(originalMediaUrl: originalMediaUrl, shouldLoop: stream.isLoopingVideo) container.addSubview(videoView) videoView.autoPinEdgesToSuperviewEdges() } else if stream.shouldBeRenderedByYY { @@ -313,11 +568,15 @@ class StoryItemMediaView: UIView { } } + private var videoPlayerLoopCount = 0 private var videoPlayer: OWSVideoPlayer? - private func buildVideoView(originalMediaUrl: URL) -> UIView { - let player = OWSVideoPlayer(url: originalMediaUrl, shouldLoop: false) + private func buildVideoView(originalMediaUrl: URL, shouldLoop: Bool) -> UIView { + let player = OWSVideoPlayer(url: originalMediaUrl, shouldLoop: shouldLoop) + player.delegate = self self.videoPlayer = player + videoPlayerLoopCount = 0 + let playerView = VideoPlayerView() playerView.contentMode = .scaleAspectFit playerView.videoPlayer = player @@ -429,3 +688,9 @@ class StoryItem: NSObject { self.attachment = attachment } } + +extension StoryItemMediaView: OWSVideoPlayerDelegate { + func videoPlayerDidPlayToCompletion(_ videoPlayer: OWSVideoPlayer) { + videoPlayerLoopCount += 1 + } +} diff --git a/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplyViewItem.swift b/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplyViewItem.swift index 866c211102..234035a8f3 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplyViewItem.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplyViewItem.swift @@ -18,7 +18,7 @@ class StoryGroupReplyViewItem: Dependencies { var cellType: StoryGroupReplyCell.CellType - var timeString: String { DateUtil.formatMessageTimestampForCVC(receivedAtTimestamp, shouldUseLongFormat: false) } + var timeString: String { DateUtil.formatTimestampRelatively(receivedAtTimestamp) } init( message: TSMessage, diff --git a/Signal/src/ViewControllers/HomeView/Stories/IncomingStoryViewModel.swift b/Signal/src/ViewControllers/HomeView/Stories/IncomingStoryViewModel.swift index 64bde68282..568cf689f3 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/IncomingStoryViewModel.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/IncomingStoryViewModel.swift @@ -19,21 +19,14 @@ struct IncomingStoryViewModel: Dependencies { let hasReplies: Bool let latestMessageName: String let latestMessageTimestamp: UInt64 + let latestMessageViewedTimestamp: UInt64? let latestMessageAvatarDataSource: ConversationAvatarDataSource init(messages: [StoryMessage], transaction: SDSAnyReadTransaction) throws { let sortedFilteredMessages = messages.lazy.filter { $0.direction == .incoming }.sorted { $0.timestamp < $1.timestamp } self.messages = sortedFilteredMessages - self.hasUnviewedMessages = sortedFilteredMessages.contains { message in - switch message.manifest { - case .incoming(_, let viewedTimestamp): - return viewedTimestamp == nil - case .outgoing: - owsFailDebug("Unexpected message type") - return false - } - } + self.hasUnviewedMessages = sortedFilteredMessages.contains { $0.localUserViewedTimestamp == nil } guard let latestMessage = sortedFilteredMessages.last else { throw OWSAssertionError("At least one message is required.") @@ -76,6 +69,7 @@ struct IncomingStoryViewModel: Dependencies { } latestMessageTimestamp = latestMessage.timestamp + latestMessageViewedTimestamp = latestMessage.localUserViewedTimestamp } func copy(updatedMessages: [StoryMessage], deletedMessageRowIds: [Int64], transaction: SDSAnyReadTransaction) throws -> Self? { diff --git a/Signal/src/ViewControllers/HomeView/Stories/StoriesViewController.swift b/Signal/src/ViewControllers/HomeView/Stories/StoriesViewController.swift index b918f0b782..8dd084a029 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/StoriesViewController.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/StoriesViewController.swift @@ -142,7 +142,7 @@ class StoriesViewController: OWSViewController { let groupedMessages = Dictionary(grouping: incomingMessages) { $0.context } let newModels = Self.databaseStorage.read { transaction in groupedMessages.compactMap { try? IncomingStoryViewModel(messages: $1, transaction: transaction) } - }.sorted { $0.latestMessageTimestamp > $1.latestMessageTimestamp } + }.sorted(by: self.sortStoryModels) DispatchQueue.main.async { self.models = newModels self.tableView.reloadData() @@ -183,7 +183,7 @@ class StoriesViewController: OWSViewController { transaction: transaction ) } + groupedMessages.map { try IncomingStoryViewModel(messages: $1, transaction: transaction) } - }.sorted { $0.latestMessageTimestamp > $1.latestMessageTimestamp } + }.sorted(by: self.sortStoryModels) } catch { owsFailDebug("Failed to build new models, hard reloading \(error)") DispatchQueue.main.async { self.reloadStories() } @@ -234,6 +234,22 @@ class StoriesViewController: OWSViewController { } } } + + // Sort story models for display. + // * We show unviewed stories first, sorted by their sent timestamp, with the most recently sent at the top + // * We then show viewed stories, sorted by when they were viewed, with the most recently viewed at the top + private func sortStoryModels(lhs: IncomingStoryViewModel, rhs: IncomingStoryViewModel) -> Bool { + if let lhsViewedTimestamp = lhs.latestMessageViewedTimestamp, + let rhsViewedTimestamp = rhs.latestMessageViewedTimestamp { + return lhsViewedTimestamp > rhsViewedTimestamp + } else if lhs.latestMessageViewedTimestamp != nil { + return false + } else if rhs.latestMessageViewedTimestamp != nil { + return true + } else { + return lhs.latestMessageTimestamp > rhs.latestMessageTimestamp + } + } } extension StoriesViewController: CameraFirstCaptureDelegate { @@ -369,7 +385,11 @@ extension StoriesViewController: ContextMenuInteractionDelegate { } AttachmentSharing.showShareUI(forAttachment: attachment, sender: cell) case .text(let attachment): - AttachmentSharing.showShareUI(forText: attachment.text, sender: cell) + if let url = attachment.preview?.urlString { + AttachmentSharing.showShareUI(for: URL(string: url)!, sender: cell) + } else if let text = attachment.text { + AttachmentSharing.showShareUI(forText: text, sender: cell) + } case .missing: owsFailDebug("Unexpectedly missing attachment for story.") } diff --git a/Signal/src/ViewControllers/HomeView/Stories/StoryCell.swift b/Signal/src/ViewControllers/HomeView/Stories/StoryCell.swift index ea96edacca..76ac6a2241 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/StoryCell.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/StoryCell.swift @@ -188,6 +188,6 @@ class StoryCell: UITableViewCell { func configureTimestamp(with model: IncomingStoryViewModel) { timestampLabel.font = .ows_dynamicTypeSubheadline timestampLabel.textColor = Theme.secondaryTextAndIconColor - timestampLabel.text = DateUtil.formatTimestampShort(model.latestMessageTimestamp) + timestampLabel.text = DateUtil.formatTimestampRelatively(model.latestMessageTimestamp) } } diff --git a/Signal/src/ViewControllers/HomeView/Stories/TextAttachmentView.swift b/Signal/src/ViewControllers/HomeView/Stories/TextAttachmentView.swift index de9dd6add7..e4095f3cab 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/TextAttachmentView.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/TextAttachmentView.swift @@ -29,35 +29,37 @@ class TextAttachmentView: UIView { addSubview(contentStackView) contentStackView.autoPinEdgesToSuperviewEdges() - let label = UILabel() - label.numberOfLines = 0 - label.textColor = attachment.textForegroundColor ?? Theme.darkThemePrimaryColor - label.text = transformedText(attachment.text, for: attachment.textStyle) - label.textAlignment = .center - label.font = font(for: attachment.textStyle) - label.adjustsFontSizeToFitWidth = true - label.minimumScaleFactor = 0.2 + if let text = attachment.text { + let label = UILabel() + label.numberOfLines = 0 + label.textColor = attachment.textForegroundColor ?? Theme.darkThemePrimaryColor + label.text = transformedText(text, for: attachment.textStyle) + label.textAlignment = .center + label.font = font(for: attachment.textStyle) + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.2 - if let textBackgroundColor = attachment.textBackgroundColor { - let labelBackgroundView = UIView() - labelBackgroundView.layoutMargins = UIEdgeInsets(hMargin: 16, vMargin: 16) - labelBackgroundView.backgroundColor = textBackgroundColor - labelBackgroundView.layer.cornerRadius = 18 + if let textBackgroundColor = attachment.textBackgroundColor { + let labelBackgroundView = UIView() + labelBackgroundView.layoutMargins = UIEdgeInsets(hMargin: 16, vMargin: 16) + labelBackgroundView.backgroundColor = textBackgroundColor + labelBackgroundView.layer.cornerRadius = 18 - labelBackgroundView.addSubview(label) - label.autoPinEdgesToSuperviewMargins() + labelBackgroundView.addSubview(label) + label.autoPinEdgesToSuperviewMargins() - let labelWrapper = UIView() - labelWrapper.addSubview(labelBackgroundView) - labelBackgroundView.autoPinWidthToSuperview(withMargin: 24) - labelBackgroundView.autoPinHeightToSuperview() - contentStackView.addArrangedSubview(labelWrapper) - } else { - let labelWrapper = UIView() - labelWrapper.addSubview(label) - label.autoPinWidthToSuperview(withMargin: 40) - label.autoPinHeightToSuperview() - contentStackView.addArrangedSubview(labelWrapper) + let labelWrapper = UIView() + labelWrapper.addSubview(labelBackgroundView) + labelBackgroundView.autoPinWidthToSuperview(withMargin: 24) + labelBackgroundView.autoPinHeightToSuperview() + contentStackView.addArrangedSubview(labelWrapper) + } else { + let labelWrapper = UIView() + labelWrapper.addSubview(label) + label.autoPinWidthToSuperview(withMargin: 40) + label.autoPinHeightToSuperview() + contentStackView.addArrangedSubview(labelWrapper) + } } if let linkPreviewView = buildLinkPreviewView(attachment.preview) { @@ -77,6 +79,8 @@ class TextAttachmentView: UIView { topSpacer.autoMatch(.height, to: .height, of: bottomSpacer) } + public var isPresentingLinkTooltip: Bool { linkPreviewTooltipView != nil } + private var linkPreviewTooltipView: LinkPreviewTooltipView? func willHandleTapGesture(_ gesture: UITapGestureRecognizer) -> Bool { if let linkPreviewTooltipView = linkPreviewTooltipView { @@ -87,11 +91,11 @@ class TextAttachmentView: UIView { options: [:], completionHandler: nil ) + } else { + linkPreviewTooltipView.removeFromSuperview() + self.linkPreviewTooltipView = nil } - linkPreviewTooltipView.removeFromSuperview() - self.linkPreviewTooltipView = nil - return true } else if let linkPreviewView = linkPreviewView, let urlString = textAttachment.preview?.urlString, @@ -99,7 +103,7 @@ class TextAttachmentView: UIView { linkPreviewView.frame.contains(gesture.location(in: container)) { let tooltipView = LinkPreviewTooltipView( fromView: self, - referenceView: linkPreviewView, + tailReferenceView: linkPreviewView, url: URL(string: urlString)! ) self.linkPreviewTooltipView = tooltipView @@ -215,6 +219,8 @@ class TextAttachmentView: UIView { titleLabel.font = .boldSystemFont(ofSize: 16) titleLabel.textColor = Theme.darkThemePrimaryColor titleLabel.numberOfLines = 2 + titleLabel.setCompressionResistanceVerticalHigh() + titleLabel.setContentHuggingVerticalHigh() previewVStack.addArrangedSubview(titleLabel) } @@ -224,6 +230,8 @@ class TextAttachmentView: UIView { descriptionLabel.font = .systemFont(ofSize: 12) descriptionLabel.textColor = Theme.darkThemePrimaryColor descriptionLabel.numberOfLines = 3 + descriptionLabel.setCompressionResistanceVerticalHigh() + descriptionLabel.setContentHuggingVerticalHigh() previewVStack.addArrangedSubview(descriptionLabel) } @@ -231,6 +239,8 @@ class TextAttachmentView: UIView { footerLabel.font = .systemFont(ofSize: 12) footerLabel.numberOfLines = 2 footerLabel.textColor = Theme.darkThemeSecondaryTextAndIconColor + footerLabel.setCompressionResistanceVerticalHigh() + footerLabel.setContentHuggingVerticalHigh() previewVStack.addArrangedSubview(footerLabel) var footerText: String @@ -298,12 +308,12 @@ private extension CAGradientLayer { private class LinkPreviewTooltipView: TooltipView { let url: URL - init(fromView: UIView, referenceView: UIView, url: URL) { + init(fromView: UIView, tailReferenceView: UIView, url: URL) { self.url = url super.init( fromView: fromView, - widthReferenceView: referenceView, - tailReferenceView: referenceView, + widthReferenceView: fromView, + tailReferenceView: tailReferenceView, wasTappedBlock: nil ) } @@ -340,4 +350,5 @@ private class LinkPreviewTooltipView: TooltipView { public override var bubbleHSpacing: CGFloat { 16 } public override var tailDirection: TooltipView.TailDirection { .down } + public override var dismissOnTap: Bool { false } } diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index e10a3461a3..5891191c68 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -6091,6 +6091,9 @@ /* Label for the 'uninstall sticker pack' button. */ "STICKERS_UNINSTALL_BUTTON" = "Uninstall"; +/* Text indication a story caption can be tapped to read more. */ +"STORIES_CAPTION_READ_MORE" = "Read more"; + /* Context menu action to copy the selected story reply */ "STORIES_COPY_REPLY_ACTION" = "Copy"; @@ -6136,12 +6139,6 @@ /* Author text when the local user author's a story reply */ "STORY_REPLY_YOU_AUTHOR" = "You"; -/* Text indicating a story has been reacted to */ -"STORY_REPLY_REACTION" = "Reacted to the story"; - -/* placeholder text for replying to a story */ -"STORY_REPLY_TEXT_FIELD_PLACEHOLDER" = "Reply"; - /* Title for the 'Badges' button in sustainer view. */ "SUBSCRIBER_BADGES" = "Badges"; diff --git a/SignalMessaging/utils/DateUtil.swift b/SignalMessaging/utils/DateUtil.swift index d500e86d88..ff786c58ba 100644 --- a/SignalMessaging/utils/DateUtil.swift +++ b/SignalMessaging/utils/DateUtil.swift @@ -1,5 +1,5 @@ // -// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// Copyright (c) 2022 Open Whisper Systems. All rights reserved. // import Foundation @@ -60,7 +60,7 @@ extension DateUtil { // We might receive a message "from the future" due to a bug or // malicious sender or a sender whose device time is misconfigured, // etc. Clamp message and date headers dates to the past & present. - private static func safeDateForCVC(_ date: Date) -> Date { + private static func clampBeforeNow(_ date: Date) -> Date { let nowDate = Date() return date < nowDate ? date : nowDate } @@ -68,7 +68,7 @@ extension DateUtil { @objc public static func formatMessageTimestampForCVC(_ timestamp: UInt64, shouldUseLongFormat: Bool) -> String { - let date = safeDateForCVC(Date(millisecondsSince1970: timestamp)) + let date = clampBeforeNow(Date(millisecondsSince1970: timestamp)) let calendar = Calendar.current let minutesDiff = calendar.dateComponents([.minute], from: date, to: Date()).minute ?? 0 if minutesDiff < 1 { @@ -93,7 +93,7 @@ extension DateUtil { @objc public static func formatDateHeaderForCVC(_ date: Date) -> String { - let date = safeDateForCVC(date) + let date = clampBeforeNow(date) let calendar = Calendar.current let monthsDiff = calendar.dateComponents([.month], from: date, to: Date()).month ?? 0 if monthsDiff >= 6 { @@ -108,6 +108,18 @@ extension DateUtil { } } + public static func formatTimestampRelatively(_ timestamp: UInt64) -> String { + let date = clampBeforeNow(Date(millisecondsSince1970: timestamp)) + let calendar = Calendar.current + let minutesDiff = calendar.dateComponents([.minute], from: date, to: Date()).minute ?? 0 + if minutesDiff < 1 { + return OWSLocalizedString("DATE_NOW", comment: "The present; the current time.") + } else { + let secondsDiff = calendar.dateComponents([.second], from: date, to: Date()).second ?? 0 + return NSString.formatDurationSeconds(UInt32(secondsDiff), useShortFormat: true) + } + } + private static let dateHeaderRecentDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = .current diff --git a/SignalServiceKit/src/Messages/Stories/StoryMessage.swift b/SignalServiceKit/src/Messages/Stories/StoryMessage.swift index 78795a5c7a..daec5d8229 100644 --- a/SignalServiceKit/src/Messages/Stories/StoryMessage.swift +++ b/SignalServiceKit/src/Messages/Stories/StoryMessage.swift @@ -36,6 +36,24 @@ public final class StoryMessage: NSObject, SDSCodableModel { public private(set) var manifest: StoryManifest public let attachment: StoryMessageAttachment + public var localUserViewedTimestamp: UInt64? { + switch manifest { + case .incoming(_, let viewedTimestamp): + return viewedTimestamp + case .outgoing: + return timestamp + } + } + + public var localUserAllowedToReply: Bool { + switch manifest { + case .incoming(let allowsReplies, _): + return allowsReplies + case .outgoing: + return true + } + } + @objc public var allAttachmentIds: [String] { switch attachment { case .file(let attachmentId): @@ -239,7 +257,7 @@ public enum StoryMessageAttachment: Codable { } public struct TextAttachment: Codable { - public let text: String + public let text: String? public enum TextStyle: Int, Codable { case regular = 0 @@ -292,10 +310,7 @@ public struct TextAttachment: Codable { public private(set) var preview: OWSLinkPreview? init(from proto: SSKProtoTextAttachment, transaction: SDSAnyWriteTransaction) throws { - guard let text = proto.text?.nilIfEmpty else { - throw OWSAssertionError("Missing text for attachment.") - } - self.text = text + self.text = proto.text?.nilIfEmpty guard let style = proto.textStyle else { throw OWSAssertionError("Missing style for attachment.") diff --git a/SignalUI/ViewModels/ThreadViewModel.swift b/SignalUI/ViewModels/ThreadViewModel.swift index 9dd6488444..08d02fdc81 100644 --- a/SignalUI/ViewModels/ThreadViewModel.swift +++ b/SignalUI/ViewModels/ThreadViewModel.swift @@ -106,12 +106,7 @@ public class ThreadViewModel: NSObject { } if let latestStory = StoryFinder.latestStoryForThread(thread, transaction: transaction) { - switch latestStory.manifest { - case .incoming(_, let viewedTimestamp): - storyState = viewedTimestamp != nil ? .viewed : .unviewed - case .outgoing: - storyState = .viewed - } + storyState = latestStory.localUserViewedTimestamp != nil ? .viewed : .unviewed } else { self.storyState = .none } diff --git a/SignalUI/Views/Tooltips/TooltipView.swift b/SignalUI/Views/Tooltips/TooltipView.swift index 0e1d4dafe7..3418c48021 100644 --- a/SignalUI/Views/Tooltips/TooltipView.swift +++ b/SignalUI/Views/Tooltips/TooltipView.swift @@ -1,5 +1,5 @@ // -// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// Copyright (c) 2022 Open Whisper Systems. All rights reserved. // import Foundation @@ -51,6 +51,8 @@ open class TooltipView: UIView { public enum TailDirection { case up, down } open var tailDirection: TailDirection { .down } + open var dismissOnTap: Bool { true } + private func createContents(fromView: UIView, widthReferenceView: UIView, tailReferenceView: UIView) { @@ -164,11 +166,9 @@ open class TooltipView: UIView { @objc func handleTap(sender: UIGestureRecognizer) { - guard sender.state == .recognized else { - return - } + guard sender.state == .recognized else { return } Logger.verbose("") - removeFromSuperview() + if dismissOnTap { removeFromSuperview() } wasTappedBlock?() } }