Merge branch 'nt/stories-5'

This commit is contained in:
Nora Trapp 2022-03-31 00:41:05 -07:00
commit fcba39fcc9
12 changed files with 500 additions and 159 deletions

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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,

View File

@ -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? {

View File

@ -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.")
}

View File

@ -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)
}
}

View File

@ -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 }
}

View File

@ -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";

View File

@ -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

View File

@ -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.")

View File

@ -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
}

View File

@ -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?()
}
}