diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m index 6e794396e..daf958e8b 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m @@ -1343,6 +1343,7 @@ NS_ASSUME_NONNULL_BEGIN if (self.hasBottomFooter) { CGSize footerSize = [self.footerView measureWithConversationViewItem:self.viewItem]; + footerSize.width = MIN(footerSize.width, self.conversationStyle.maxMessageWidth); [textViewSizes addObject:[NSValue valueWithCGSize:footerSize]]; } diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageFooterView.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageFooterView.m index 094616ec0..a125c4dc0 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageFooterView.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageFooterView.m @@ -168,7 +168,7 @@ NS_ASSUME_NONNULL_BEGIN self.timestampLabel.text = NSLocalizedString(@"MESSAGE_STATUS_SEND_FAILED", @"Label indicating that a message failed to send."); } else { - self.timestampLabel.text = [DateUtil formatTimestampAsTimeShort:viewItem.interaction.timestamp]; + self.timestampLabel.text = [DateUtil formatMessageTimestamp:viewItem.interaction.timestamp]; } } @@ -180,7 +180,34 @@ NS_ASSUME_NONNULL_BEGIN CGSize result = CGSizeZero; result.height = MAX(self.timestampLabel.font.lineHeight, self.imageHeight); - result.width = [self.timestampLabel sizeThatFits:CGSizeZero].width; + + // Measure the actual current width, to be safe. + CGFloat timestampLabelWidth = [self.timestampLabel sizeThatFits:CGSizeZero].width; + + // Measuring the timestamp label's width is non-trivial since its + // contents can be relative the current time. We avoid having + // message bubbles' "visually vibrate" as their timestamp labels + // vary in width. So we try to leave enough space for all possible + // contents of this label _for the first hour of its lifetime_, when + // the timestamp is particularly volatile. + if ([DateUtil isTimestampFromLastHour:viewItem.interaction.timestamp]) { + // Measure the "now" case. + self.timestampLabel.text = [DateUtil exemplaryNowTimeFormat]; + timestampLabelWidth = MAX(timestampLabelWidth, [self.timestampLabel sizeThatFits:CGSizeZero].width); + // Measure the "relative time" case. + // Since this case varies with time, we multiply to leave + // space for the worst case (whose exact value, due to localization, + // is unpredictable). + self.timestampLabel.text = [DateUtil exemplaryMinutesTimeFormat]; + timestampLabelWidth = MAX(timestampLabelWidth, + [self.timestampLabel sizeThatFits:CGSizeZero].width + self.timestampLabel.font.lineHeight * 0.5f); + + // Re-configure the labels with the current appropriate value in case + // we are configuring this view for display. + [self configureLabelsWithConversationViewItem:viewItem]; + } + + result.width = timestampLabelWidth; if (viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage) { if (![self isFailedOutgoingMessage:viewItem]) { result.width += (self.maxImageWidth + self.hSpacing); diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index 60b8311be..c174667be 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -237,6 +237,8 @@ typedef enum : NSUInteger { @property (nonatomic, nullable) NSNumber *previousLastTimestamp; @property (nonatomic, nullable) NSNumber *viewHorizonTimestamp; @property (nonatomic) ContactShareViewHelper *contactShareViewHelper; +@property (nonatomic) NSTimer *reloadTimer; +@property (nonatomic, nullable) NSDate *lastReloadDate; @end @@ -480,6 +482,39 @@ typedef enum : NSUInteger { }]; [self updateMessageMappingRangeOptions]; [self updateShouldObserveDBModifications]; + + self.reloadTimer = [NSTimer weakScheduledTimerWithTimeInterval:1.f + target:self + selector:@selector(reloadTimerDidFire) + userInfo:nil + repeats:YES]; +} + +- (void)dealloc +{ + [self.reloadTimer invalidate]; +} + +- (void)reloadTimerDidFire +{ + OWSAssertIsOnMainThread(); + + if (self.isUserScrolling || !self.isViewCompletelyAppeared || !self.isViewVisible + || !self.shouldObserveDBModifications || !self.viewHasEverAppeared) { + return; + } + + NSDate *now = [NSDate new]; + if (self.lastReloadDate) { + NSTimeInterval timeSinceLastReload = [now timeIntervalSinceDate:self.lastReloadDate]; + const NSTimeInterval kReloadFrequency = 60.f; + if (timeSinceLastReload < kReloadFrequency) { + return; + } + } + + DDLogVerbose(@"%@ reloading conversation view contents.", self.logTag); + [self resetContentAndLayout]; } - (BOOL)userLeftGroup @@ -795,6 +830,7 @@ typedef enum : NSUInteger { // Avoid layout corrupt issues and out-of-date message subtitles. [self.collectionView.collectionViewLayout invalidateLayout]; [self.collectionView reloadData]; + self.lastReloadDate = [NSDate new]; } - (void)setUserHasScrolled:(BOOL)userHasScrolled @@ -1669,8 +1705,7 @@ typedef enum : NSUInteger { self.loadMoreHeader.userInteractionEnabled = showLoadMoreHeader; if (valueChanged) { - [self.collectionView.collectionViewLayout invalidateLayout]; - [self.collectionView reloadData]; + [self resetContentAndLayout]; } } @@ -3341,6 +3376,7 @@ typedef enum : NSUInteger { OWSProdLogAndFail(@"%@ hasMalformedRowChange", self.logTag); [self reloadViewItems]; [self.collectionView reloadData]; + self.lastReloadDate = [NSDate new]; [self updateLastVisibleTimestamp]; [self cleanUpUnreadIndicatorIfNecessary]; return; @@ -3438,6 +3474,7 @@ typedef enum : NSUInteger { [self.collectionView performBatchUpdates:batchUpdates completion:batchUpdatesCompletion]; }]; } + self.lastReloadDate = [NSDate new]; } - (BOOL)shouldAnimateRowUpdates:(NSArray *)rowChanges @@ -4325,6 +4362,7 @@ typedef enum : NSUInteger { [self.conversationStyle updateProperties]; [self.headerView updateAvatar]; [self.collectionView reloadData]; + self.lastReloadDate = [NSDate new]; } - (void)groupWasUpdated:(TSGroupModel *)groupModel diff --git a/Signal/src/util/DateUtil.h b/Signal/src/util/DateUtil.h index 50805fdc4..3a5974a21 100644 --- a/Signal/src/util/DateUtil.h +++ b/Signal/src/util/DateUtil.h @@ -23,8 +23,16 @@ NS_ASSUME_NONNULL_BEGIN + (NSString *)formatTimestampShort:(uint64_t)timestamp; + (NSString *)formatDateShort:(NSDate *)date; -+ (NSString *)formatTimestampAsTimeShort:(uint64_t)timestamp; -+ (NSString *)formatDateAsTimeShort:(NSDate *)date; ++ (NSString *)formatTimestampAsTime:(uint64_t)timestamp; ++ (NSString *)formatDateAsTime:(NSDate *)date; + ++ (NSString *)formatMessageTimestamp:(uint64_t)timestamp; + ++ (BOOL)isTimestampFromLastHour:(uint64_t)timestamp; +// These two "exemplary" values can be used by views to measure +// the likely size for recent values formatted using isTimestampFromLastHour:. ++ (NSString *)exemplaryNowTimeFormat; ++ (NSString *)exemplaryMinutesTimeFormat; + (BOOL)isSameDayWithTimestamp:(uint64_t)timestamp1 timestamp:(uint64_t)timestamp2; + (BOOL)isSameDayWithDate:(NSDate *)date1 date:(NSDate *)date2; diff --git a/Signal/src/util/DateUtil.m b/Signal/src/util/DateUtil.m index e3d1b6c96..bda7dcaec 100644 --- a/Signal/src/util/DateUtil.m +++ b/Signal/src/util/DateUtil.m @@ -4,6 +4,7 @@ #import "DateUtil.h" #import +#import #import NS_ASSUME_NONNULL_BEGIN @@ -145,6 +146,24 @@ static NSString *const DATE_FORMAT_WEEKDAY = @"EEEE"; return [[calendar components:NSCalendarUnitDay fromDate:date1 toDate:date2 options:0] day]; } +// Returns the difference in years, ignoring shorter units of time. +// If both dates fall in the same year, returns 0. +// If firstDate is from the year before secondDate, returns 1. +// +// Note: Assumes both dates use the "current" calendar. ++ (NSInteger)yearsFromFirstDate:(NSDate *)firstDate toSecondDate:(NSDate *)secondDate +{ + NSCalendar *calendar = [NSCalendar currentCalendar]; + NSCalendarUnit units = NSCalendarUnitEra | NSCalendarUnitYear; + NSDateComponents *comp1 = [calendar components:units fromDate:firstDate]; + NSDateComponents *comp2 = [calendar components:units fromDate:secondDate]; + [comp1 setHour:12]; + [comp2 setHour:12]; + NSDate *date1 = [calendar dateFromComponents:comp1]; + NSDate *date2 = [calendar dateFromComponents:comp2]; + return [[calendar components:NSCalendarUnitYear fromDate:date1 toDate:date2 options:0] year]; +} + + (NSString *)formatPastTimestampRelativeToNow:(uint64_t)pastTimestamp { OWSCAssert(pastTimestamp > 0); @@ -187,12 +206,12 @@ static NSString *const DATE_FORMAT_WEEKDAY = @"EEEE"; return dateTimeString.uppercaseString; } -+ (NSString *)formatTimestampAsTimeShort:(uint64_t)timestamp ++ (NSString *)formatTimestampAsTime:(uint64_t)timestamp { - return [self formatDateAsTimeShort:[NSDate ows_dateWithMillisecondsSince1970:timestamp]]; + return [self formatDateAsTime:[NSDate ows_dateWithMillisecondsSince1970:timestamp]]; } -+ (NSString *)formatDateAsTimeShort:(NSDate *)date ++ (NSString *)formatDateAsTime:(NSDate *)date { OWSAssert(date); @@ -200,6 +219,120 @@ static NSString *const DATE_FORMAT_WEEKDAY = @"EEEE"; return dateTimeString.uppercaseString; } ++ (NSDateFormatter *)otherYearMessageFormatter +{ + static NSDateFormatter *formatter; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + formatter = [NSDateFormatter new]; + [formatter setLocale:[NSLocale currentLocale]]; + [formatter setDateFormat:@"MMM d, yyyy"]; + }); + return formatter; +} + ++ (NSDateFormatter *)thisYearMessageFormatter +{ + static NSDateFormatter *formatter; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + formatter = [NSDateFormatter new]; + [formatter setLocale:[NSLocale currentLocale]]; + [formatter setDateFormat:@"MMM d"]; + }); + return formatter; +} + ++ (NSDateFormatter *)thisWeekMessageFormatter +{ + static NSDateFormatter *formatter; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + formatter = [NSDateFormatter new]; + [formatter setLocale:[NSLocale currentLocale]]; + [formatter setDateFormat:@"E"]; + }); + return formatter; +} + ++ (NSString *)formatMessageTimestamp:(uint64_t)timestamp +{ + NSDate *date = [NSDate ows_dateWithMillisecondsSince1970:timestamp]; + uint64_t nowTimestamp = [NSDate ows_millisecondTimeStamp]; + NSDate *nowDate = [NSDate ows_dateWithMillisecondsSince1970:nowTimestamp]; + + NSCalendar *calendar = [NSCalendar currentCalendar]; + + // Note: we are careful to treat "future" dates as "now". + NSInteger yearsDiff = [self yearsFromFirstDate:date toSecondDate:nowDate]; + NSInteger daysDiff = [self daysFromFirstDate:date toSecondDate:nowDate]; + NSInteger minutesDiff + = MAX(0, [[calendar components:NSCalendarUnitMinute fromDate:date toDate:nowDate options:0] minute]); + NSInteger hoursDiff + = MAX(0, [[calendar components:NSCalendarUnitHour fromDate:date toDate:nowDate options:0] hour]); + + NSString *result; + if (yearsDiff > 0) { + // "Long date" + locale-specific "short" time format. + NSString *dayOfWeek = [self.otherYearMessageFormatter stringFromDate:date]; + NSString *formattedTime = [[self timeFormatter] stringFromDate:date]; + result = [[dayOfWeek rtlSafeAppend:@" "] rtlSafeAppend:formattedTime]; + } else if (daysDiff >= 7) { + // "Short date" + locale-specific "short" time format. + NSString *dayOfWeek = [self.thisYearMessageFormatter stringFromDate:date]; + NSString *formattedTime = [[self timeFormatter] stringFromDate:date]; + result = [[dayOfWeek rtlSafeAppend:@" "] rtlSafeAppend:formattedTime]; + } else if (daysDiff > 0) { + // "Day of week" + locale-specific "short" time format. + NSString *dayOfWeek = [self.thisWeekMessageFormatter stringFromDate:date]; + NSString *formattedTime = [[self timeFormatter] stringFromDate:date]; + result = [[dayOfWeek rtlSafeAppend:@" "] rtlSafeAppend:formattedTime]; + } else if (minutesDiff < 1) { + result = NSLocalizedString(@"DATE_NOW", @"The present; the current time."); + } else if (hoursDiff < 1) { + NSString *minutesString = [OWSFormat formatInt:(int)minutesDiff]; + result = [NSString stringWithFormat:NSLocalizedString(@"DATE_MINUTES_AGO_FORMAT", + @"Format string for a relative time, expressed as a certain number of " + @"minutes in the past. Embeds {{The number of minutes}}."), + minutesString]; + } else { + NSString *hoursString = [OWSFormat formatInt:(int)hoursDiff]; + result = [NSString stringWithFormat:NSLocalizedString(@"DATE_HOURS_AGO_FORMAT", + @"Format string for a relative time, expressed as a certain number of " + @"hours in the past. Embeds {{The number of hours}}."), + hoursString]; + } + return result.uppercaseString; +} + ++ (BOOL)isTimestampFromLastHour:(uint64_t)timestamp +{ + NSDate *date = [NSDate ows_dateWithMillisecondsSince1970:timestamp]; + uint64_t nowTimestamp = [NSDate ows_millisecondTimeStamp]; + NSDate *nowDate = [NSDate ows_dateWithMillisecondsSince1970:nowTimestamp]; + + NSCalendar *calendar = [NSCalendar currentCalendar]; + + NSInteger hoursDiff + = MAX(0, [[calendar components:NSCalendarUnitHour fromDate:date toDate:nowDate options:0] hour]); + return hoursDiff < 1; +} + ++ (NSString *)exemplaryNowTimeFormat +{ + return NSLocalizedString(@"DATE_NOW", @"The present; the current time.").uppercaseString; +} + ++ (NSString *)exemplaryMinutesTimeFormat +{ + NSString *minutesString = [OWSFormat formatInt:(int)59]; + return [NSString stringWithFormat:NSLocalizedString(@"DATE_MINUTES_AGO_FORMAT", + @"Format string for a relative time, expressed as a certain number of " + @"minutes in the past. Embeds {{The number of minutes}}."), + minutesString] + .uppercaseString; +} + + (BOOL)isSameDayWithTimestamp:(uint64_t)timestamp1 timestamp:(uint64_t)timestamp2 { return [self isSameDayWithDate:[NSDate ows_dateWithMillisecondsSince1970:timestamp1] @@ -211,6 +344,7 @@ static NSString *const DATE_FORMAT_WEEKDAY = @"EEEE"; NSInteger dayDifference = [self daysFromFirstDate:date1 toSecondDate:date2]; return dayDifference == 0; } + @end NS_ASSUME_NONNULL_END diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 055a0c775..cc95d4978 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -611,6 +611,15 @@ /* Title shown while the app is updating its database. */ "DATABASE_VIEW_OVERLAY_TITLE" = "Optimizing Database"; +/* Format string for a relative time, expressed as a certain number of hours in the past. Embeds {{The number of hours}}. */ +"DATE_HOURS_AGO_FORMAT" = "%@ Hr Ago"; + +/* Format string for a relative time, expressed as a certain number of minutes in the past. Embeds {{The number of minutes}}. */ +"DATE_MINUTES_AGO_FORMAT" = "%@ Min Ago"; + +/* The present; the current time. */ +"DATE_NOW" = "Now"; + /* The current day. */ "DATE_TODAY" = "Today"; diff --git a/SignalMessaging/categories/UIView+OWS.h b/SignalMessaging/categories/UIView+OWS.h index cd9a9b79f..1206a2e7b 100644 --- a/SignalMessaging/categories/UIView+OWS.h +++ b/SignalMessaging/categories/UIView+OWS.h @@ -168,6 +168,6 @@ CG_INLINE CGSize CGSizeMax(CGSize size1, CGSize size2) return CGSizeMake(MAX(size1.width, size2.width), MAX(size1.height, size2.height)); } -CGFloat CGHairlineWidth(); +CGFloat CGHairlineWidth(void); NS_ASSUME_NONNULL_END