session-ios/Signal/src/ViewControllers/HomeView/HomeViewCell.m

593 lines
23 KiB
Objective-C
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import "HomeViewCell.h"
#import "OWSAvatarBuilder.h"
#import "Session-Swift.h"
#import <SignalMessaging/OWSFormat.h>
#import <SignalMessaging/OWSUserProfile.h>
#import <SignalMessaging/SignalMessaging-Swift.h>
#import <SignalServiceKit/OWSMath.h>
#import <SignalServiceKit/OWSMessageManager.h>
#import <SignalServiceKit/TSContactThread.h>
#import <SignalServiceKit/TSGroupThread.h>
NS_ASSUME_NONNULL_BEGIN
@interface HomeViewCell ()
@property (nonatomic) AvatarImageView *avatarView;
@property (nonatomic) UILabel *nameLabel;
@property (nonatomic) UILabel *snippetLabel;
@property (nonatomic) UILabel *dateTimeLabel;
@property (nonatomic) MessageStatusView *messageStatusView;
@property (nonatomic) TypingIndicatorView *typingIndicatorView;
@property (nonatomic) UIView *unreadBadge;
@property (nonatomic) UILabel *unreadLabel;
@property (nonatomic, nullable) ThreadViewModel *thread;
@property (nonatomic, nullable) NSAttributedString *overrideSnippet;
@property (nonatomic) BOOL isBlocked;
@property (nonatomic, readonly) NSMutableArray<NSLayoutConstraint *> *viewConstraints;
@end
#pragma mark -
@implementation HomeViewCell
#pragma mark - Dependencies
- (OWSContactsManager *)contactsManager
{
OWSAssertDebug(Environment.shared.contactsManager);
return Environment.shared.contactsManager;
}
- (id<OWSTypingIndicators>)typingIndicators
{
return SSKEnvironment.shared.typingIndicators;
}
- (TSAccountManager *)tsAccountManager
{
OWSAssertDebug(SSKEnvironment.shared.tsAccountManager);
return SSKEnvironment.shared.tsAccountManager;
}
#pragma mark -
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(nullable NSString *)reuseIdentifier
{
if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
[self commontInit];
}
return self;
}
// `[UIView init]` invokes `[self initWithFrame:...]`.
- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
[self commontInit];
}
return self;
}
- (void)commontInit
{
OWSAssertDebug(!self.avatarView);
self.backgroundColor = Theme.backgroundColor;
_viewConstraints = [NSMutableArray new];
self.avatarView = [[AvatarImageView alloc] init];
[self.contentView addSubview:self.avatarView];
[self.avatarView autoSetDimension:ALDimensionWidth toSize:self.avatarSize];
[self.avatarView autoSetDimension:ALDimensionHeight toSize:self.avatarSize];
[self.avatarView autoPinLeadingToSuperviewMargin];
[self.avatarView autoVCenterInSuperview];
[self.avatarView setContentHuggingHigh];
[self.avatarView setCompressionResistanceHigh];
// Ensure that the cell's contents never overflow the cell bounds.
[self.avatarView autoPinEdgeToSuperviewMargin:ALEdgeTop relation:NSLayoutRelationGreaterThanOrEqual];
[self.avatarView autoPinEdgeToSuperviewMargin:ALEdgeBottom relation:NSLayoutRelationGreaterThanOrEqual];
self.nameLabel = [UILabel new];
self.nameLabel.lineBreakMode = NSLineBreakByTruncatingTail;
self.nameLabel.font = self.nameFont;
[self.nameLabel setContentHuggingHorizontalLow];
[self.nameLabel setCompressionResistanceHorizontalLow];
self.dateTimeLabel = [UILabel new];
[self.dateTimeLabel setContentHuggingHorizontalHigh];
[self.dateTimeLabel setCompressionResistanceHorizontalHigh];
self.messageStatusView = [MessageStatusView new];
[self.messageStatusView setContentHuggingHorizontalHigh];
[self.messageStatusView setCompressionResistanceHorizontalHigh];
UIStackView *topRowView = [[UIStackView alloc] initWithArrangedSubviews:@[
self.nameLabel,
self.dateTimeLabel,
]];
topRowView.axis = UILayoutConstraintAxisHorizontal;
topRowView.alignment = UIStackViewAlignmentLastBaseline;
topRowView.spacing = 6.f;
self.snippetLabel = [UILabel new];
self.snippetLabel.font = [self snippetFont];
self.snippetLabel.numberOfLines = 1;
self.snippetLabel.lineBreakMode = NSLineBreakByTruncatingTail;
[self.snippetLabel setContentHuggingHorizontalLow];
[self.snippetLabel setCompressionResistanceHorizontalLow];
self.typingIndicatorView = [TypingIndicatorView new];
[self.contentView addSubview:self.typingIndicatorView];
UIStackView *bottomRowView = [[UIStackView alloc] initWithArrangedSubviews:@[
self.snippetLabel,
self.messageStatusView,
]];
bottomRowView.axis = UILayoutConstraintAxisHorizontal;
bottomRowView.alignment = UIStackViewAlignmentLastBaseline;
bottomRowView.spacing = 6.f;
UIStackView *vStackView = [[UIStackView alloc] initWithArrangedSubviews:@[
topRowView,
bottomRowView,
]];
vStackView.axis = UILayoutConstraintAxisVertical;
[self.contentView addSubview:vStackView];
[vStackView autoPinLeadingToTrailingEdgeOfView:self.avatarView offset:self.avatarHSpacing];
[vStackView autoVCenterInSuperview];
// Ensure that the cell's contents never overflow the cell bounds.
[vStackView autoPinEdgeToSuperviewMargin:ALEdgeTop relation:NSLayoutRelationGreaterThanOrEqual];
[vStackView autoPinEdgeToSuperviewMargin:ALEdgeBottom relation:NSLayoutRelationGreaterThanOrEqual];
[vStackView autoPinTrailingToSuperviewMargin];
vStackView.userInteractionEnabled = NO;
self.unreadLabel = [UILabel new];
self.unreadLabel.textColor = [UIColor ows_whiteColor];
self.unreadLabel.lineBreakMode = NSLineBreakByTruncatingTail;
self.unreadLabel.textAlignment = NSTextAlignmentCenter;
[self.unreadLabel setContentHuggingHigh];
[self.unreadLabel setCompressionResistanceHigh];
self.unreadBadge = [NeverClearView new];
self.unreadBadge.backgroundColor = [UIColor ows_materialBlueColor];
[self.unreadBadge addSubview:self.unreadLabel];
[self.unreadLabel autoCenterInSuperview];
[self.unreadBadge setContentHuggingHigh];
[self.unreadBadge setCompressionResistanceHigh];
[self.contentView addSubview:self.unreadBadge];
[self.unreadBadge autoAlignAxis:ALAxisHorizontal toSameAxisOfView:self.nameLabel];
[self.typingIndicatorView autoPinEdge:ALEdgeLeading toEdge:ALEdgeLeading ofView:self.snippetLabel];
[self.typingIndicatorView autoAlignAxis:ALAxisHorizontal toSameAxisOfView:self.snippetLabel];
}
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
+ (NSString *)cellReuseIdentifier
{
return NSStringFromClass([self class]);
}
- (void)initializeLayout
{
self.selectionStyle = UITableViewCellSelectionStyleDefault;
}
- (nullable NSString *)reuseIdentifier
{
return NSStringFromClass(self.class);
}
- (void)configureWithThread:(ThreadViewModel *)thread
isBlocked:(BOOL)isBlocked
{
[self configureWithThread:thread
isBlocked:isBlocked
overrideSnippet:nil
overrideDate:nil];
}
- (void)configureWithThread:(ThreadViewModel *)thread
isBlocked:(BOOL)isBlocked
overrideSnippet:(nullable NSAttributedString *)overrideSnippet
overrideDate:(nullable NSDate *)overrideDate
{
OWSAssertIsOnMainThread();
OWSAssertDebug(thread);
[OWSTableItem configureCell:self];
self.thread = thread;
self.overrideSnippet = overrideSnippet;
self.isBlocked = isBlocked;
BOOL hasUnreadMessages = thread.hasUnreadMessages;
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(otherUsersProfileDidChange:)
name:kNSNotificationName_OtherUsersProfileDidChange
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(typingIndicatorStateDidChange:)
name:[OWSTypingIndicatorsImpl typingIndicatorStateDidChange]
object:nil];
[self updateNameLabel];
[self updateAvatarView];
// We update the fonts every time this cell is configured to ensure that
// changes to the dynamic type settings are reflected.
self.snippetLabel.font = [self snippetFont];
[self updatePreview];
self.dateTimeLabel.text
= (overrideDate ? [self stringForDate:overrideDate] : [self stringForDate:thread.lastMessageDate]);
UIColor *textColor = [Theme secondaryColor];
if (hasUnreadMessages && overrideSnippet == nil) {
textColor = [Theme primaryColor];
self.dateTimeLabel.font = self.dateTimeFont.ows_mediumWeight;
} else {
self.dateTimeLabel.font = self.dateTimeFont;
}
self.dateTimeLabel.textColor = textColor;
NSUInteger unreadCount = thread.unreadCount;
if (overrideSnippet) {
// If we're using the home view cell to render search results,
// don't show "unread badge" or "message status" indicator.
self.unreadBadge.hidden = YES;
self.messageStatusView.hidden = YES;
} else if (unreadCount > 0) {
// If there are unread messages, show the "unread badge."
// The "message status" indicators is redundant.
self.unreadBadge.hidden = NO;
self.messageStatusView.hidden = YES;
self.unreadLabel.text = [OWSFormat formatInt:(int)unreadCount];
self.unreadLabel.font = self.unreadFont;
const int unreadBadgeHeight = (int)ceil(self.unreadLabel.font.lineHeight * 1.5f);
self.unreadBadge.layer.cornerRadius = unreadBadgeHeight / 2;
self.unreadBadge.layer.borderColor = Theme.backgroundColor.CGColor;
self.unreadBadge.layer.borderWidth = 1.f;
[NSLayoutConstraint autoSetPriority:UILayoutPriorityDefaultHigh
forConstraints:^{
// This is a bit arbitrary, but it should scale with the size of dynamic text
CGFloat minMargin = CeilEven(unreadBadgeHeight * .5f);
// Spec check. Should be 12pts (6pt on each side) when using default font size.
OWSAssertDebug(UIFont.ows_dynamicTypeBodyFont.pointSize != 17 || minMargin == 12);
[self.viewConstraints addObjectsFromArray:@[
// badge sizing
[self.unreadBadge autoMatchDimension:ALDimensionWidth
toDimension:ALDimensionWidth
ofView:self.unreadLabel
withOffset:minMargin
relation:NSLayoutRelationGreaterThanOrEqual],
[self.unreadBadge autoSetDimension:ALDimensionWidth
toSize:unreadBadgeHeight
relation:NSLayoutRelationGreaterThanOrEqual],
[self.unreadBadge autoSetDimension:ALDimensionHeight toSize:unreadBadgeHeight],
[self.unreadBadge autoPinEdge:ALEdgeTrailing
toEdge:ALEdgeTrailing
ofView:self.avatarView
withOffset:6.f],
]];
}];
} else {
UIImage *_Nullable statusIndicatorImage = nil;
// TODO: Theme, Review with design.
UIColor *messageStatusViewTintColor
= (Theme.isDarkThemeEnabled ? [UIColor ows_gray25Color] : [UIColor ows_gray45Color]);
BOOL shouldAnimateStatusIcon = NO;
if ([self.thread.lastMessageForInbox isKindOfClass:[TSOutgoingMessage class]]) {
TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.thread.lastMessageForInbox;
MessageReceiptStatus messageStatus =
[MessageRecipientStatusUtils recipientStatusWithOutgoingMessage:outgoingMessage];
switch (messageStatus) {
case MessageReceiptStatusCalculatingPoW:
statusIndicatorImage = [UIImage imageNamed:@"Cog"];
shouldAnimateStatusIcon = YES;
break;
case MessageReceiptStatusUploading:
case MessageReceiptStatusSending:
statusIndicatorImage = [UIImage imageNamed:@"message_status_sending"];
shouldAnimateStatusIcon = YES;
break;
case MessageReceiptStatusSent:
case MessageReceiptStatusSkipped:
statusIndicatorImage = [UIImage imageNamed:@"message_status_sent"];
break;
case MessageReceiptStatusDelivered:
statusIndicatorImage = [UIImage imageNamed:@"message_status_delivered"];
break;
case MessageReceiptStatusRead:
statusIndicatorImage = [UIImage imageNamed:@"message_status_read"];
break;
case MessageReceiptStatusFailed:
statusIndicatorImage = [UIImage imageNamed:@"message_status_failed"];
messageStatusViewTintColor = [UIColor ows_destructiveRedColor];
break;
}
}
self.messageStatusView.image = [statusIndicatorImage imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
self.messageStatusView.tintColor = messageStatusViewTintColor;
self.messageStatusView.hidden = statusIndicatorImage == nil;
self.unreadBadge.hidden = YES;
if (shouldAnimateStatusIcon) {
CABasicAnimation *animation;
animation = [CABasicAnimation animationWithKeyPath:@"transform.rotation.z"];
animation.toValue = @(M_PI * 2.0);
const CGFloat kPeriodSeconds = 1.f;
animation.duration = kPeriodSeconds;
animation.cumulative = YES;
animation.repeatCount = HUGE_VALF;
[self.messageStatusView.layer addAnimation:animation forKey:@"animation"];
} else {
[self.messageStatusView.layer removeAllAnimations];
}
}
}
- (void)updateAvatarView
{
ThreadViewModel *thread = self.thread;
if (thread == nil) {
OWSFailDebug(@"thread should not be nil");
self.avatarView.image = nil;
return;
}
self.avatarView.contactID = thread.contactIdentifier;
[self.avatarView updateOnlineStatusIndicator];
self.avatarView.image = [OWSAvatarBuilder buildImageForThread:thread.threadRecord diameter:self.avatarSize];
}
- (NSAttributedString *)attributedSnippetForThread:(ThreadViewModel *)thread isBlocked:(BOOL)isBlocked
{
OWSAssertDebug(thread);
BOOL hasUnreadMessages = thread.hasUnreadMessages;
NSMutableAttributedString *snippetText = [NSMutableAttributedString new];
if (isBlocked) {
// If thread is blocked, don't show a snippet or mute status.
[snippetText appendAttributedString:
[[NSAttributedString alloc]
initWithString:NSLocalizedString(@"HOME_VIEW_BLOCKED_CONVERSATION",
@"Table cell subtitle label for a conversation the user has blocked.")
attributes:@{
NSFontAttributeName : self.snippetFont.ows_mediumWeight,
NSForegroundColorAttributeName : [Theme primaryColor],
}]];
} else {
if (thread.isMuted && !thread.isGroupThread) {
[snippetText appendAttributedString:[[NSAttributedString alloc]
initWithString:LocalizationNotNeeded(@"\ue067 ")
attributes:@{
NSFontAttributeName : [UIFont ows_elegantIconsFont:9.f],
NSForegroundColorAttributeName :
(hasUnreadMessages ? [Theme primaryColor]
: [Theme secondaryColor]),
}]];
}
NSString *displayableText = thread.lastMessageText;
if (displayableText) {
[LKAPI populateUserHexEncodedPublicKeyCacheIfNeededFor:thread.threadRecord.uniqueId in:nil]; // TODO: Terrible place to do this, but okay for now
displayableText = [LKMentionUtilities highlightMentionsIn:displayableText thread:thread.threadRecord];
[snippetText appendAttributedString:[[NSAttributedString alloc]
initWithString:displayableText
attributes:@{
NSFontAttributeName :
(hasUnreadMessages ? self.snippetFont.ows_mediumWeight
: self.snippetFont),
NSForegroundColorAttributeName :
(hasUnreadMessages ? [Theme primaryColor]
: [Theme secondaryColor]),
}]];
}
}
return snippetText;
}
#pragma mark - Date formatting
- (NSString *)stringForDate:(nullable NSDate *)date
{
if (date == nil) {
OWSFailDebug(@"date was unexpectedly nil");
return @"";
}
return [DateUtil formatDateShort:date];
}
#pragma mark - Constants
- (UIFont *)unreadFont
{
return [UIFont ows_dynamicTypeCaption1Font].ows_mediumWeight;
}
- (UIFont *)dateTimeFont
{
return [UIFont ows_dynamicTypeCaption1Font];
}
- (UIFont *)snippetFont
{
return [UIFont ows_dynamicTypeSubheadlineFont];
}
- (UIFont *)nameFont
{
return [UIFont ows_dynamicTypeBodyFont].ows_mediumWeight;
}
// Used for profile names.
- (UIFont *)nameSecondaryFont
{
return [UIFont ows_dynamicTypeBodyFont].ows_italic;
}
- (NSUInteger)avatarSize
{
return kStandardAvatarSize;
}
- (NSUInteger)avatarHSpacing
{
return 12.f;
}
#pragma mark - Reuse
- (void)prepareForReuse
{
[super prepareForReuse];
[NSLayoutConstraint deactivateConstraints:self.viewConstraints];
[self.viewConstraints removeAllObjects];
self.thread = nil;
self.overrideSnippet = nil;
self.avatarView.image = nil;
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
#pragma mark - Name
- (void)otherUsersProfileDidChange:(NSNotification *)notification
{
OWSAssertIsOnMainThread();
NSString *recipientId = notification.userInfo[kNSNotificationKey_ProfileRecipientId];
if (recipientId.length == 0) {
return;
}
if (![self.thread isKindOfClass:[TSContactThread class]]) {
return;
}
if (![self.thread.contactIdentifier isEqualToString:recipientId]) {
return;
}
[self updateNameLabel];
[self updateAvatarView];
}
- (void)updateNameLabel
{
OWSAssertIsOnMainThread();
self.nameLabel.font = self.nameFont;
self.nameLabel.textColor = [Theme primaryColor];
ThreadViewModel *thread = self.thread;
if (thread == nil) {
OWSFailDebug(@"thread should not be nil");
self.nameLabel.attributedText = nil;
return;
}
NSAttributedString *name;
if (thread.isGroupThread) {
if (thread.name.length == 0) {
name = [[NSAttributedString alloc] initWithString:[MessageStrings newGroupDefaultTitle]];
} else {
name = [[NSAttributedString alloc] initWithString:thread.name];
}
} else {
if (self.thread.threadRecord.isNoteToSelf) {
name = [[NSAttributedString alloc]
initWithString:NSLocalizedString(@"NOTE_TO_SELF", @"Label for 1:1 conversation with yourself.")
attributes:@{
NSFontAttributeName : self.nameFont,
}];
} else {
name = [self.contactsManager attributedContactOrProfileNameForPhoneIdentifier:thread.contactIdentifier
primaryFont:self.nameFont
secondaryFont:self.nameSecondaryFont];
BOOL hasCheckmark = self.thread.threadRecord.isContactFriend;
if (hasCheckmark) {
NSMutableAttributedString *checkmark = [[NSMutableAttributedString alloc] initWithString:@""];
[checkmark beginEditing];
[checkmark addAttribute:NSForegroundColorAttributeName value:UIColor.lokiGreen range:NSMakeRange(0, 1)];
[checkmark endEditing];
NSMutableAttributedString *mutableName = [name mutableCopy];
[mutableName appendAttributedString:[[NSAttributedString alloc] initWithString:@" "]];
[mutableName appendAttributedString:checkmark];
name = [mutableName copy];
}
}
}
self.nameLabel.attributedText = name;
}
#pragma mark - Typing Indicators
- (void)updatePreview
{
if ([self.typingIndicators typingRecipientIdForThread:self.thread.threadRecord] != nil) {
// If we hide snippetLabel, our layout will break since UIStackView will remove
// it from the layout. Wrapping the preview views (the snippet label and the
// typing indicator) in a UIStackView proved non-trivial since we're using
// UIStackViewAlignmentLastBaseline. Therefore we hide the _contents_ of the
// snippet label using an empty string.
self.snippetLabel.text = @" ";
self.typingIndicatorView.hidden = NO;
[self.typingIndicatorView startAnimation];
} else {
if (self.overrideSnippet) {
self.snippetLabel.attributedText = self.overrideSnippet;
} else {
self.snippetLabel.attributedText = [self attributedSnippetForThread:self.thread isBlocked:self.isBlocked];
}
self.typingIndicatorView.hidden = YES;
[self.typingIndicatorView stopAnimation];
}
}
- (void)typingIndicatorStateDidChange:(NSNotification *)notification
{
OWSAssertIsOnMainThread();
OWSAssertDebug(self.thread);
if (notification.object && ![notification.object isEqual:self.thread.threadRecord.uniqueId]) {
return;
}
[self updatePreview];
}
@end
NS_ASSUME_NONNULL_END