Merge branch 'nt/stories-5'
This commit is contained in:
commit
fcba39fcc9
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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.")
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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?()
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user