MessagesVC: Paging and fix scrolling bug.

Reviewed-by: @FredericJacobs
This commit is contained in:
dtsbourg 2014-12-31 13:22:40 +01:00 committed by Frederic Jacobs
parent 9ae4a435a1
commit ee07490d3e
4 changed files with 220 additions and 17 deletions

View file

@ -505,6 +505,7 @@
FC4FA0331A1D46AE00DA100A /* InitialViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = FC4FA0321A1D46AE00DA100A /* InitialViewController.m */; }; FC4FA0331A1D46AE00DA100A /* InitialViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = FC4FA0321A1D46AE00DA100A /* InitialViewController.m */; };
FC5CDF391A3393DD00B47253 /* error_white@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = FC5CDF371A3393DD00B47253 /* error_white@2x.png */; }; FC5CDF391A3393DD00B47253 /* error_white@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = FC5CDF371A3393DD00B47253 /* error_white@2x.png */; };
FC5CDF3A1A3393DD00B47253 /* warning_white@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = FC5CDF381A3393DD00B47253 /* warning_white@2x.png */; }; FC5CDF3A1A3393DD00B47253 /* warning_white@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = FC5CDF381A3393DD00B47253 /* warning_white@2x.png */; };
FC7C7A961A581AF40091823B /* TSAdapterCacheManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FC7C7A951A581AF40091823B /* TSAdapterCacheManager.m */; };
FC9120411A39EFB70074545C /* qr@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = FC91203F1A39EFB70074545C /* qr@2x.png */; }; FC9120411A39EFB70074545C /* qr@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = FC91203F1A39EFB70074545C /* qr@2x.png */; };
FC9120431A39F9E00074545C /* qr_scan_fingerprint@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = FC9120421A39F9E00074545C /* qr_scan_fingerprint@2x.png */; }; FC9120431A39F9E00074545C /* qr_scan_fingerprint@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = FC9120421A39F9E00074545C /* qr_scan_fingerprint@2x.png */; };
FCA52AE61A2B676C00CCADFA /* call_canceled@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = FCA52ADE1A2B676C00CCADFA /* call_canceled@2x.png */; }; FCA52AE61A2B676C00CCADFA /* call_canceled@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = FCA52ADE1A2B676C00CCADFA /* call_canceled@2x.png */; };
@ -1211,6 +1212,8 @@
FC4FA0321A1D46AE00DA100A /* InitialViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = InitialViewController.m; sourceTree = "<group>"; }; FC4FA0321A1D46AE00DA100A /* InitialViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = InitialViewController.m; sourceTree = "<group>"; };
FC5CDF371A3393DD00B47253 /* error_white@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "error_white@2x.png"; sourceTree = "<group>"; }; FC5CDF371A3393DD00B47253 /* error_white@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "error_white@2x.png"; sourceTree = "<group>"; };
FC5CDF381A3393DD00B47253 /* warning_white@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "warning_white@2x.png"; sourceTree = "<group>"; }; FC5CDF381A3393DD00B47253 /* warning_white@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "warning_white@2x.png"; sourceTree = "<group>"; };
FC7C7A941A581AF40091823B /* TSAdapterCacheManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSAdapterCacheManager.h; sourceTree = "<group>"; };
FC7C7A951A581AF40091823B /* TSAdapterCacheManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSAdapterCacheManager.m; sourceTree = "<group>"; };
FC91203F1A39EFB70074545C /* qr@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "qr@2x.png"; sourceTree = "<group>"; }; FC91203F1A39EFB70074545C /* qr@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "qr@2x.png"; sourceTree = "<group>"; };
FC9120421A39F9E00074545C /* qr_scan_fingerprint@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "qr_scan_fingerprint@2x.png"; sourceTree = "<group>"; }; FC9120421A39F9E00074545C /* qr_scan_fingerprint@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "qr_scan_fingerprint@2x.png"; sourceTree = "<group>"; };
FCA52ADE1A2B676C00CCADFA /* call_canceled@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "call_canceled@2x.png"; sourceTree = "<group>"; }; FCA52ADE1A2B676C00CCADFA /* call_canceled@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "call_canceled@2x.png"; sourceTree = "<group>"; };
@ -1833,6 +1836,8 @@
76EB04FD18170B33006006FC /* Zid.m */, 76EB04FD18170B33006006FC /* Zid.m */,
FCC81A961A44558300DFEC7D /* UIDevice+TSHardwareVersion.h */, FCC81A961A44558300DFEC7D /* UIDevice+TSHardwareVersion.h */,
FCC81A971A44558300DFEC7D /* UIDevice+TSHardwareVersion.m */, FCC81A971A44558300DFEC7D /* UIDevice+TSHardwareVersion.m */,
FC7C7A941A581AF40091823B /* TSAdapterCacheManager.h */,
FC7C7A951A581AF40091823B /* TSAdapterCacheManager.m */,
); );
path = util; path = util;
sourceTree = "<group>"; sourceTree = "<group>";
@ -3272,6 +3277,7 @@
7038632718F70C0700D4A43F /* CryptoTools.m in Sources */, 7038632718F70C0700D4A43F /* CryptoTools.m in Sources */,
76EB058C18170B33006006FC /* DnsManager.m in Sources */, 76EB058C18170B33006006FC /* DnsManager.m in Sources */,
B63AF5CB1A1F757900D01AAD /* TSRegisterPrekeysRequest.m in Sources */, B63AF5CB1A1F757900D01AAD /* TSRegisterPrekeysRequest.m in Sources */,
FC7C7A961A581AF40091823B /* TSAdapterCacheManager.m in Sources */,
B6FAAAE81A41BC6C007FEC1D /* TSAttachmentPointer.m in Sources */, B6FAAAE81A41BC6C007FEC1D /* TSAttachmentPointer.m in Sources */,
B6B096881A1D25ED008BFAA6 /* TSStorageManager+keyFromIntLong.m in Sources */, B6B096881A1D25ED008BFAA6 /* TSStorageManager+keyFromIntLong.m in Sources */,
B6B50AAB1A4192C500F8F607 /* TSMessagesManager+attachments.m in Sources */, B6B50AAB1A4192C500F8F607 /* TSMessagesManager+attachments.m in Sources */,

