You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-ios/Signal/src/ViewControllers/HomeView/HomeViewCell.m

593 lines
23 KiB
Objective-C

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

//
// 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 threadID:thread.threadRecord.uniqueId];
[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