mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
7fd3d665dc
// FREEBIE
1075 lines
41 KiB
Objective-C
1075 lines
41 KiB
Objective-C
//
|
|
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
#import "HomeViewController.h"
|
|
#import "AppDelegate.h"
|
|
#import "AppSettingsViewController.h"
|
|
#import "ConversationViewController.h"
|
|
#import "InboxTableViewCell.h"
|
|
#import "NewContactThreadViewController.h"
|
|
#import "OWSContactsManager.h"
|
|
#import "OWSNavigationController.h"
|
|
#import "ProfileViewController.h"
|
|
#import "PushManager.h"
|
|
#import "Signal-Swift.h"
|
|
#import "TSAccountManager.h"
|
|
#import "TSDatabaseView.h"
|
|
#import "TSGroupThread.h"
|
|
#import "TSStorageManager.h"
|
|
#import "UIUtil.h"
|
|
#import "VersionMigrations.h"
|
|
#import "ViewControllerUtils.h"
|
|
#import <PromiseKit/AnyPromise.h>
|
|
#import <SignalServiceKit/NSDate+OWS.h>
|
|
#import <SignalServiceKit/OWSBlockingManager.h>
|
|
#import <SignalServiceKit/OWSMessageSender.h>
|
|
#import <SignalServiceKit/TSOutgoingMessage.h>
|
|
#import <SignalServiceKit/Threading.h>
|
|
#import <YapDatabase/YapDatabaseViewChange.h>
|
|
#import <YapDatabase/YapDatabaseViewConnection.h>
|
|
|
|
typedef NS_ENUM(NSInteger, CellState) { kArchiveState, kInboxState };
|
|
|
|
@interface HomeViewController () <UITableViewDelegate, UITableViewDataSource, UIViewControllerPreviewingDelegate>
|
|
|
|
@property (nonatomic) UITableView *tableView;
|
|
@property (nonatomic) UILabel *emptyBoxLabel;
|
|
|
|
@property (nonatomic) YapDatabaseConnection *editingDbConnection;
|
|
@property (nonatomic) YapDatabaseConnection *uiDatabaseConnection;
|
|
@property (nonatomic) YapDatabaseViewMappings *threadMappings;
|
|
@property (nonatomic) CellState viewingThreadsIn;
|
|
@property (nonatomic) long inboxCount;
|
|
@property (nonatomic) UISegmentedControl *segmentedControl;
|
|
@property (nonatomic) id previewingContext;
|
|
@property (nonatomic) NSSet<NSString *> *blockedPhoneNumberSet;
|
|
@property (nonatomic) BOOL viewHasEverAppeared;
|
|
|
|
@property (nonatomic) BOOL isViewVisible;
|
|
@property (nonatomic) BOOL isAppInBackground;
|
|
@property (nonatomic) BOOL shouldObserveDBModifications;
|
|
@property (nonatomic) BOOL hasBeenPresented;
|
|
|
|
// Dependencies
|
|
|
|
@property (nonatomic, readonly) AccountManager *accountManager;
|
|
@property (nonatomic, readonly) OWSContactsManager *contactsManager;
|
|
@property (nonatomic, readonly) ExperienceUpgradeFinder *experienceUpgradeFinder;
|
|
@property (nonatomic, readonly) OWSMessageManager *messagesManager;
|
|
@property (nonatomic, readonly) OWSMessageSender *messageSender;
|
|
@property (nonatomic, readonly) OWSBlockingManager *blockingManager;
|
|
|
|
// Views
|
|
|
|
@property (nonatomic) NSLayoutConstraint *hideArchiveReminderViewConstraint;
|
|
@property (nonatomic) NSLayoutConstraint *hideMissingContactsPermissionViewConstraint;
|
|
|
|
@property (nonatomic) TSThread *lastThread;
|
|
|
|
@end
|
|
|
|
#pragma mark -
|
|
|
|
@implementation HomeViewController
|
|
|
|
#pragma mark - Init
|
|
|
|
- (instancetype)init
|
|
{
|
|
self = [super init];
|
|
if (!self) {
|
|
return self;
|
|
}
|
|
|
|
[self commonInit];
|
|
|
|
return self;
|
|
}
|
|
|
|
- (instancetype)initWithCoder:(NSCoder *)aDecoder
|
|
{
|
|
OWSFail(@"Do not load this from the storyboard.");
|
|
|
|
self = [super initWithCoder:aDecoder];
|
|
if (!self) {
|
|
return self;
|
|
}
|
|
|
|
[self commonInit];
|
|
|
|
return self;
|
|
}
|
|
|
|
- (void)commonInit
|
|
{
|
|
_accountManager = [Environment getCurrent].accountManager;
|
|
_contactsManager = [Environment getCurrent].contactsManager;
|
|
_messagesManager = [OWSMessageManager sharedManager];
|
|
_messageSender = [Environment getCurrent].messageSender;
|
|
_blockingManager = [OWSBlockingManager sharedManager];
|
|
_blockedPhoneNumberSet = [NSSet setWithArray:[_blockingManager blockedPhoneNumbers]];
|
|
|
|
_experienceUpgradeFinder = [ExperienceUpgradeFinder new];
|
|
|
|
[[NSNotificationCenter defaultCenter] addObserver:self
|
|
selector:@selector(blockedPhoneNumbersDidChange:)
|
|
name:kNSNotificationName_BlockedPhoneNumbersDidChange
|
|
object:nil];
|
|
[[NSNotificationCenter defaultCenter] addObserver:self
|
|
selector:@selector(signalAccountsDidChange:)
|
|
name:OWSContactsManagerSignalAccountsDidChangeNotification
|
|
object:nil];
|
|
[[NSNotificationCenter defaultCenter] addObserver:self
|
|
selector:@selector(applicationWillEnterForeground:)
|
|
name:UIApplicationWillEnterForegroundNotification
|
|
object:nil];
|
|
[[NSNotificationCenter defaultCenter] addObserver:self
|
|
selector:@selector(applicationDidEnterBackground:)
|
|
name:UIApplicationDidEnterBackgroundNotification
|
|
object:nil];
|
|
[[NSNotificationCenter defaultCenter] addObserver:self
|
|
selector:@selector(applicationDidBecomeActive:)
|
|
name:UIApplicationDidBecomeActiveNotification
|
|
object:nil];
|
|
[[NSNotificationCenter defaultCenter] addObserver:self
|
|
selector:@selector(yapDatabaseModified:)
|
|
name:YapDatabaseModifiedNotification
|
|
object:nil];
|
|
}
|
|
|
|
- (void)dealloc
|
|
{
|
|
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
|
}
|
|
|
|
#pragma mark - Notifications
|
|
|
|
- (void)blockedPhoneNumbersDidChange:(id)notification
|
|
{
|
|
OWSAssert([NSThread isMainThread]);
|
|
|
|
_blockedPhoneNumberSet = [NSSet setWithArray:[_blockingManager blockedPhoneNumbers]];
|
|
|
|
[self.tableView reloadData];
|
|
}
|
|
|
|
- (void)signalAccountsDidChange:(id)notification
|
|
{
|
|
OWSAssert([NSThread isMainThread]);
|
|
|
|
[self.tableView reloadData];
|
|
}
|
|
|
|
#pragma mark - View Life Cycle
|
|
|
|
- (void)loadView
|
|
{
|
|
[super loadView];
|
|
|
|
self.view.backgroundColor = [UIColor whiteColor];
|
|
|
|
// TODO: Remove this.
|
|
[[Environment getCurrent] setHomeViewController:self];
|
|
|
|
self.navigationItem.rightBarButtonItem =
|
|
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemCompose
|
|
target:self
|
|
action:@selector(showNewConversationView)];
|
|
|
|
ReminderView *archiveReminderView = [ReminderView new];
|
|
archiveReminderView.text = NSLocalizedString(
|
|
@"INBOX_VIEW_ARCHIVE_MODE_REMINDER", @"Label reminding the user that they are in archive mode.");
|
|
__weak HomeViewController *weakSelf = self;
|
|
archiveReminderView.tapAction = ^{
|
|
[weakSelf showInboxGrouping];
|
|
};
|
|
[self.view addSubview:archiveReminderView];
|
|
[archiveReminderView autoPinWidthToSuperview];
|
|
[archiveReminderView autoPinToTopLayoutGuideOfViewController:self withInset:0];
|
|
self.hideArchiveReminderViewConstraint = [archiveReminderView autoSetDimension:ALDimensionHeight toSize:0];
|
|
self.hideArchiveReminderViewConstraint.priority = UILayoutPriorityRequired;
|
|
|
|
ReminderView *missingContactsPermissionView = [ReminderView new];
|
|
missingContactsPermissionView.text = NSLocalizedString(@"INBOX_VIEW_MISSING_CONTACTS_PERMISSION",
|
|
@"Multiline label explaining how to show names instead of phone numbers in your inbox");
|
|
missingContactsPermissionView.tapAction = ^{
|
|
[[UIApplication sharedApplication] openSystemSettings];
|
|
};
|
|
[self.view addSubview:missingContactsPermissionView];
|
|
[missingContactsPermissionView autoPinWidthToSuperview];
|
|
[missingContactsPermissionView autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:archiveReminderView];
|
|
self.hideMissingContactsPermissionViewConstraint =
|
|
[missingContactsPermissionView autoSetDimension:ALDimensionHeight toSize:0];
|
|
self.hideMissingContactsPermissionViewConstraint.priority = UILayoutPriorityRequired;
|
|
|
|
self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain];
|
|
self.tableView.delegate = self;
|
|
self.tableView.dataSource = self;
|
|
[self.tableView registerClass:[InboxTableViewCell class]
|
|
forCellReuseIdentifier:InboxTableViewCell.cellReuseIdentifier];
|
|
[self.view addSubview:self.tableView];
|
|
[self.tableView autoPinWidthToSuperview];
|
|
[self.tableView autoPinToBottomLayoutGuideOfViewController:self withInset:0];
|
|
[self.tableView autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:missingContactsPermissionView];
|
|
|
|
UILabel *emptyBoxLabel = [UILabel new];
|
|
self.emptyBoxLabel = emptyBoxLabel;
|
|
[self.view addSubview:emptyBoxLabel];
|
|
[emptyBoxLabel autoPinWidthToSuperview];
|
|
[emptyBoxLabel autoPinToTopLayoutGuideOfViewController:self withInset:0];
|
|
[emptyBoxLabel autoPinToBottomLayoutGuideOfViewController:self withInset:0];
|
|
|
|
UIRefreshControl *pullToRefreshView = [UIRefreshControl new];
|
|
pullToRefreshView.tintColor = [UIColor grayColor];
|
|
[pullToRefreshView addTarget:self
|
|
action:@selector(pullToRefreshPerformed:)
|
|
forControlEvents:UIControlEventValueChanged];
|
|
[self.tableView insertSubview:pullToRefreshView atIndex:0];
|
|
|
|
[self updateReminderViews];
|
|
}
|
|
|
|
- (void)updateReminderViews
|
|
{
|
|
BOOL shouldHideArchiveReminderView = self.viewingThreadsIn != kArchiveState;
|
|
BOOL shouldHideMissingContactsPermissionView = !self.shouldShowMissingContactsPermissionView;
|
|
if (self.hideArchiveReminderViewConstraint.active == shouldHideArchiveReminderView
|
|
&& self.hideMissingContactsPermissionViewConstraint.active == shouldHideMissingContactsPermissionView) {
|
|
return;
|
|
}
|
|
self.hideArchiveReminderViewConstraint.active = shouldHideArchiveReminderView;
|
|
self.hideMissingContactsPermissionViewConstraint.active = shouldHideMissingContactsPermissionView;
|
|
[self.view setNeedsLayout];
|
|
[self.view layoutSubviews];
|
|
}
|
|
|
|
- (void)viewDidLoad
|
|
{
|
|
[super viewDidLoad];
|
|
[self.navigationController.navigationBar setTranslucent:NO];
|
|
|
|
self.editingDbConnection = TSStorageManager.sharedManager.newDatabaseConnection;
|
|
|
|
// Create the database connection.
|
|
[self uiDatabaseConnection];
|
|
|
|
[self showInboxGrouping];
|
|
|
|
// because this uses the table data source, `tableViewSetup` must happen
|
|
// after mappings have been set up in `showInboxGrouping`
|
|
[self tableViewSetUp];
|
|
|
|
self.segmentedControl = [[UISegmentedControl alloc] initWithItems:@[
|
|
NSLocalizedString(@"WHISPER_NAV_BAR_TITLE", nil),
|
|
NSLocalizedString(@"ARCHIVE_NAV_BAR_TITLE", nil)
|
|
]];
|
|
|
|
[self.segmentedControl addTarget:self
|
|
action:@selector(swappedSegmentedControl)
|
|
forControlEvents:UIControlEventValueChanged];
|
|
UINavigationItem *navigationItem = self.navigationItem;
|
|
navigationItem.titleView = self.segmentedControl;
|
|
[self.segmentedControl setSelectedSegmentIndex:0];
|
|
navigationItem.leftBarButtonItem.accessibilityLabel
|
|
= NSLocalizedString(@"SETTINGS_BUTTON_ACCESSIBILITY", @"Accessibility hint for the settings button");
|
|
|
|
if ([self.traitCollection respondsToSelector:@selector(forceTouchCapability)]
|
|
&& (self.traitCollection.forceTouchCapability == UIForceTouchCapabilityAvailable)) {
|
|
[self registerForPreviewingWithDelegate:self sourceView:self.tableView];
|
|
}
|
|
|
|
[self updateBarButtonItems];
|
|
}
|
|
|
|
- (void)updateBarButtonItems
|
|
{
|
|
const CGFloat kBarButtonSize = 44;
|
|
// We use UIButtons with [UIBarButtonItem initWithCustomView:...] instead of
|
|
// UIBarButtonItem in order to ensure that these buttons are spaced tightly.
|
|
// The contents of the navigation bar are cramped in this view.
|
|
UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom];
|
|
UIImage *image = [UIImage imageNamed:@"button_settings_white"];
|
|
[button setImage:image forState:UIControlStateNormal];
|
|
UIEdgeInsets imageEdgeInsets = UIEdgeInsetsZero;
|
|
// We normally would want to use left and right insets that ensure the button
|
|
// is square and the icon is centered. However UINavigationBar doesn't offer us
|
|
// control over the margins and spacing of its content, and the buttons end up
|
|
// too far apart and too far from the edge of the screen. So we use a smaller
|
|
// leading inset tighten up the layout.
|
|
CGFloat hInset = round((kBarButtonSize - image.size.width) * 0.5f);
|
|
if (self.view.isRTL) {
|
|
imageEdgeInsets.right = hInset;
|
|
imageEdgeInsets.left = round((kBarButtonSize - (image.size.width + hInset)) * 0.5f);
|
|
} else {
|
|
imageEdgeInsets.left = hInset;
|
|
imageEdgeInsets.right = round((kBarButtonSize - (image.size.width + hInset)) * 0.5f);
|
|
}
|
|
imageEdgeInsets.top = round((kBarButtonSize - image.size.height) * 0.5f);
|
|
imageEdgeInsets.bottom = round(kBarButtonSize - (image.size.height + imageEdgeInsets.top));
|
|
button.imageEdgeInsets = imageEdgeInsets;
|
|
button.accessibilityLabel
|
|
= NSLocalizedString(@"OPEN_SETTINGS_BUTTON", "Label for button which opens the settings UI");
|
|
[button addTarget:self action:@selector(settingsButtonPressed:) forControlEvents:UIControlEventTouchUpInside];
|
|
button.frame = CGRectMake(0,
|
|
0,
|
|
round(image.size.width + imageEdgeInsets.left + imageEdgeInsets.right),
|
|
round(image.size.height + imageEdgeInsets.top + imageEdgeInsets.bottom));
|
|
self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithCustomView:button];
|
|
}
|
|
|
|
- (void)settingsButtonPressed:(id)sender {
|
|
OWSNavigationController *navigationController = [AppSettingsViewController inModalNavigationController];
|
|
[self presentViewController:navigationController animated:YES completion:nil];
|
|
}
|
|
|
|
- (UIViewController *)previewingContext:(id<UIViewControllerPreviewing>)previewingContext
|
|
viewControllerForLocation:(CGPoint)location
|
|
{
|
|
NSIndexPath *indexPath = [self.tableView indexPathForRowAtPoint:location];
|
|
|
|
if (indexPath) {
|
|
[previewingContext setSourceRect:[self.tableView rectForRowAtIndexPath:indexPath]];
|
|
|
|
ConversationViewController *vc = [ConversationViewController new];
|
|
TSThread *thread = [self threadForIndexPath:indexPath];
|
|
self.lastThread = thread;
|
|
[vc configureForThread:thread keyboardOnViewAppearing:NO callOnViewAppearing:NO];
|
|
[vc peekSetup];
|
|
|
|
return vc;
|
|
} else {
|
|
return nil;
|
|
}
|
|
}
|
|
|
|
- (void)previewingContext:(id<UIViewControllerPreviewing>)previewingContext
|
|
commitViewController:(UIViewController *)viewControllerToCommit
|
|
{
|
|
ConversationViewController *vc = (ConversationViewController *)viewControllerToCommit;
|
|
[vc popped];
|
|
|
|
[self.navigationController pushViewController:vc animated:NO];
|
|
}
|
|
|
|
- (void)showNewConversationView
|
|
{
|
|
NewContactThreadViewController *viewController = [NewContactThreadViewController new];
|
|
|
|
[self.contactsManager requestSystemContactsOnceWithCompletion:^(NSError *_Nullable error) {
|
|
if (error) {
|
|
DDLogError(@"%@ Error when requesting contacts: %@", self.logTag, error);
|
|
}
|
|
// Even if there is an error fetching contacts we proceed to the next screen.
|
|
// As the compose view will present the proper thing depending on contact access.
|
|
//
|
|
// We just want to make sure contact access is *complete* before showing the compose
|
|
// screen to avoid flicker.
|
|
OWSNavigationController *navigationController =
|
|
[[OWSNavigationController alloc] initWithRootViewController:viewController];
|
|
[self presentTopLevelModalViewController:navigationController animateDismissal:YES animatePresentation:YES];
|
|
}];
|
|
}
|
|
|
|
- (void)swappedSegmentedControl
|
|
{
|
|
if (self.segmentedControl.selectedSegmentIndex == 0) {
|
|
[self showInboxGrouping];
|
|
} else {
|
|
[self showArchiveGrouping];
|
|
}
|
|
}
|
|
|
|
- (void)viewWillAppear:(BOOL)animated
|
|
{
|
|
[super viewWillAppear:animated];
|
|
if ([TSThread numberOfKeysInCollection] > 0) {
|
|
[self.contactsManager requestSystemContactsOnceWithCompletion:^(NSError *_Nullable error) {
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
[self updateReminderViews];
|
|
});
|
|
}];
|
|
}
|
|
|
|
[self updateInboxCountLabel];
|
|
|
|
self.isViewVisible = YES;
|
|
|
|
// When returning to home view, try to ensure that the "last" thread is still
|
|
// visible. The threads often change ordering while in conversation view due
|
|
// to incoming & outgoing messages.
|
|
if (self.lastThread) {
|
|
__block NSIndexPath *indexPathOfLastThread = nil;
|
|
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
|
|
indexPathOfLastThread =
|
|
[[transaction extension:TSThreadDatabaseViewExtensionName] indexPathForKey:self.lastThread.uniqueId
|
|
inCollection:[TSThread collection]
|
|
withMappings:self.threadMappings];
|
|
}];
|
|
|
|
if (indexPathOfLastThread) {
|
|
[self.tableView scrollToRowAtIndexPath:indexPathOfLastThread
|
|
atScrollPosition:UITableViewScrollPositionNone
|
|
animated:NO];
|
|
}
|
|
}
|
|
|
|
[self checkIfEmptyView];
|
|
}
|
|
|
|
- (void)viewWillDisappear:(BOOL)animated
|
|
{
|
|
[super viewWillDisappear:animated];
|
|
|
|
self.isViewVisible = NO;
|
|
}
|
|
|
|
- (void)setIsViewVisible:(BOOL)isViewVisible
|
|
{
|
|
_isViewVisible = isViewVisible;
|
|
|
|
[self updateShouldObserveDBModifications];
|
|
}
|
|
|
|
- (void)setIsAppInBackground:(BOOL)isAppInBackground
|
|
{
|
|
_isAppInBackground = isAppInBackground;
|
|
|
|
[self updateShouldObserveDBModifications];
|
|
}
|
|
|
|
- (void)updateShouldObserveDBModifications
|
|
{
|
|
self.shouldObserveDBModifications = self.isViewVisible && !self.isAppInBackground;
|
|
}
|
|
|
|
- (void)setShouldObserveDBModifications:(BOOL)shouldObserveDBModifications
|
|
{
|
|
if (_shouldObserveDBModifications == shouldObserveDBModifications) {
|
|
return;
|
|
}
|
|
|
|
_shouldObserveDBModifications = shouldObserveDBModifications;
|
|
|
|
if (self.shouldObserveDBModifications) {
|
|
[self resetMappings];
|
|
}
|
|
}
|
|
|
|
- (void)resetMappings
|
|
{
|
|
// If we're entering "active" mode (e.g. view is visible and app is in foreground),
|
|
// reset all state updated by yapDatabaseModified:.
|
|
if (self.threadMappings != nil) {
|
|
// Before we begin observing database modifications, make sure
|
|
// our mapping and table state is up-to-date.
|
|
//
|
|
// We need to `beginLongLivedReadTransaction` before we update our
|
|
// mapping in order to jump to the most recent commit.
|
|
[self.uiDatabaseConnection beginLongLivedReadTransaction];
|
|
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
|
|
[self.threadMappings updateWithTransaction:transaction];
|
|
}];
|
|
}
|
|
|
|
[[self tableView] reloadData];
|
|
[self checkIfEmptyView];
|
|
[self updateInboxCountLabel];
|
|
|
|
// If the user hasn't already granted contact access
|
|
// we don't want to request until they receive a message.
|
|
if ([TSThread numberOfKeysInCollection] > 0) {
|
|
[self.contactsManager requestSystemContactsOnce];
|
|
}
|
|
}
|
|
|
|
- (void)applicationWillEnterForeground:(NSNotification *)notification
|
|
{
|
|
self.isAppInBackground = NO;
|
|
[self checkIfEmptyView];
|
|
}
|
|
|
|
- (void)applicationDidEnterBackground:(NSNotification *)notification
|
|
{
|
|
self.isAppInBackground = YES;
|
|
}
|
|
|
|
- (void)applicationDidBecomeActive:(NSNotification *)notification
|
|
{
|
|
// It's possible a thread was created while we where in the background. But since we don't honor contact
|
|
// requests unless the app is in the foregrond, we must check again here upon becoming active.
|
|
if ([TSThread numberOfKeysInCollection] > 0) {
|
|
[self.contactsManager requestSystemContactsOnceWithCompletion:^(NSError *_Nullable error) {
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
[self updateReminderViews];
|
|
});
|
|
}];
|
|
}
|
|
}
|
|
|
|
- (void)viewDidAppear:(BOOL)animated
|
|
{
|
|
[super viewDidAppear:animated];
|
|
|
|
if (!self.viewHasEverAppeared) {
|
|
self.viewHasEverAppeared = YES;
|
|
[self displayAnyUnseenUpgradeExperience];
|
|
}
|
|
}
|
|
|
|
#pragma mark - startup
|
|
|
|
- (NSArray<ExperienceUpgrade *> *)unseenUpgradeExperiences
|
|
{
|
|
AssertIsOnMainThread();
|
|
|
|
__block NSArray<ExperienceUpgrade *> *unseenUpgrades;
|
|
[self.editingDbConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
|
|
unseenUpgrades = [self.experienceUpgradeFinder allUnseenWithTransaction:transaction];
|
|
}];
|
|
return unseenUpgrades;
|
|
}
|
|
|
|
- (void)markAllUpgradeExperiencesAsSeen
|
|
{
|
|
AssertIsOnMainThread();
|
|
|
|
[self.editingDbConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) {
|
|
[self.experienceUpgradeFinder markAllAsSeenWithTransaction:transaction];
|
|
}];
|
|
}
|
|
|
|
- (void)displayAnyUnseenUpgradeExperience
|
|
{
|
|
AssertIsOnMainThread();
|
|
|
|
NSArray<ExperienceUpgrade *> *unseenUpgrades = [self unseenUpgradeExperiences];
|
|
|
|
if (unseenUpgrades.count > 0) {
|
|
ExperienceUpgradesPageViewController *experienceUpgradeViewController =
|
|
[[ExperienceUpgradesPageViewController alloc] initWithExperienceUpgrades:unseenUpgrades];
|
|
[self presentViewController:experienceUpgradeViewController
|
|
animated:YES
|
|
completion:^{
|
|
[self markAllUpgradeExperiencesAsSeen];
|
|
}];
|
|
} else if (!self.hasBeenPresented && [ProfileViewController shouldDisplayProfileViewOnLaunch]) {
|
|
[ProfileViewController presentForUpgradeOrNag:self];
|
|
}
|
|
|
|
self.hasBeenPresented = YES;
|
|
}
|
|
|
|
- (void)tableViewSetUp
|
|
{
|
|
self.tableView.tableFooterView = [[UIView alloc] initWithFrame:CGRectZero];
|
|
}
|
|
|
|
- (BOOL)shouldShowMissingContactsPermissionView
|
|
{
|
|
if (!self.contactsManager.systemContactsHaveBeenRequestedAtLeastOnce) {
|
|
return NO;
|
|
}
|
|
|
|
return !self.contactsManager.isSystemContactsAuthorized;
|
|
}
|
|
|
|
#pragma mark - Table View Data Source
|
|
|
|
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
|
|
{
|
|
return (NSInteger)[self.threadMappings numberOfSections];
|
|
}
|
|
|
|
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
|
|
{
|
|
return (NSInteger)[self.threadMappings numberOfItemsInSection:(NSUInteger)section];
|
|
}
|
|
|
|
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
|
|
{
|
|
InboxTableViewCell *cell =
|
|
[self.tableView dequeueReusableCellWithIdentifier:InboxTableViewCell.cellReuseIdentifier];
|
|
OWSAssert(cell);
|
|
|
|
TSThread *thread = [self threadForIndexPath:indexPath];
|
|
|
|
[cell configureWithThread:thread contactsManager:self.contactsManager blockedPhoneNumberSet:_blockedPhoneNumberSet];
|
|
|
|
if ((unsigned long)indexPath.row == [self.threadMappings numberOfItemsInSection:0] - 1) {
|
|
cell.separatorInset = UIEdgeInsetsMake(0.f, cell.bounds.size.width, 0.f, 0.f);
|
|
}
|
|
|
|
return cell;
|
|
}
|
|
|
|
- (TSThread *)threadForIndexPath:(NSIndexPath *)indexPath
|
|
{
|
|
__block TSThread *thread = nil;
|
|
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
|
|
thread = [[transaction extension:TSThreadDatabaseViewExtensionName] objectAtIndexPath:indexPath
|
|
withMappings:self.threadMappings];
|
|
}];
|
|
|
|
return thread;
|
|
}
|
|
|
|
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
|
|
{
|
|
return InboxTableViewCell.rowHeight;
|
|
}
|
|
|
|
- (void)pullToRefreshPerformed:(UIRefreshControl *)refreshControl
|
|
{
|
|
OWSAssert([NSThread isMainThread]);
|
|
DDLogInfo(@"%@ beggining refreshing.", self.logTag);
|
|
[[Environment getCurrent].messageFetcherJob run].always(^{
|
|
DDLogInfo(@"%@ ending refreshing.", self.logTag);
|
|
[refreshControl endRefreshing];
|
|
});
|
|
}
|
|
|
|
#pragma mark Table Swipe to Delete
|
|
|
|
- (void)tableView:(UITableView *)tableView
|
|
commitEditingStyle:(UITableViewCellEditingStyle)editingStyle
|
|
forRowAtIndexPath:(NSIndexPath *)indexPath
|
|
{
|
|
return;
|
|
}
|
|
|
|
- (NSArray *)tableView:(UITableView *)tableView editActionsForRowAtIndexPath:(NSIndexPath *)indexPath
|
|
{
|
|
UITableViewRowAction *deleteAction =
|
|
[UITableViewRowAction rowActionWithStyle:UITableViewRowActionStyleDefault
|
|
title:NSLocalizedString(@"TXT_DELETE_TITLE", nil)
|
|
handler:^(UITableViewRowAction *action, NSIndexPath *swipedIndexPath) {
|
|
[self tableViewCellTappedDelete:swipedIndexPath];
|
|
}];
|
|
|
|
UITableViewRowAction *archiveAction;
|
|
if (self.viewingThreadsIn == kInboxState) {
|
|
archiveAction = [UITableViewRowAction
|
|
rowActionWithStyle:UITableViewRowActionStyleNormal
|
|
title:NSLocalizedString(@"ARCHIVE_ACTION",
|
|
@"Pressing this button moves a thread from the inbox to the archive")
|
|
handler:^(UITableViewRowAction *_Nonnull action, NSIndexPath *_Nonnull tappedIndexPath) {
|
|
[self archiveIndexPath:tappedIndexPath];
|
|
[Environment.preferences setHasArchivedAMessage:YES];
|
|
}];
|
|
|
|
} else {
|
|
archiveAction = [UITableViewRowAction
|
|
rowActionWithStyle:UITableViewRowActionStyleNormal
|
|
title:NSLocalizedString(@"UNARCHIVE_ACTION",
|
|
@"Pressing this button moves an archived thread from the archive back to the inbox")
|
|
handler:^(UITableViewRowAction *_Nonnull action, NSIndexPath *_Nonnull tappedIndexPath) {
|
|
[self archiveIndexPath:tappedIndexPath];
|
|
}];
|
|
}
|
|
|
|
|
|
return @[ deleteAction, archiveAction ];
|
|
}
|
|
|
|
- (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath
|
|
{
|
|
return YES;
|
|
}
|
|
|
|
#pragma mark - HomeFeedTableViewCellDelegate
|
|
|
|
- (void)tableViewCellTappedDelete:(NSIndexPath *)indexPath
|
|
{
|
|
TSThread *thread = [self threadForIndexPath:indexPath];
|
|
if ([thread isKindOfClass:[TSGroupThread class]]) {
|
|
|
|
TSGroupThread *gThread = (TSGroupThread *)thread;
|
|
if ([gThread.groupModel.groupMemberIds containsObject:[TSAccountManager localNumber]]) {
|
|
UIAlertController *removingFromGroup = [UIAlertController
|
|
alertControllerWithTitle:[NSString
|
|
stringWithFormat:NSLocalizedString(@"GROUP_REMOVING", nil), [thread name]]
|
|
message:nil
|
|
preferredStyle:UIAlertControllerStyleAlert];
|
|
[self presentViewController:removingFromGroup animated:YES completion:nil];
|
|
|
|
TSOutgoingMessage *message = [[TSOutgoingMessage alloc] initWithTimestamp:[NSDate ows_millisecondTimeStamp]
|
|
inThread:thread
|
|
groupMetaMessage:TSGroupMessageQuit];
|
|
[self.messageSender enqueueMessage:message
|
|
success:^{
|
|
[self dismissViewControllerAnimated:YES
|
|
completion:^{
|
|
[self deleteThread:thread];
|
|
}];
|
|
}
|
|
failure:^(NSError *error) {
|
|
[self dismissViewControllerAnimated:YES
|
|
completion:^{
|
|
[OWSAlerts
|
|
showAlertWithTitle:
|
|
NSLocalizedString(@"GROUP_REMOVING_FAILED",
|
|
@"Title of alert indicating that group deletion failed.")
|
|
message:error.localizedRecoverySuggestion];
|
|
}];
|
|
}];
|
|
} else {
|
|
[self deleteThread:thread];
|
|
}
|
|
} else {
|
|
[self deleteThread:thread];
|
|
}
|
|
}
|
|
|
|
- (void)deleteThread:(TSThread *)thread
|
|
{
|
|
[self.editingDbConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
|
[thread removeWithTransaction:transaction];
|
|
}];
|
|
|
|
_inboxCount -= (self.viewingThreadsIn == kArchiveState) ? 1 : 0;
|
|
[self checkIfEmptyView];
|
|
}
|
|
|
|
- (void)archiveIndexPath:(NSIndexPath *)indexPath
|
|
{
|
|
TSThread *thread = [self threadForIndexPath:indexPath];
|
|
|
|
BOOL viewingThreadsIn = self.viewingThreadsIn;
|
|
[self.editingDbConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
|
viewingThreadsIn == kInboxState ? [thread archiveThreadWithTransaction:transaction]
|
|
: [thread unarchiveThreadWithTransaction:transaction];
|
|
|
|
}];
|
|
[self checkIfEmptyView];
|
|
}
|
|
|
|
- (void)updateInboxCountLabel
|
|
{
|
|
NSUInteger numberOfItems = [self.messagesManager unreadMessagesCount];
|
|
NSString *unreadString = NSLocalizedString(@"WHISPER_NAV_BAR_TITLE", nil);
|
|
|
|
if (numberOfItems > 0) {
|
|
unreadString =
|
|
[unreadString stringByAppendingFormat:@" (%@)", [ViewControllerUtils formatInt:(int)numberOfItems]];
|
|
}
|
|
|
|
[_segmentedControl setTitle:unreadString forSegmentAtIndex:0];
|
|
[_segmentedControl.superview setNeedsLayout];
|
|
[_segmentedControl reloadInputViews];
|
|
}
|
|
|
|
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
|
|
{
|
|
TSThread *thread = [self threadForIndexPath:indexPath];
|
|
[self presentThread:thread keyboardOnViewAppearing:NO callOnViewAppearing:NO];
|
|
[tableView deselectRowAtIndexPath:indexPath animated:YES];
|
|
}
|
|
|
|
- (void)presentThread:(TSThread *)thread
|
|
keyboardOnViewAppearing:(BOOL)keyboardOnViewAppearing
|
|
callOnViewAppearing:(BOOL)callOnViewAppearing
|
|
{
|
|
// At most one.
|
|
OWSAssert(!keyboardOnViewAppearing || !callOnViewAppearing);
|
|
|
|
if (thread == nil) {
|
|
OWSFail(@"Thread unexpectedly nil");
|
|
return;
|
|
}
|
|
|
|
// We do this synchronously if we're already on the main thread.
|
|
DispatchMainThreadSafe(^{
|
|
ConversationViewController *mvc = [ConversationViewController new];
|
|
[mvc configureForThread:thread
|
|
keyboardOnViewAppearing:keyboardOnViewAppearing
|
|
callOnViewAppearing:callOnViewAppearing];
|
|
self.lastThread = thread;
|
|
|
|
[self pushTopLevelViewController:mvc animateDismissal:YES animatePresentation:YES];
|
|
});
|
|
}
|
|
|
|
- (void)presentTopLevelModalViewController:(UIViewController *)viewController
|
|
animateDismissal:(BOOL)animateDismissal
|
|
animatePresentation:(BOOL)animatePresentation
|
|
{
|
|
OWSAssert([NSThread isMainThread]);
|
|
OWSAssert(viewController);
|
|
|
|
[self presentViewControllerWithBlock:^{
|
|
[self presentViewController:viewController animated:animatePresentation completion:nil];
|
|
}
|
|
animateDismissal:animateDismissal];
|
|
}
|
|
|
|
- (void)pushTopLevelViewController:(UIViewController *)viewController
|
|
animateDismissal:(BOOL)animateDismissal
|
|
animatePresentation:(BOOL)animatePresentation
|
|
{
|
|
OWSAssert([NSThread isMainThread]);
|
|
OWSAssert(viewController);
|
|
|
|
[self presentViewControllerWithBlock:^{
|
|
[self.navigationController pushViewController:viewController animated:animatePresentation];
|
|
}
|
|
animateDismissal:animateDismissal];
|
|
}
|
|
|
|
- (void)presentViewControllerWithBlock:(void (^)(void))presentationBlock animateDismissal:(BOOL)animateDismissal
|
|
{
|
|
OWSAssert([NSThread isMainThread]);
|
|
OWSAssert(presentationBlock);
|
|
|
|
// Presenting a "top level" view controller has three steps:
|
|
//
|
|
// First, dismiss any presented modal.
|
|
// Second, pop to the root view controller if necessary.
|
|
// Third present the new view controller using presentationBlock.
|
|
|
|
// Define a block to perform the second step.
|
|
void (^dismissNavigationBlock)(void) = ^{
|
|
if (self.navigationController.viewControllers.lastObject != self) {
|
|
[CATransaction begin];
|
|
[CATransaction setCompletionBlock:^{
|
|
presentationBlock();
|
|
}];
|
|
|
|
[self.navigationController popToViewController:self animated:animateDismissal];
|
|
|
|
[CATransaction commit];
|
|
} else {
|
|
presentationBlock();
|
|
}
|
|
};
|
|
|
|
// Perform the first step.
|
|
if (self.presentedViewController) {
|
|
if ([self.presentedViewController isKindOfClass:[CallViewController class]]) {
|
|
OWSProdInfo([OWSAnalyticsEvents errorCouldNotPresentViewDueToCall]);
|
|
return;
|
|
}
|
|
[self.presentedViewController dismissViewControllerAnimated:animateDismissal completion:dismissNavigationBlock];
|
|
} else {
|
|
dismissNavigationBlock();
|
|
}
|
|
}
|
|
|
|
#pragma mark - Groupings
|
|
|
|
- (YapDatabaseViewMappings *)threadMappings
|
|
{
|
|
OWSAssert(_threadMappings != nil);
|
|
return _threadMappings;
|
|
}
|
|
|
|
- (void)showInboxGrouping
|
|
{
|
|
self.viewingThreadsIn = kInboxState;
|
|
}
|
|
|
|
- (void)showArchiveGrouping
|
|
{
|
|
self.viewingThreadsIn = kArchiveState;
|
|
}
|
|
|
|
- (void)setViewingThreadsIn:(CellState)viewingThreadsIn
|
|
{
|
|
BOOL didChange = _viewingThreadsIn != viewingThreadsIn;
|
|
_viewingThreadsIn = viewingThreadsIn;
|
|
self.segmentedControl.selectedSegmentIndex = (viewingThreadsIn == kInboxState ? 0 : 1);
|
|
if (didChange || !self.threadMappings) {
|
|
[self updateMappings];
|
|
} else {
|
|
[self checkIfEmptyView];
|
|
[self updateReminderViews];
|
|
}
|
|
}
|
|
|
|
- (NSString *)currentGrouping
|
|
{
|
|
return self.viewingThreadsIn == kInboxState ? TSInboxGroup : TSArchiveGroup;
|
|
}
|
|
|
|
- (void)updateMappings
|
|
{
|
|
OWSAssert([NSThread isMainThread]);
|
|
|
|
self.threadMappings = [[YapDatabaseViewMappings alloc] initWithGroups:@[ self.currentGrouping ]
|
|
view:TSThreadDatabaseViewExtensionName];
|
|
[self.threadMappings setIsReversed:YES forGroup:self.currentGrouping];
|
|
|
|
[self resetMappings];
|
|
|
|
[[self tableView] reloadData];
|
|
[self checkIfEmptyView];
|
|
[self updateReminderViews];
|
|
}
|
|
|
|
#pragma mark Database delegates
|
|
|
|
- (YapDatabaseConnection *)uiDatabaseConnection
|
|
{
|
|
NSAssert([NSThread isMainThread], @"Must access uiDatabaseConnection on main thread!");
|
|
if (!_uiDatabaseConnection) {
|
|
YapDatabase *database = TSStorageManager.sharedManager.database;
|
|
_uiDatabaseConnection = [database newConnection];
|
|
[_uiDatabaseConnection beginLongLivedReadTransaction];
|
|
}
|
|
return _uiDatabaseConnection;
|
|
}
|
|
|
|
- (void)yapDatabaseModified:(NSNotification *)notification
|
|
{
|
|
if (!self.shouldObserveDBModifications) {
|
|
return;
|
|
}
|
|
|
|
NSArray *notifications = [self.uiDatabaseConnection beginLongLivedReadTransaction];
|
|
|
|
if (![[self.uiDatabaseConnection ext:TSThreadDatabaseViewExtensionName] hasChangesForGroup:self.currentGrouping
|
|
inNotifications:notifications]) {
|
|
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
|
|
[self.self.threadMappings updateWithTransaction:transaction];
|
|
}];
|
|
return;
|
|
}
|
|
|
|
// If the user hasn't already granted contact access
|
|
// we don't want to request until they receive a message.
|
|
if ([TSThread numberOfKeysInCollection] > 0) {
|
|
[self.contactsManager requestSystemContactsOnce];
|
|
}
|
|
|
|
NSArray *sectionChanges = nil;
|
|
NSArray *rowChanges = nil;
|
|
[[self.uiDatabaseConnection ext:TSThreadDatabaseViewExtensionName] getSectionChanges:§ionChanges
|
|
rowChanges:&rowChanges
|
|
forNotifications:notifications
|
|
withMappings:self.threadMappings];
|
|
|
|
// We want this regardless of if we're currently viewing the archive.
|
|
// So we run it before the early return
|
|
[self updateInboxCountLabel];
|
|
[self checkIfEmptyView];
|
|
|
|
if ([sectionChanges count] == 0 && [rowChanges count] == 0) {
|
|
return;
|
|
}
|
|
|
|
[self.tableView beginUpdates];
|
|
|
|
for (YapDatabaseViewSectionChange *sectionChange in sectionChanges) {
|
|
switch (sectionChange.type) {
|
|
case YapDatabaseViewChangeDelete: {
|
|
[self.tableView deleteSections:[NSIndexSet indexSetWithIndex:sectionChange.index]
|
|
withRowAnimation:UITableViewRowAnimationAutomatic];
|
|
break;
|
|
}
|
|
case YapDatabaseViewChangeInsert: {
|
|
[self.tableView insertSections:[NSIndexSet indexSetWithIndex:sectionChange.index]
|
|
withRowAnimation:UITableViewRowAnimationAutomatic];
|
|
break;
|
|
}
|
|
case YapDatabaseViewChangeUpdate:
|
|
case YapDatabaseViewChangeMove:
|
|
break;
|
|
}
|
|
}
|
|
|
|
for (YapDatabaseViewRowChange *rowChange in rowChanges) {
|
|
switch (rowChange.type) {
|
|
case YapDatabaseViewChangeDelete: {
|
|
[self.tableView deleteRowsAtIndexPaths:@[ rowChange.indexPath ]
|
|
withRowAnimation:UITableViewRowAnimationAutomatic];
|
|
_inboxCount += (self.viewingThreadsIn == kArchiveState) ? 1 : 0;
|
|
break;
|
|
}
|
|
case YapDatabaseViewChangeInsert: {
|
|
[self.tableView insertRowsAtIndexPaths:@[ rowChange.newIndexPath ]
|
|
withRowAnimation:UITableViewRowAnimationAutomatic];
|
|
_inboxCount -= (self.viewingThreadsIn == kArchiveState) ? 1 : 0;
|
|
break;
|
|
}
|
|
case YapDatabaseViewChangeMove: {
|
|
[self.tableView deleteRowsAtIndexPaths:@[ rowChange.indexPath ]
|
|
withRowAnimation:UITableViewRowAnimationAutomatic];
|
|
[self.tableView insertRowsAtIndexPaths:@[ rowChange.newIndexPath ]
|
|
withRowAnimation:UITableViewRowAnimationAutomatic];
|
|
break;
|
|
}
|
|
case YapDatabaseViewChangeUpdate: {
|
|
[self.tableView reloadRowsAtIndexPaths:@[ rowChange.indexPath ]
|
|
withRowAnimation:UITableViewRowAnimationNone];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
[self.tableView endUpdates];
|
|
}
|
|
|
|
- (void)checkIfEmptyView
|
|
{
|
|
[_tableView setHidden:NO];
|
|
[_emptyBoxLabel setHidden:NO];
|
|
if (self.viewingThreadsIn == kInboxState && [self.threadMappings numberOfItemsInGroup:TSInboxGroup] == 0) {
|
|
[self setEmptyBoxText];
|
|
[_tableView setHidden:YES];
|
|
} else if (self.viewingThreadsIn == kArchiveState &&
|
|
[self.threadMappings numberOfItemsInGroup:TSArchiveGroup] == 0) {
|
|
[self setEmptyBoxText];
|
|
[_tableView setHidden:YES];
|
|
} else {
|
|
[_emptyBoxLabel setHidden:YES];
|
|
}
|
|
}
|
|
|
|
- (void)setEmptyBoxText
|
|
{
|
|
_emptyBoxLabel.textColor = [UIColor grayColor];
|
|
_emptyBoxLabel.font = [UIFont ows_regularFontWithSize:18.f];
|
|
_emptyBoxLabel.textAlignment = NSTextAlignmentCenter;
|
|
_emptyBoxLabel.numberOfLines = 4;
|
|
|
|
NSString *firstLine = @"";
|
|
NSString *secondLine = @"";
|
|
|
|
if (self.viewingThreadsIn == kInboxState) {
|
|
if ([Environment.preferences getHasSentAMessage]) {
|
|
firstLine = NSLocalizedString(@"EMPTY_INBOX_FIRST_TITLE", @"");
|
|
secondLine = NSLocalizedString(@"EMPTY_INBOX_FIRST_TEXT", @"");
|
|
} else {
|
|
// FIXME This looks wrong. Shouldn't we be showing inbox_title/text here?
|
|
firstLine = NSLocalizedString(@"EMPTY_ARCHIVE_FIRST_TITLE", @"");
|
|
secondLine = NSLocalizedString(@"EMPTY_ARCHIVE_FIRST_TEXT", @"");
|
|
}
|
|
} else {
|
|
if ([Environment.preferences getHasArchivedAMessage]) {
|
|
// FIXME This looks wrong. Shouldn't we be showing first_archive_title/text here?
|
|
firstLine = NSLocalizedString(@"EMPTY_INBOX_TITLE", @"");
|
|
secondLine = NSLocalizedString(@"EMPTY_INBOX_TEXT", @"");
|
|
} else {
|
|
firstLine = NSLocalizedString(@"EMPTY_ARCHIVE_TITLE", @"");
|
|
secondLine = NSLocalizedString(@"EMPTY_ARCHIVE_TEXT", @"");
|
|
}
|
|
}
|
|
NSMutableAttributedString *fullLabelString =
|
|
[[NSMutableAttributedString alloc] initWithString:[NSString stringWithFormat:@"%@\n%@", firstLine, secondLine]];
|
|
|
|
[fullLabelString addAttribute:NSFontAttributeName
|
|
value:[UIFont ows_boldFontWithSize:15.f]
|
|
range:NSMakeRange(0, firstLine.length)];
|
|
[fullLabelString addAttribute:NSFontAttributeName
|
|
value:[UIFont ows_regularFontWithSize:14.f]
|
|
range:NSMakeRange(firstLine.length + 1, secondLine.length)];
|
|
[fullLabelString addAttribute:NSForegroundColorAttributeName
|
|
value:[UIColor blackColor]
|
|
range:NSMakeRange(0, firstLine.length)];
|
|
[fullLabelString addAttribute:NSForegroundColorAttributeName
|
|
value:[UIColor ows_darkGrayColor]
|
|
range:NSMakeRange(firstLine.length + 1, secondLine.length)];
|
|
_emptyBoxLabel.attributedText = fullLabelString;
|
|
}
|
|
|
|
@end
|