View file

@ -0,0 +1,27 @@
//
// TSAdapterCacheManager.h
// Signal
//
// Created by Dylan Bourgeois on 03/01/15.
// Copyright (c) 2015 Open Whisper Systems. All rights reserved.
//
#import <Foundation/Foundation.h>
@class TSMessageAdapter;
@interface TSAdapterCacheManager : NSObject {
NSCache * messageAdaptersCache;
}
@property (nonatomic, retain) NSCache *messageAdaptersCache;
+ (id)sharedManager;
- (void)cacheAdapter:(TSMessageAdapter*)adapter forInteractionId:(NSString*)identifier;
- (void)clearCacheEntryForInteractionId:(NSString*)identifier;
- (TSMessageAdapter*)adapterForInteractionId:(NSString*)identifier;
- (BOOL)containsCacheEntryForInteractionId:(NSString*)identifier;
@end

View file

@ -0,0 +1,58 @@
//
// TSAdapterCacheManager.m
// Signal
//
// Created by Dylan Bourgeois on 03/01/15.
// Copyright (c) 2015 Open Whisper Systems. All rights reserved.
//
#import "TSAdapterCacheManager.h"
#import "TSMessageAdapter.h"
@implementation TSAdapterCacheManager
@synthesize messageAdaptersCache;
+ (id)sharedManager {
static TSAdapterCacheManager *sharedManager = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedManager = [[self alloc] init];
});
return sharedManager;
}
- (id)init {
if (self = [super init]) {
messageAdaptersCache = [[NSCache alloc]init];
}
return self;
}
- (void)cacheAdapter:(TSMessageAdapter*)adapter forInteractionId:(NSString*)identifier
{
NSParameterAssert(adapter);
NSParameterAssert(identifier);
[messageAdaptersCache setObject:adapter forKey:identifier];
}
-(void)clearCacheEntryForInteractionId:(NSString*)identifier
{
NSParameterAssert(identifier);
[messageAdaptersCache removeObjectForKey:identifier];
}
-(TSMessageAdapter*)adapterForInteractionId:(NSString*)identifier
{
NSParameterAssert(identifier);
return [messageAdaptersCache objectForKey:identifier];
}
-(BOOL)containsCacheEntryForInteractionId:(NSString*)identifier
{
return [messageAdaptersCache objectForKey:identifier] != nil;
}
@end

View file

