From d4fa7e5e68d2f06bf3ddab2be6e7a78ee42f15af Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Mon, 9 Jul 2018 13:55:25 -0400 Subject: [PATCH] Tweak relative timestamps. --- .../Cells/OWSMessageFooterView.m | 17 +-- Signal/src/util/DateUtil.h | 7 +- Signal/src/util/DateUtil.m | 127 ++++++++++++++---- .../translations/en.lproj/Localizable.strings | 5 +- 4 files changed, 116 insertions(+), 40 deletions(-) diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageFooterView.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageFooterView.m index 86426276a..a125c4dc0 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageFooterView.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageFooterView.m @@ -168,16 +168,10 @@ 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 formatTimestampAsTime:viewItem.interaction.timestamp - maxRelativeDurationMinutes:self.maxRelativeDurationMinutes]; + self.timestampLabel.text = [DateUtil formatMessageTimestamp:viewItem.interaction.timestamp]; } } -- (NSInteger)maxRelativeDurationMinutes -{ - return 59; -} - - (CGSize)measureWithConversationViewItem:(ConversationViewItem *)viewItem { OWSAssert(viewItem); @@ -194,9 +188,9 @@ NS_ASSUME_NONNULL_BEGIN // 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. - if ([DateUtil isTimestampRelative:viewItem.interaction.timestamp - maxRelativeDurationMinutes:self.maxRelativeDurationMinutes]) { + // 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); @@ -204,8 +198,7 @@ NS_ASSUME_NONNULL_BEGIN // 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 exemplaryRelativeTimeFormatWithMaxRelativeDurationMinutes:self.maxRelativeDurationMinutes]; + self.timestampLabel.text = [DateUtil exemplaryMinutesTimeFormat]; timestampLabelWidth = MAX(timestampLabelWidth, [self.timestampLabel sizeThatFits:CGSizeZero].width + self.timestampLabel.font.lineHeight * 0.5f); diff --git a/Signal/src/util/DateUtil.h b/Signal/src/util/DateUtil.h index 7a8522312..ac2c13f96 100644 --- a/Signal/src/util/DateUtil.h +++ b/Signal/src/util/DateUtil.h @@ -26,12 +26,11 @@ NS_ASSUME_NONNULL_BEGIN + (NSString *)formatTimestampAsTime:(uint64_t)timestamp; + (NSString *)formatDateAsTime:(NSDate *)date; -+ (NSString *)formatTimestampAsTime:(uint64_t)timestamp - maxRelativeDurationMinutes:(NSInteger)maxRelativeDurationMinutes; ++ (NSString *)formatMessageTimestamp:(uint64_t)timestamp; -+ (BOOL)isTimestampRelative:(uint64_t)timestamp maxRelativeDurationMinutes:(NSInteger)maxRelativeDurationMinutes; ++ (BOOL)isTimestampFromLastHour:(uint64_t)timestamp; + (NSString *)exemplaryNowTimeFormat; -+ (NSString *)exemplaryRelativeTimeFormatWithMaxRelativeDurationMinutes:(NSInteger)maxRelativeDurationMinutes; ++ (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 841ea5ffa..2c92f1c2d 100644 --- a/Signal/src/util/DateUtil.m +++ b/Signal/src/util/DateUtil.m @@ -146,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); @@ -201,54 +219,117 @@ static NSString *const DATE_FORMAT_WEEKDAY = @"EEEE"; return dateTimeString.uppercaseString; } -+ (NSString *)formatTimestampAsTime:(uint64_t)timestamp maxRelativeDurationMinutes:(NSInteger)maxRelativeDurationMinutes ++ (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]; - NSDate *now = [NSDate new]; + uint64_t nowTimestamp = [NSDate ows_millisecondTimeStamp]; + NSDate *nowDate = [NSDate ows_dateWithMillisecondsSince1970:nowTimestamp]; NSCalendar *calendar = [NSCalendar currentCalendar]; - NSInteger minutesDiff - = MAX(0, [[calendar components:NSCalendarUnitMinute fromDate:date toDate:now options:0] minute]); - // Treat anything in the last two minutes as "now", so that we - // don't have to worry about pluralization while formatting. - if (minutesDiff <= 1) { - return NSLocalizedString(@"DATE_NOW", @"The present; the current time."); - } else if (minutesDiff <= maxRelativeDurationMinutes) { + 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]; - 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]; + 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 { - return [self formatDateAsTime:date]; + 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)isTimestampRelative:(uint64_t)timestamp maxRelativeDurationMinutes:(NSInteger)maxRelativeDurationMinutes ++ (BOOL)isTimestampFromLastHour:(uint64_t)timestamp { NSDate *date = [NSDate ows_dateWithMillisecondsSince1970:timestamp]; - NSDate *now = [NSDate new]; + uint64_t nowTimestamp = [NSDate ows_millisecondTimeStamp]; + NSDate *nowDate = [NSDate ows_dateWithMillisecondsSince1970:nowTimestamp]; NSCalendar *calendar = [NSCalendar currentCalendar]; - NSInteger minutesDiff - = MAX(0, [[calendar components:NSCalendarUnitMinute fromDate:date toDate:now options:0] minute]); - return minutesDiff <= maxRelativeDurationMinutes; + 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."); + return NSLocalizedString(@"DATE_NOW", @"The present; the current time.").uppercaseString; } -+ (NSString *)exemplaryRelativeTimeFormatWithMaxRelativeDurationMinutes:(NSInteger)maxRelativeDurationMinutes ++ (NSString *)exemplaryMinutesTimeFormat { - NSString *minutesString = [OWSFormat formatInt:(int)maxRelativeDurationMinutes]; + 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]; + minutesString] + .uppercaseString; } + (BOOL)isSameDayWithTimestamp:(uint64_t)timestamp1 timestamp:(uint64_t)timestamp2 diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 5f4c4ea23..cc95d4978 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -611,7 +611,10 @@ /* Title shown while the app is updating its database. */ "DATABASE_VIEW_OVERLAY_TITLE" = "Optimizing Database"; -/* Format string for a duration of time, expressed as a certain number of minutes. Embeds {{The number of minutes}}. */ +/* 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. */