@ -54,6 +54,12 @@
#import "ContactsManager.h" #import "ContactsManager.h"
#import "PreferencesUtil.h" #import "PreferencesUtil.h"
#import "TSAdapterCacheManager.h"
#define kYapDatabaseRangeLength 50
#define kYapDatabaseRangeMaxLength 300
#define kYapDatabaseRangeMinLength 20
static NSTimeInterval const kTSMessageSentDateShowTimeInterval = 5 * 60; static NSTimeInterval const kTSMessageSentDateShowTimeInterval = 5 * 60;
static NSString *const kUpdateGroupSegueIdentifier = @"updateGroupSegue"; static NSString *const kUpdateGroupSegueIdentifier = @"updateGroupSegue";
static NSString *const kFingerprintSegueIdentifier = @"fingerprintSegue"; static NSString *const kFingerprintSegueIdentifier = @"fingerprintSegue";
@ -82,6 +88,8 @@ typedef enum : NSUInteger {
@property (nonatomic, retain) NSIndexPath *lastDeliveredMessageIndexPath; @property (nonatomic, retain) NSIndexPath *lastDeliveredMessageIndexPath;
@property NSUInteger page;
@end @end
@implementation MessagesViewController @implementation MessagesViewController
@ -115,6 +123,7 @@ typedef enum : NSUInteger {
- (void)viewDidLoad { - (void)viewDidLoad {
[super viewDidLoad]; [super viewDidLoad];
[self markAllMessagesAsRead]; [self markAllMessagesAsRead];
[self initializeBubbles]; [self initializeBubbles];
@ -122,14 +131,17 @@ typedef enum : NSUInteger {
self.messageMappings = [[YapDatabaseViewMappings alloc] initWithGroups:@[self.thread.uniqueId] self.messageMappings = [[YapDatabaseViewMappings alloc] initWithGroups:@[self.thread.uniqueId]
view:TSMessageDatabaseViewExtensionName]; view:TSMessageDatabaseViewExtensionName];
self.page = 0;
[self updateRangeOptionsForPage:self.page];
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
[self.messageMappings updateWithTransaction:transaction]; [self.messageMappings updateWithTransaction:transaction];
}]; }];
[self initializeToolbars]; [self initializeToolbars];
[self initializeCollectionViewLayout]; [self initializeCollectionViewLayout];
self.senderId = ME_MESSAGE_IDENTIFIER self.senderId = ME_MESSAGE_IDENTIFIER
self.senderDisplayName = ME_MESSAGE_IDENTIFIER self.senderDisplayName = ME_MESSAGE_IDENTIFIER
@ -142,8 +154,10 @@ typedef enum : NSUInteger {
-(void)viewWillAppear:(BOOL)animated -(void)viewWillAppear:(BOOL)animated
{ {
[super viewWillAppear:animated]; [super viewWillAppear:animated];
self.automaticallyScrollsToMostRecentMessage = YES;
[self scrollToBottomAnimated:NO]; NSIndexPath * lastCellIndexPath = [NSIndexPath indexPathForRow:(NSInteger)[self.messageMappings numberOfItemsInGroup:self.thread.uniqueId]-1 inSection:0];
[self.collectionView scrollToItemAtIndexPath:lastCellIndexPath atScrollPosition:UICollectionViewScrollPositionBottom animated:NO];
} }
- (void)startReadTimer{ - (void)startReadTimer{
@ -157,7 +171,6 @@ typedef enum : NSUInteger {
- (void)viewDidAppear:(BOOL)animated{ - (void)viewDidAppear:(BOOL)animated{
[super viewDidAppear:animated]; [super viewDidAppear:animated];
[self startReadTimer]; [self startReadTimer];
[self scrollToBottomAnimated:YES];
} }
- (void)viewWillDisappear:(BOOL)animated{ - (void)viewWillDisappear:(BOOL)animated{
@ -219,7 +232,7 @@ typedef enum : NSUInteger {
self.collectionView.showsVerticalScrollIndicator = NO; self.collectionView.showsVerticalScrollIndicator = NO;
self.collectionView.showsHorizontalScrollIndicator = NO; self.collectionView.showsHorizontalScrollIndicator = NO;
self.automaticallyScrollsToMostRecentMessage = NO; [self updateLoadEarlierVisible];
self.collectionView.collectionViewLayout.incomingAvatarViewSize = CGSizeZero; self.collectionView.collectionViewLayout.incomingAvatarViewSize = CGSizeZero;
self.collectionView.collectionViewLayout.outgoingAvatarViewSize = CGSizeZero; self.collectionView.collectionViewLayout.outgoingAvatarViewSize = CGSizeZero;
@ -426,7 +439,7 @@ typedef enum : NSUInteger {
-(BOOL)shouldShowMessageStatusAtIndexPath:(NSIndexPath*)indexPath -(BOOL)shouldShowMessageStatusAtIndexPath:(NSIndexPath*)indexPath
{ {
TSMessageAdapter *currentMessage = [self messageAtIndexPath:indexPath]; TSMessageAdapter *currentMessage = [self messageAtIndexPath:indexPath];
if([self.thread isKindOfClass:[TSGroupThread class]]) { if([self.thread isKindOfClass:[TSGroupThread class]]) {
return currentMessage.messageType == TSIncomingMessageAdapter; return currentMessage.messageType == TSIncomingMessageAdapter;
@ -513,8 +526,7 @@ typedef enum : NSUInteger {
{ {
TSMessageAdapter *messageItem = [collectionView.dataSource collectionView:collectionView messageDataForItemAtIndexPath:indexPath]; TSMessageAdapter *messageItem = [collectionView.dataSource collectionView:collectionView messageDataForItemAtIndexPath:indexPath];
TSInteraction *interaction = [self interactionAtIndexPath:indexPath]; TSInteraction *interaction = [self interactionAtIndexPath:indexPath];
switch (messageItem.messageType) { switch (messageItem.messageType) {
case TSOutgoingMessageAdapter: case TSOutgoingMessageAdapter:
if (messageItem.messageState == TSOutgoingMessageStateUnsent) { if (messageItem.messageState == TSOutgoingMessageStateUnsent) {
@ -558,6 +570,81 @@ typedef enum : NSUInteger {
} }
} }
-(void)collectionView:(JSQMessagesCollectionView *)collectionView header:(JSQMessagesLoadEarlierHeaderView *)headerView didTapLoadEarlierMessagesButton:(UIButton *)sender
{
if ([self shouldShowLoadEarlierMessages]) {
self.page++;
}
NSInteger item = (NSInteger)[self scrollToItem];
[self updateRangeOptionsForPage:self.page];
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
[self.messageMappings updateWithTransaction:transaction];
}];
[self updateLayoutForEarlierMessagesWithOffset:item];
}
-(BOOL)shouldShowLoadEarlierMessages
{
__block BOOL show = YES;
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction){
show = [self.messageMappings numberOfItemsInGroup:self.thread.uniqueId] < [[transaction ext:TSMessageDatabaseViewExtensionName] numberOfItemsInGroup:self.thread.uniqueId];
}];
return show;
}
-(NSUInteger)scrollToItem
{
__block NSUInteger item = kYapDatabaseRangeLength*(self.page+1) - [self.messageMappings numberOfItemsInGroup:self.thread.uniqueId];
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
NSUInteger numberOfVisibleMessages = [self.messageMappings numberOfItemsInGroup:self.thread.uniqueId] ;
NSUInteger numberOfTotalMessages = [[transaction ext:TSMessageDatabaseViewExtensionName] numberOfItemsInGroup:self.thread.uniqueId] ;
NSUInteger numberOfMessagesToLoad = numberOfTotalMessages - numberOfVisibleMessages ;
BOOL canLoadFullRange = numberOfMessagesToLoad >= kYapDatabaseRangeLength;
if (!canLoadFullRange) {
item = numberOfMessagesToLoad;
}
}];
return item == 0 ? item : item - 1;
}
-(void)updateLoadEarlierVisible
{
[self setShowLoadEarlierMessagesHeader:[self shouldShowLoadEarlierMessages]];
}
-(void)updateLayoutForEarlierMessagesWithOffset:(NSInteger)offset
{
[self.collectionView.collectionViewLayout invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]];
[self.collectionView reloadData];
[self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForItem:offset inSection:0] atScrollPosition:UICollectionViewScrollPositionTop animated:NO];
[self updateLoadEarlierVisible];
}
-(void)updateRangeOptionsForPage:(NSUInteger)page
{
YapDatabaseViewRangeOptions *rangeOptions = [YapDatabaseViewRangeOptions flexibleRangeWithLength:kYapDatabaseRangeLength*(page+1) offset:0 from:YapDatabaseViewEnd];
rangeOptions.maxLength = kYapDatabaseRangeMaxLength;
rangeOptions.minLength = kYapDatabaseRangeMinLength;
[self.messageMappings setRangeOptions:rangeOptions forGroup:self.thread.uniqueId];
}
#pragma mark Bubble User Actions #pragma mark Bubble User Actions
- (void)handleUnsentMessageTap:(TSOutgoingMessage*)message{ - (void)handleUnsentMessageTap:(TSOutgoingMessage*)message{
@ -823,48 +910,65 @@ typedef enum : NSUInteger {
forNotifications:notifications forNotifications:notifications
withMappings:self.messageMappings]; withMappings:self.messageMappings];
__block BOOL containsInsertion = NO;
if (!messageRowChanges) { if (!messageRowChanges) {
return; return;
} }
[self.collectionView performBatchUpdates:^{ [self.collectionView performBatchUpdates:^{
for (YapDatabaseViewRowChange *rowChange in messageRowChanges) for (YapDatabaseViewRowChange *rowChange in messageRowChanges)
{ {
switch (rowChange.type) switch (rowChange.type)
{ {
case YapDatabaseViewChangeDelete : case YapDatabaseViewChangeDelete :
{ {
TSInteraction * interaction = [self interactionAtIndexPath:rowChange.indexPath];
[[TSAdapterCacheManager sharedManager] clearCacheEntryForInteractionId:interaction.uniqueId];
[self.collectionView deleteItemsAtIndexPaths:@[ rowChange.indexPath ]]; [self.collectionView deleteItemsAtIndexPaths:@[ rowChange.indexPath ]];
break; break;
} }
case YapDatabaseViewChangeInsert : case YapDatabaseViewChangeInsert :
{ {
TSInteraction * interaction = [self interactionAtIndexPath:rowChange.newIndexPath];
[[TSAdapterCacheManager sharedManager] cacheAdapter:[TSMessageAdapter messageViewDataWithInteraction:interaction inThread:self.thread] forInteractionId:interaction.uniqueId];
[self.collectionView insertItemsAtIndexPaths:@[ rowChange.newIndexPath ]]; [self.collectionView insertItemsAtIndexPaths:@[ rowChange.newIndexPath ]];
containsInsertion = YES;
break; break;
} }
case YapDatabaseViewChangeMove : case YapDatabaseViewChangeMove :
{ {
[self.collectionView deleteItemsAtIndexPaths:@[ rowChange.indexPath]]; [self.collectionView deleteItemsAtIndexPaths:@[ rowChange.indexPath ]];
[self.collectionView insertItemsAtIndexPaths:@[ rowChange.newIndexPath]]; [self.collectionView insertItemsAtIndexPaths:@[ rowChange.newIndexPath ]];
break; break;
} }
case YapDatabaseViewChangeUpdate : case YapDatabaseViewChangeUpdate :
{ {
NSMutableArray *rowsToUpdate = [@[rowChange.indexPath] mutableCopy]; NSMutableArray *rowsToUpdate = [@[rowChange.indexPath] mutableCopy];
if (_lastDeliveredMessageIndexPath) { if (_lastDeliveredMessageIndexPath) {
[rowsToUpdate addObject:_lastDeliveredMessageIndexPath]; [rowsToUpdate addObject:_lastDeliveredMessageIndexPath];
} }
for (NSIndexPath* indexPath in rowsToUpdate) {
TSInteraction * interaction = [self interactionAtIndexPath:indexPath];
[[TSAdapterCacheManager sharedManager] cacheAdapter:[TSMessageAdapter messageViewDataWithInteraction:interaction inThread:self.thread] forInteractionId:interaction.uniqueId];
}
[self.collectionView reloadItemsAtIndexPaths:rowsToUpdate]; [self.collectionView reloadItemsAtIndexPaths:rowsToUpdate];
break; break;
} }
} }
} }
} completion:^(BOOL success) {
} completion:^(BOOL finished) { if (success) {
[self scrollToBottomAnimated:YES]; [self.collectionView.collectionViewLayout invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]];
[self.collectionView reloadData];
}
if (containsInsertion) {
[self scrollToBottomAnimated:YES];
}
}]; }];
} }
@ -897,8 +1001,16 @@ typedef enum : NSUInteger {
- (TSMessageAdapter*)messageAtIndexPath:(NSIndexPath *)indexPath { - (TSMessageAdapter*)messageAtIndexPath:(NSIndexPath *)indexPath {
TSInteraction *interaction = [self interactionAtIndexPath:indexPath]; TSInteraction *interaction = [self interactionAtIndexPath:indexPath];
return [TSMessageAdapter messageViewDataWithInteraction:interaction inThread:self.thread]; TSAdapterCacheManager * manager = [TSAdapterCacheManager sharedManager];
if (![manager containsCacheEntryForInteractionId:interaction.uniqueId]) {
[manager cacheAdapter:[TSMessageAdapter messageViewDataWithInteraction:interaction inThread:self.thread] forInteractionId:interaction.uniqueId];
}
return [manager adapterForInteractionId:interaction.uniqueId];
} }
#pragma mark group action view #pragma mark group action view
-(void)didPressGroupMenuButton:(UIButton *)sender -(void)didPressGroupMenuButton:(UIButton *)sender
{ {