diff --git a/Riot/Modules/MatrixKit/Animators/MXKAttachmentAnimator.h b/Riot/Modules/MatrixKit/Animators/MXKAttachmentAnimator.h new file mode 100644 index 000000000..31588cfad --- /dev/null +++ b/Riot/Modules/MatrixKit/Animators/MXKAttachmentAnimator.h @@ -0,0 +1,45 @@ +/* + Copyright 2017 Aram Sargsyan + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +typedef NS_ENUM(NSInteger, PhotoBrowserAnimationType) { + PhotoBrowserZoomInAnimation, + PhotoBrowserZoomOutAnimation +}; + +@protocol MXKSourceAttachmentAnimatorDelegate + +- (UIImageView *)originalImageView; + +- (CGRect)convertedFrameForOriginalImageView; + +@end + +@protocol MXKDestinationAttachmentAnimatorDelegate + +- (UIImageView *)finalImageView; + +@end + +@interface MXKAttachmentAnimator : NSObject + +- (instancetype)initWithAnimationType:(PhotoBrowserAnimationType)animationType sourceViewController:(UIViewController *)viewController; + ++ (CGRect)aspectFitImage:(UIImage *)image inFrame:(CGRect)targetFrame; + +@end diff --git a/Riot/Modules/MatrixKit/Animators/MXKAttachmentAnimator.m b/Riot/Modules/MatrixKit/Animators/MXKAttachmentAnimator.m new file mode 100644 index 000000000..d4ca8393f --- /dev/null +++ b/Riot/Modules/MatrixKit/Animators/MXKAttachmentAnimator.m @@ -0,0 +1,161 @@ +/* + Copyright 2017 Aram Sargsyan + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAttachmentAnimator.h" +#import "MXLog.h" + +@interface MXKAttachmentAnimator () + +@property (nonatomic) PhotoBrowserAnimationType animationType; +@property (nonatomic, weak) UIViewController *sourceViewController; + +@end + +@implementation MXKAttachmentAnimator + +#pragma mark - Lifecycle + +- (instancetype)initWithAnimationType:(PhotoBrowserAnimationType)animationType sourceViewController:(UIViewController *)viewController +{ + self = [self init]; + if (self) { + self.animationType = animationType; + self.sourceViewController = viewController; + } + return self; +} + +#pragma mark - Public + ++ (CGRect)aspectFitImage:(UIImage *)image inFrame:(CGRect)targetFrame +{ + // Sanity check + if (!image) + { + MXLogDebug(@"[MXKAttachmentAnimator] aspectFitImage failed: image is nil"); + return CGRectZero; + } + + if (CGSizeEqualToSize(image.size, targetFrame.size)) + { + return targetFrame; + } + + CGFloat targetWidth = CGRectGetWidth(targetFrame); + CGFloat targetHeight = CGRectGetHeight(targetFrame); + CGFloat imageWidth = image.size.width; + CGFloat imageHeight = image.size.height; + + CGFloat factor = MIN(targetWidth/imageWidth, targetHeight/imageHeight); + + CGSize finalSize = CGSizeMake(imageWidth * factor, imageHeight * factor); + CGRect finalFrame = CGRectMake((targetWidth - finalSize.width)/2 + targetFrame.origin.x, (targetHeight - finalSize.height)/2 + targetFrame.origin.y, finalSize.width, finalSize.height); + + return finalFrame; +} + +#pragma mark - Animations + +- (void)animateZoomInAnimation:(id)transitionContext +{ + //originalImageView + UIImageView *originalImageView = [self.sourceViewController originalImageView]; + originalImageView.hidden = YES; + CGRect convertedFrame = [self.sourceViewController convertedFrameForOriginalImageView]; + + //toViewController + UIViewController *toViewController = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey]; + toViewController.view.frame = [transitionContext finalFrameForViewController:toViewController]; + [[transitionContext containerView] addSubview:toViewController.view]; + toViewController.view.alpha = 0.0; + + //destinationImageView + UIImageView *destinationImageView = [toViewController finalImageView]; + destinationImageView.hidden = YES; + + //transitioningImageView + UIImageView *transitioningImageView = [[UIImageView alloc] initWithImage:originalImageView.image]; + transitioningImageView.frame = convertedFrame; + [[transitionContext containerView] addSubview:transitioningImageView]; + CGRect finalFrameForTransitioningView = [[self class] aspectFitImage:originalImageView.image inFrame:toViewController.view.frame]; + + + //animation + [UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{ + toViewController.view.alpha = 1.0; + transitioningImageView.frame = finalFrameForTransitioningView; + } completion:^(BOOL finished) { + [transitioningImageView removeFromSuperview]; + destinationImageView.hidden = NO; + originalImageView.hidden = NO; + [transitionContext completeTransition:![transitionContext transitionWasCancelled]]; + }]; +} + +- (void)animateZoomOutAnimation:(id)transitionContext +{ + //fromViewController + UIViewController *fromViewController = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey]; + UIImageView *destinationImageView = [fromViewController finalImageView]; + destinationImageView.hidden = YES; + + //toViewController + UIViewController *toViewController = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey]; + toViewController.view.frame = [transitionContext finalFrameForViewController:toViewController]; + [[transitionContext containerView] insertSubview:toViewController.view belowSubview:fromViewController.view]; + UIImageView *originalImageView = [self.sourceViewController originalImageView]; + originalImageView.hidden = YES; + CGRect convertedFrame = [self.sourceViewController convertedFrameForOriginalImageView]; + + //transitioningImageView + UIImageView *transitioningImageView = [[UIImageView alloc] initWithImage:destinationImageView.image]; + transitioningImageView.frame = [[self class] aspectFitImage:destinationImageView.image inFrame:destinationImageView.frame]; + [[transitionContext containerView] addSubview:transitioningImageView]; + + //animation + [UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{ + fromViewController.view.alpha = 0.0; + transitioningImageView.frame = convertedFrame; + } completion:^(BOOL finished) { + [transitioningImageView removeFromSuperview]; + destinationImageView.hidden = NO; + originalImageView.hidden = NO; + [transitionContext completeTransition:![transitionContext transitionWasCancelled]]; + }]; +} + +#pragma mark - UIViewControllerAnimatedTransitioning + +- (NSTimeInterval)transitionDuration:(id)transitionContext +{ + return 0.3; +} + +- (void)animateTransition:(id)transitionContext +{ + switch (self.animationType) { + case PhotoBrowserZoomInAnimation: + [self animateZoomInAnimation:transitionContext]; + break; + + case PhotoBrowserZoomOutAnimation: + [self animateZoomOutAnimation:transitionContext]; + break; + } +} + + +@end diff --git a/Riot/Modules/MatrixKit/Animators/MXKAttachmentInteractionController.h b/Riot/Modules/MatrixKit/Animators/MXKAttachmentInteractionController.h new file mode 100644 index 000000000..64b55cc22 --- /dev/null +++ b/Riot/Modules/MatrixKit/Animators/MXKAttachmentInteractionController.h @@ -0,0 +1,26 @@ +/* + Copyright 2017 Aram Sargsyan + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import "MXKAttachmentAnimator.h" + +@interface MXKAttachmentInteractionController : UIPercentDrivenInteractiveTransition + +@property (nonatomic) BOOL interactionInProgress; + +- (instancetype)initWithDestinationViewController:(UIViewController *)viewController sourceViewController:(UIViewController *)sourceViewController; + +@end diff --git a/Riot/Modules/MatrixKit/Animators/MXKAttachmentInteractionController.m b/Riot/Modules/MatrixKit/Animators/MXKAttachmentInteractionController.m new file mode 100644 index 000000000..ba3d952fc --- /dev/null +++ b/Riot/Modules/MatrixKit/Animators/MXKAttachmentInteractionController.m @@ -0,0 +1,204 @@ +/* + Copyright 2017 Aram Sargsyan + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAttachmentInteractionController.h" +#import "MXLog.h" + +@interface MXKAttachmentInteractionController () + +@property (nonatomic, weak) UIViewController *destinationViewController; +@property (nonatomic, weak) UIViewController *sourceViewController; + +@property (nonatomic) UIImageView *transitioningImageView; +@property (nonatomic, weak) id transitionContext; + +@property (nonatomic) CGPoint translation; +@property (nonatomic) CGPoint delta; + +@end + +@implementation MXKAttachmentInteractionController + +#pragma mark - Lifecycle + +- (instancetype)initWithDestinationViewController:(UIViewController *)viewController sourceViewController:(UIViewController *)sourceViewController +{ + self = [super init]; + if (self) { + self.destinationViewController = viewController; + self.sourceViewController = sourceViewController; + self.interactionInProgress = NO; + + [self preparePanGestureRecognizerInView:viewController.view]; + } + return self; +} + +#pragma mark - Gesture recognizer + +- (void)preparePanGestureRecognizerInView:(UIView *)view +{ + UIPanGestureRecognizer *recognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handleGesture:)]; + recognizer.minimumNumberOfTouches = 1; + recognizer.maximumNumberOfTouches = 3; + [view addGestureRecognizer:recognizer]; +} + +- (void)handleGesture:(UIPanGestureRecognizer *)recognizer +{ + CGPoint translation = [recognizer translationInView:self.destinationViewController.view]; + self.delta = CGPointMake(translation.x - self.translation.x, translation.y - self.translation.y); + self.translation = translation; + + switch (recognizer.state) { + case UIGestureRecognizerStateBegan: + + self.interactionInProgress = YES; + + if (self.destinationViewController.navigationController) { + [self.destinationViewController.navigationController popViewControllerAnimated:YES]; + } else { + [self.destinationViewController dismissViewControllerAnimated:YES completion:nil]; + } + + break; + + case UIGestureRecognizerStateChanged: + + [self updateInteractiveTransition:(ABS(translation.y) / (CGRectGetHeight(self.destinationViewController.view.frame) / 2))]; + + break; + + case UIGestureRecognizerStateCancelled: + + self.interactionInProgress = NO; + [self cancelInteractiveTransition]; + + break; + + case UIGestureRecognizerStateEnded: + + self.interactionInProgress = NO; + if (ABS(self.translation.y) < CGRectGetHeight(self.destinationViewController.view.frame)/6) { + [self cancelInteractiveTransition]; + } else { + [self finishInteractiveTransition]; + } + + break; + + default: + MXLogDebug(@"UIGestureRecognizerState not handled"); + break; + } +} + +#pragma mark - UIPercentDrivenInteractiveTransition + +- (void)startInteractiveTransition:(id )transitionContext +{ + self.transitionContext = transitionContext; + + UIViewController *fromViewController = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey]; + UIImageView *destinationImageView = [self.destinationViewController finalImageView]; + destinationImageView.hidden = YES; + + UIViewController *toViewController = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey]; + toViewController.view.frame = [transitionContext finalFrameForViewController:toViewController]; + [[transitionContext containerView] insertSubview:toViewController.view belowSubview:fromViewController.view]; + UIImageView *originalImageView = [self.sourceViewController originalImageView]; + originalImageView.hidden = YES; + + self.transitioningImageView = [[UIImageView alloc] initWithImage:destinationImageView.image]; + self.transitioningImageView.frame = [MXKAttachmentAnimator aspectFitImage:destinationImageView.image inFrame:destinationImageView.frame]; + [[transitionContext containerView] addSubview:self.transitioningImageView]; +} + +- (void)updateInteractiveTransition:(CGFloat)percentComplete { + self.destinationViewController.view.alpha = MAX(0, (1 - percentComplete)); + + CGRect newFrame = CGRectMake(self.transitioningImageView.frame.origin.x, self.transitioningImageView.frame.origin.y + self.delta.y, CGRectGetWidth(self.transitioningImageView.frame), CGRectGetHeight(self.transitioningImageView.frame)); + self.transitioningImageView.frame = newFrame; +} + +- (void)cancelInteractiveTransition { + UIViewController *fromViewController = [self.transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey]; + UIImageView *destinationImageView = [self.destinationViewController finalImageView]; + UIImageView *originalImageView = [self.sourceViewController originalImageView]; + + __weak typeof(self) weakSelf = self; + + [UIView animateWithDuration:([self transitionDuration:self.transitionContext]/2) animations:^{ + if (weakSelf) + { + typeof(self) self = weakSelf; + fromViewController.view.alpha = 1; + self.transitioningImageView.frame = [MXKAttachmentAnimator aspectFitImage:destinationImageView.image inFrame:destinationImageView.frame]; + } + } completion:^(BOOL finished) { + if (weakSelf) + { + typeof(self) self = weakSelf; + destinationImageView.hidden = NO; + originalImageView.hidden = NO; + [self.transitioningImageView removeFromSuperview]; + + [self.transitionContext cancelInteractiveTransition]; + [self.transitionContext completeTransition:NO]; + } + }]; +} + +- (void)finishInteractiveTransition +{ + UIViewController *fromViewController = [self.transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey]; + UIImageView *destinationImageView = [self.destinationViewController finalImageView]; + + UIImageView *originalImageView = [self.sourceViewController originalImageView]; + CGRect originalImageViewFrame = [self.sourceViewController convertedFrameForOriginalImageView]; + + __weak typeof(self) weakSelf = self; + + [UIView animateWithDuration:[self transitionDuration:self.transitionContext] animations:^{ + if (weakSelf) + { + typeof(self) self = weakSelf; + fromViewController.view.alpha = 0.0; + self.transitioningImageView.frame = originalImageViewFrame; + } + } completion:^(BOOL finished) { + if (weakSelf) + { + typeof(self) self = weakSelf; + [self.transitioningImageView removeFromSuperview]; + destinationImageView.hidden = NO; + originalImageView.hidden = NO; + + [self.transitionContext finishInteractiveTransition]; + [self.transitionContext completeTransition:YES]; + } + }]; +} + +#pragma mark - UIViewControllerAnimatedTransitioning + +- (NSTimeInterval)transitionDuration:(id)transitionContext +{ + return 0.3; +} + + +@end diff --git a/Riot/Modules/MatrixKit/Assets/InfoPlist.strings b/Riot/Modules/MatrixKit/Assets/InfoPlist.strings new file mode 100644 index 000000000..9625bbddd --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/InfoPlist.strings @@ -0,0 +1,25 @@ +/* + Copyright 2016 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +// Permissions usage explanations +// The usage of the camera is explicit. No need of explanation. +"NSCameraUsageDescription" = ""; +"NSPhotoLibraryUsageDescription" = ""; +"NSMicrophoneUsageDescription" = ""; + +// We show a popup before accessing Contacts explaining why. No need of more explanation. +"NSContactsUsageDescription" = ""; + diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon.png new file mode 100644 index 000000000..dfef9b68d Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon@2x.png new file mode 100644 index 000000000..96d24fa95 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon@3x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon@3x.png new file mode 100644 index 000000000..c5186c770 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon@3x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon@4x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon@4x.png new file mode 100644 index 000000000..c6e227a11 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon@4x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/bubble_ios_messages_right.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/bubble_ios_messages_right.png new file mode 100644 index 000000000..50d90254c Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/bubble_ios_messages_right.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/bubble_ios_messages_right@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/bubble_ios_messages_right@2x.png new file mode 100644 index 000000000..d0bec7c32 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/bubble_ios_messages_right@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/default-profile.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/default-profile.png new file mode 100644 index 000000000..b48dd4098 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/default-profile.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/default-profile@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/default-profile@2x.png new file mode 100644 index 000000000..6f81a3c41 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/default-profile@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/disclosure.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/disclosure.png new file mode 100755 index 000000000..501026d49 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/disclosure.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/disclosure@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/disclosure@2x.png new file mode 100644 index 000000000..3920aba23 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/disclosure@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/filetype-gif.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/filetype-gif.png new file mode 100644 index 000000000..a4f048f7b Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/filetype-gif.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/filetype-gif@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/filetype-gif@2x.png new file mode 100644 index 000000000..415769083 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/filetype-gif@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_audio_mute.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_audio_mute.png new file mode 100755 index 000000000..020c67583 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_audio_mute.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_audio_mute@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_audio_mute@2x.png new file mode 100755 index 000000000..2804524e1 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_audio_mute@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_audio_unmute.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_audio_unmute.png new file mode 100755 index 000000000..1eb9a831b Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_audio_unmute.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_audio_unmute@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_audio_unmute@2x.png new file mode 100644 index 000000000..87095ec67 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_audio_unmute@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_backtoapp.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_backtoapp.png new file mode 100644 index 000000000..0c0db3a59 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_backtoapp.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_backtoapp@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_backtoapp@2x.png new file mode 100644 index 000000000..ece39112e Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_backtoapp@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_keyboard.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_keyboard.png new file mode 100644 index 000000000..8fbefd852 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_keyboard.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_keyboard@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_keyboard@2x.png new file mode 100644 index 000000000..f6a01a8f6 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_keyboard@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_minus.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_minus.png new file mode 100644 index 000000000..93e8cc466 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_minus.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_minus@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_minus@2x.png new file mode 100644 index 000000000..4239229fc Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_minus@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_pause.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_pause.png new file mode 100644 index 000000000..4120c3c32 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_pause.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_pause@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_pause@2x.png new file mode 100644 index 000000000..93e8d149b Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_pause@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_play.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_play.png new file mode 100644 index 000000000..82f93f7e5 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_play.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_play@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_play@2x.png new file mode 100644 index 000000000..a7151e5e8 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_play@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_speaker_off.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_speaker_off.png new file mode 100755 index 000000000..dbfdc83cc Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_speaker_off.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_speaker_off@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_speaker_off@2x.png new file mode 100644 index 000000000..acc6eba4d Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_speaker_off@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_speaker_on.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_speaker_on.png new file mode 100755 index 000000000..1022e8d98 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_speaker_on.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_speaker_on@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_speaker_on@2x.png new file mode 100755 index 000000000..198c12d90 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_speaker_on@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video.png new file mode 100755 index 000000000..8ee589aaa Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video@2x.png new file mode 100755 index 000000000..7b68c34c4 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video_mute.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video_mute.png new file mode 100755 index 000000000..efdb97478 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video_mute.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video_mute@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video_mute@2x.png new file mode 100755 index 000000000..1c041f16a Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video_mute@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video_unmute.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video_unmute.png new file mode 100755 index 000000000..ff47d6256 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video_unmute.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video_unmute@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video_unmute@2x.png new file mode 100755 index 000000000..b196c5052 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video_unmute@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/logoHighRes.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/logoHighRes.png new file mode 100644 index 000000000..7187eada0 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/logoHighRes.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/logoHighRes@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/logoHighRes@2x.png new file mode 100644 index 000000000..c4b53a848 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/logoHighRes@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/matrixUser.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/matrixUser.png new file mode 100755 index 000000000..09496113e Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/matrixUser.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/matrixUser@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/matrixUser@2x.png new file mode 100755 index 000000000..9282f3d60 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/matrixUser@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/network_matrix.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/network_matrix.png new file mode 100644 index 000000000..cf21452bc Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/network_matrix.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/network_matrix@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/network_matrix@2x.png new file mode 100644 index 000000000..64d6f4891 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/network_matrix@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/play.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/play.png new file mode 100755 index 000000000..e4da01208 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/play.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/play@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/play@2x.png new file mode 100755 index 000000000..50e34bc24 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/play@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/shrink.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/shrink.png new file mode 100755 index 000000000..ab04eedc0 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/shrink.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/shrink@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/shrink@2x.png new file mode 100644 index 000000000..43021be82 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/shrink@2x.png differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/busy.mp3 b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/busy.mp3 new file mode 100644 index 000000000..fec27ba4c Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/busy.mp3 differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/callend.mp3 b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/callend.mp3 new file mode 100644 index 000000000..50c34e564 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/callend.mp3 differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/message.mp3 b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/message.mp3 new file mode 100644 index 000000000..942adbe85 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/message.mp3 differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/ring.mp3 b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/ring.mp3 new file mode 100644 index 000000000..3c3cdde3f Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/ring.mp3 differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/ringback.mp3 b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/ringback.mp3 new file mode 100644 index 000000000..6ee34bf39 Binary files /dev/null and b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/ringback.mp3 differ diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/ar.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/ar.lproj/MatrixKit.strings new file mode 100644 index 000000000..fd6b76f33 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/ar.lproj/MatrixKit.strings @@ -0,0 +1,549 @@ + + +"show_details" = "إظهَارُ التَّفاصيل"; +"cancel_download" = "إلغَاءُ التَّنزيل"; +"cancel_upload" = "إلغَاءُ الرَّفع"; +"select_all" = "تَحدِيدُ الكُل"; +"resend_message" = "إعادَةُ إرسَالِ الرِّسالَة"; +"reset_to_default" = "إعادَةُ الضَّبط إلى الاِفتِراضي"; +"invite_user" = "دَعوَة مُستَخدِمِ matrix"; +"capture_media" = "اِلتِقَاطُ صُّورة/مَقطَعَ مَرئيّ"; +"attach_media" = "إرفاقُ وسائطٍ مِنَ المَكتَبَة"; +"select_account" = "حَدِّد حِسَابًا"; +"mention" = "الذِّكْر"; +"start_video_call" = "بَدءُ مُكالَمَةٍ مَرئيَّة"; +"start_voice_call" = "بَدءُ مُكالَمَةٍ صَوتيَّة"; +"start_chat" = "بَدءُ مُحادَثَة"; +"login_error_resource_limit_exceeded_contact_button" = "التَّواصُل مع المُدير"; +"login_error_resource_limit_exceeded_message_contact" = "\n\nيُرجَى التَّواصُل مَعَ مُدير خِدمَتك لِمُواصَلَة اِستِخدام هَذِهِ الخِدمَة."; +"set_admin" = "تَعيِينُ مُدير"; +"set_moderator" = "تَعيِينُ مُشرِف"; +"set_default_power_level" = "إعادَة ضَبط مُستَوى القُوَّة"; +"set_power_level" = "ضَبط مُستَوى القُوَّة"; +"submit_code" = "تَسلِيم الرَّمز"; +"submit" = "التَّسلِيم"; +"sign_up" = "الاِشتِراك"; +"retry" = "إعادَةُ المُحاوَلة"; +"dismiss" = "إبعَاد"; +"discard" = "اِستِبعاد"; +"continue" = "الاِستِمرار"; +"close" = "إغلاق"; +"back" = "الرُّجُوع"; +"abort" = "إِجهَاض"; +"yes" = "نَعَم"; + +// Action +"no" = "لَا"; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "هَذَا الخادِم الرَّئيس قَد وَصَلَ إلى حَدّه الشَّهري للمُستخدِم النَّشِط."; +"login_error_resource_limit_exceeded_message_default" = "هَذَا الخادِم الرَّئيس قَد تَجاوزَ أحَد حُدُود مَوارِده."; +"login_error_resource_limit_exceeded_title" = "تَمَّ تَجاوُز حَدّ المَورِد"; +"login_email_placeholder" = "عُنوان بَريد إلِكتُرونيّ"; +"login_email_info" = "إنَّ تَعيِين عُنوان بَريد إلِكتُرونيّ يُتيحُ لِلمُستَخدِمين الآخَرين العُثُور عَلَيك في Matrix بِشَكل أسهَل، وَيَمنَحُكَ طَريقَة لِإعادَة تَعيِين كَلِمة المُرُور الخاصَّة بِك في المُستَقبَل."; +"login_desktop_device" = "سَطح مَكتَب"; +"login_tablet_device" = "لَوحي"; +"login_mobile_device" = "هَاتِف"; +"login_error_forgot_password_is_not_supported" = "نِسيان كَلِمَة المُرُور غَير مَدعُوم حاليًا"; +"register_error_title" = "فَشَلَ التَّسجِيل"; +"login_invalid_param" = "مُعامِل غَير صَالِح"; +"login_leave_fallback" = "إلغَاء"; +"login_use_fallback" = "اِستِخدام صَفحَة اِحتياطيَّة بَديلَة"; +"login_error_user_in_use" = "إنَّ اِسم المُستَخدِم هَذَا مُستخدَمٌ بِالفِعل"; +"login_error_limit_exceeded" = "لَقَد أُرسِلَت العَديد مِنَ الطَّلَبات"; +"login_error_not_json" = "لَم يَحتَوي عَلَى JSON صالِح"; +"login_error_bad_json" = "إنَّ JSON مُشَوَّه"; +"login_error_unknown_token" = "لَم يَتِمّ التَّعرُّف عَلَى رَمز الوُصُول المَذكُور"; +"login_error_forbidden" = "اِسم مُستَخدِم/كَلِمَةُ مُرُور غَير صالِحَة"; +"login_error_registration_is_not_supported" = "إنَّ التَّسجِيل غَير مَدعوم حاليًا"; +"login_error_do_not_support_login_flows" = "حاليًا، نَحنُ لا نَدعَم أيًا مِن تَدَفُقات تَسجيل الدُّخُول المُعَرَّفة بِواسِطَة هَذَا الخادِم الرَّئيس"; +"login_error_no_login_flow" = "لَقَد فَشلنَا في اِستِرداد بَيَانات المُصادَقَة مِن هَذَا الخادِم الرَّئيس"; +"login_error_title" = "فَشَلَ تَسجيلُ الدُّخُول"; +"login_prompt_email_token" = "يُرجى إدخال رَمز التَّحقُّق مِنَ البَريد الإلِكتُرونيّ الخاص بِك:"; +"login_user_id_placeholder" = "مُعَرِّف Matrix (مِثل bob:matrix.org@ أو bob)"; +"login_display_name_placeholder" = "الاِسم الظّاهِر (مِثل. محمد عبدالله)"; +"login_optional_field" = "اِختياري"; +"login_password_placeholder" = "كَلِمَةُ المُرُور"; +"login_identity_server_info" = "تُوَفِّرُ Matrix خَوادِم هُويَّة لتَتَبُع عَناوين البَريد الإلِكتُرونيّ وَما إلى ذَلِكَ مِنَ الَّتي تَنتَمي إلى مُعَرِّفات Matrix. فَقَط https://matrix.org مُتَوَفِّر حاليًا."; +"login_identity_server_title" = "عُنوان URL لِخادِم الهُويَّة:"; +"login_home_server_info" = "الخادِم الرَّئيس الخاصّ بِك يُخَزِّنُ جَميع مُحادَثاتك وَمَعلُومات حِسابك"; +"login_home_server_title" = "عُنوان URL لِلخادِم الرَّئيس:"; +"login_server_url_placeholder" = "عُنوان URL (مِثل https://matrix.org)"; + +// Login Screen +"login_create_account" = "إنشاءُ حِساب:"; +/* *********************** */ +/* iOS specific */ +/* *********************** */ + +"matrix" = "Matrix"; +"notice_encrypted_message" = "رِسَالَةٌ مُعمّاة"; +"notice_room_related_groups" = "المَجمُوعاتُ المُرتَبِطَةُ بِهَذِهِ الغُرفَةِ هِيَ: %@"; +"notice_room_aliases_for_dm" = "الاَسماءُ البَديلَة هِيَ: %@"; +"notice_room_aliases" = "الاَسماءُ البَديلَة لِلغُرفَة هِيَ: %@"; +"notice_room_power_level_event_requirement" = "مُستَوى القُوَّة الأدنَى المُتَعلِّقُ بِالأحداث هُوَ:"; +"notice_room_power_level_acting_requirement" = "مُستَوى القُوَّة الأدنَى الَّذي يَجِبُ عَلَى المُستَخدِم اِمتِلاكُه قَبلَ التَّفاعُل هُوَ:"; +"notice_room_power_level_intro_for_dm" = "مُستَوى القُوَّة لِلأعضاءِ هُوَ:"; +"notice_room_power_level_intro" = "مُستَوى القُوَّة لِأعضاءِ الغُرفَة هُوَ:"; +"notice_room_join_rule_public_by_you_for_dm" = "أنتَ قَد جَعلتَ هَذِهِ عَامَّة."; +"notice_room_join_rule_public_by_you" = "أنتَ قَد جَعلتَ الغُرفَةَ عَامَّة."; +"notice_room_join_rule_public_for_dm" = "لَقَد جَعَلَ %@ هَذِهِ عَامَّة."; +"notice_room_join_rule_public" = "لَقَد جَعَلَ %@ الغُرفَةَ عَامَّة."; +"notice_room_join_rule_invite_by_you_for_dm" = "أنتَ قَد جَعلتَ هَذِهِ بالدَّعوَةِ فَقَط."; +"notice_room_join_rule_invite_by_you" = "أنتَ قَد جَعلتَ الغُرفَةَ بالدَّعوَةِ فَقَط."; +"notice_room_join_rule_invite_for_dm" = "لَقَد جَعَلَ %@ هَذِهِ بِالدَّعوَةِ فَقَط."; +// New +"notice_room_join_rule_invite" = "لَقَد جَعَلَ %@ الغُرفَةَ بِالدَّعوَةِ فَقَط."; +// Old +"notice_room_join_rule" = "قاعِدَة الاِنضِمام هِيَ: %@"; +"notice_room_created_for_dm" = "لَقَد اِنضَمَّ %@."; +"notice_room_created" = "لَقَد أنشأ %@ الغُرفَة وَهَيَّأَها."; +"notice_profile_change_redacted" = "لَقَد حدَّثَ %@ مَلفَّهُ الشَّخصي %@"; +"notice_event_redacted_reason" = " [السَّبَب: %@]"; +"notice_event_redacted_by" = " بِواسِطَة %@"; +"notice_event_redacted" = "<مُنَقَّح%@>"; +"notice_room_topic_removed" = "لَقَد أزالَ %@ المَوضُوع"; +"notice_room_name_removed_for_dm" = "لَقَد أزالَ %@ الاِسم"; +"notice_room_name_removed" = "لَقَد أزالَ %@ اِسمَ الغُرفَة"; + +// Events formatter +"notice_avatar_changed_too" = "(غُيِّرَت الصُّورَة الرَّمزية أيضًا)"; +"unignore" = "إلغَاءُ التَّجاهُل"; +"ignore" = "تَجاهُل"; +"end_call" = "إنهَاءُ المُكالَمَة"; +"reject_call" = "رَفضُ المُكالَمَة"; +"answer_call" = "الإجابَةُ عَلَى المُكالَمَة"; +"attachment_multiselection_original" = "الحَجمُ الفِعلي"; +"attachment_multiselection_size_prompt" = "هَل تُريدُ إِرسَالَ الصُّوَرِ كَـ :"; +"attachment_cancel_upload" = "إلغَاء الرَّفع؟"; +"attachment_cancel_download" = "إلغَاء التَّنزيل؟"; +"attachment_large" = "كَبير: %@"; +"attachment_medium" = "مُتَوَسِّط: %@"; +"attachment_small" = "صَغير: %@"; +"attachment_original" = "الحَجمُ الفِعلي: %@"; + +// Attachment +"attachment_size_prompt" = "هَل تُريدُ الإِرسَالَ كَـ :"; +"room_member_power_level_prompt" = "لَن تَكُونَ قَادِرًا عَلَى التَّراجُع عَن هَذا التَّغيِير فَأنتَ تُرَقِّي المُستَخدِم لِيَكُونَ لَهُ نَفس مُستَوى القُوَّة الَّذِي لدَيك.\nهَل أَنتَ مُتَأكِّد؟"; + +// Room members +"room_member_ignore_prompt" = "هَل أنتَ مُتَأكِّدٌ مِن رَغبَتِكَ فِي إخفَاءِ جَميعِ الرَّسَائِل عَن هَذَا المُستَخدِم؟"; +"message_reply_to_message_to_reply_to_prefix" = "رَدًّا عَلَى"; +"message_reply_to_sender_sent_a_file" = "أرسَلَ مَلَفّ."; +"message_reply_to_sender_sent_a_video" = "أرسَلَ مَقطَعًا مَرئيًا."; +"message_reply_to_sender_sent_an_audio_file" = "أرسَلَ مَلَفًّا صَوتيًا."; + +// Reply to message +"message_reply_to_sender_sent_an_image" = "أرسَلَ صُورَةَ."; +"room_no_conference_call_in_encrypted_rooms" = "إنَّ مُكالَمَاتُ الاِجتِمَاع غَيرُ مَدعُومَةٍ فِي الغُرَفِ المُعمّاة"; +"room_no_power_to_create_conference_call" = "أنتَ بِحاجَةٍ إلَى إذن لِلدَّعوَة حَتَّى تَبدَأَ اِجتِمَاع فِي هَذِهِ الغُرفَة"; +"room_left_for_dm" = "أنتَ قَد غادَرت"; +"room_left" = "أنتَ قَد غادَرتَ الغُرفَة"; +"room_error_timeline_event_not_found" = "التَّطبيقُ كَانَ يُحاوِلُ تَحميلَ نُقطَةٍ مُعَيَّنَةٍ مِنَ الخَطِّ الزَّمَنيِ لِهَذِهِ الغُرفَة لَكِن تَعَذَّرَ عَليهِ العُثُور عَلَيهَا"; +"room_error_cannot_load_timeline" = "فَشَلَ تَحمِيلُ الخَطِّ الزَّمَنِي"; +"room_error_timeline_event_not_found_title" = "فَشَلَ تَحمِيلُ مَوضِعِ الخَطِّ الزَّمَنِي"; +"room_error_topic_edition_not_authorized" = "أنتَ غَيرُ مُخَوَّلٍ لِتَحرِيرِ مَوضُوعِ هَذِهِ الغُرفَة"; +"room_error_name_edition_not_authorized" = "أنتَ غَيرُ مُخَوَّلٍ لِتَحرِيرِ اِسمِ هَذِهِ الغُرفَة"; +"room_error_join_failed_empty_room" = "حاليًّا مِن غَيرِ المُمكِنِ إعادَةُ الاِنضِمامِ إلى غُرفَةٍ فارِغَة."; +"room_error_join_failed_title" = "فَشَلَ الاِنضِمام إلى الغُرفَة"; + +// Room +"room_please_select" = "يُرجَى تَحديدُ غُرفَة"; +"room_creation_participants_placeholder" = "(مِثل. ‭(@bob:homeserver1; @john:homeserver2..."; +"room_creation_participants_title" = "المُشَارِكُون:"; +"room_creation_alias_placeholder_with_homeserver" = "(مِثل. %@foo#)"; +"room_creation_alias_placeholder" = "(مِثل. foo:example.org#)"; +"room_creation_alias_title" = "اِسمُ الغُرفَةِ البَديل:"; +"room_creation_name_placeholder" = "(مِثل. مجموعة الغداء)"; + +// Room creation +"room_creation_name_title" = "اِسمُ الغُرفَة:"; +"account_error_push_not_allowed" = "الإِشعَاراتُ غَيرُ مَسمُوحَة"; +"account_error_msisdn_wrong_description" = "يَبدو أنَّ هَذَا لَيسَ رَقمُ هَاتِفٍ صَالِح"; +"account_error_msisdn_wrong_title" = "رَقمُ هَاتِفٍ غَيرُ صَالِح"; +"account_error_email_wrong_description" = "يَبدو أنَّ هَذَا لَيسَ عُنوان بَريد إلِكتُرونيّ صَالِح"; +"account_error_email_wrong_title" = "عُنوانُ بَريدٍ إلِكتُرونيّ غَيرَ صَالِح"; +"account_error_matrix_session_is_not_opened" = "جَلسَةُ Matrix غَيرَ مَفتُوحَة"; +"account_error_display_name_change_failed" = "فَشَلَ تَغيِيرُ الاِسم الظّاهِر"; +"account_error_picture_change_failed" = "فَشَلَ تَغيِيرُ الصُّورَة"; +"account_msisdn_validation_error" = "يَتَعَذَّرُ التَّحَقُّق مِن رَقمِ الهَاتِف."; +"account_msisdn_validation_message" = "لَقَد أرسَلنا رِسَالَة SMS تَحوِي رَمزًا لِلتَفعِيل. يُرجَى إدخَالُ هَذَا الرَّمز أَدناه."; +"account_msisdn_validation_title" = "قَيدُ التَّحَقُّق"; +"account_email_validation_title" = "قَيدُ التَّحَقُّق"; +"account_email_validation_error" = "يَتَعَذَّر التَّحَقُّق مِن عُنوان البَريد إلِكتُرونيّ. يُرجَى الاِطِّلاع عَلَى البَريد إلِكتُرونيّ الخاصّ بِك ثُمَّ النَّقر عَلَى الرَّابِط الّذي يَحوِيه. بِمُجَرَّد الاِنتِهاء مِن ذَلِك، اُنقُر عَلَى الاِستِمرار"; +"account_email_validation_message" = "يُرجَى الاِطِّلاعُ عَلَى البَريدِ إلِكتُرونيّ الخاصِّ بِك ثُمَّ النَّقرَ عَلَى الرَّابِط الَّذي يَحوِيه. بِمُجَرَّدِ الاِنتِهاءُ مِن ذَلِك، اُنقُر عَلَى الاِستِمرار."; +"account_linked_emails" = "عَناوينُ البَريدِ الإلِكتُرونيّ المُرتَبِطَة"; +"account_link_email" = "رَبطُ بَريدٍ إلِكتُرونيّ"; + +// Account +"account_save_changes" = "حِفظُ التَّغَيُّرات"; +"room_event_encryption_verify_ok" = "تَأكِيدُ التَّحَقُّق"; +"room_event_encryption_verify_message" = "لِلتَحَقُّق مِن إِمكانيَّة الوُثُوق بِهَذِه الجَلسَة، يُرجَى التَّوَاصُل مَعَ المَالِك بِاِستِخدام بَعض الوَسَائِل الأُخرَى (عَلَى سَبِيلِ المِثَال شَخصيًّا أَو عَن طَرِيق مُكَالَمَة هَاتِفيَّة) وَاِسأَلهُ عَمَّا إِذَا كَانَ المِفتَاح الَّذِي يَرَاه فِي إعدادَات المُستَخدِم لِهَذِهِ الجَلسَة يَتَطَابَقُ مَعَ المِفتَاح أَدناه:\n\nاِسم الجَلسَة: %@\nمُعَرِّف الجَلسَة: %@\nمِفتَاح الجَلسَة: %@\n\nإِذَا تَطَابق، اِضغَط عَلَى زِرِّ التَّحَقُّق أَدناه. إِذَا لَم يَحدُث ذَلِك، فَهَذَا يَعنِي أَنَّ شَخصًا آخَر يَعتَرَضُ هَذِهِ الجَلسَة وَرُبَّما تُوَدُّ الضَّغطَ عَلَى زِرُّ الإضافَةِ لِلقَائِمَة السَّوداء بَدَلَا مِن ذَلِك.\n\n سَوفَ تَكُونُ عَمَلِيَّةُ التَّحَقُّقِ هَذِهِ أَكثَرُ تَطَوُّرًا في المُستَقبَل."; +"room_event_encryption_verify_title" = "التَّحَقُّقُ مِنَ الجَلسَة\n\n"; +"room_event_encryption_info_unblock" = "الإزالَة مِنَ القائِمَةِ السَّوداء"; +"room_event_encryption_info_block" = "الإضافَةُ إلى القائِمَةِ السَّوداء"; +"room_event_encryption_info_unverify" = "إلغَاءُ التَّحَقُّق"; +"room_event_encryption_info_verify" = "يَجري التَّحَقُّق..."; +"room_event_encryption_info_device_blocked" = "ضِمنُ القائِمَةِ السَّوداء"; +"room_event_encryption_info_device_not_verified" = "غَيرُ مُتَحَقَّقٍ مِنه"; +"room_event_encryption_info_device_verified" = "مُتَحَقَّقٌ مِنه"; +"room_event_encryption_info_device_fingerprint" = "بَصمَة Ed25519\n"; +"room_event_encryption_info_device_verification" = "التَّحَقُّق\n"; +"room_event_encryption_info_device_id" = "المُعَرِّف\n"; +"room_event_encryption_info_device_name" = "الاِسمُ العَامّ\n"; +"room_event_encryption_info_device_unknown" = "جَلسَةٌ غَيرَ مَعرُوفة\n"; +"room_event_encryption_info_device" = "\nمَعلُومَاتُ جَلسَةِ المُرسِل\n"; +"room_event_encryption_info_event_none" = "لَا شَيء"; +"room_event_encryption_info_event_unencrypted" = "غَيرُ مُعَمَى"; +"room_event_encryption_info_event_decryption_error" = "خَطَأٌ فِي فَكِّ التَّعميَة\n"; +"room_event_encryption_info_event_session_id" = "مُعَرِّفُ الجَلسَة\n"; +"room_event_encryption_info_event_algorithm" = "خَوارِزميَّة\n"; +"room_event_encryption_info_event_fingerprint_key" = "مُطَالَبَةُ مِفتَاحِ بَصمَة Ed25519\n"; +"room_event_encryption_info_event_identity_key" = "مِفتَاحُ هُويَّة Curve25519\n"; +"room_event_encryption_info_event_user_id" = "مُعَرِّفُ المُستَخدِم\n"; +"room_event_encryption_info_event" = "مَعلُومَاتُ الحَدَث\n"; + +// Encryption information +"room_event_encryption_info_title" = "مَعلُومَاتُ تَعمِيَةِ النِّهايَة-إلى-النِّهايَة\n\n"; +"device_details_delete_prompt_message" = "هَذِهِ العَمَلِيَّة تَتَطَلَّبُ مُصادَقةً إضافيَة.\nللاِستِمرار، يُرجَى إدخَالُ كَلِمَةُ المُرُورِ الخاصَّةِ بِك."; +"device_details_delete_prompt_title" = "المُصادَقَة"; +"device_details_rename_prompt_message" = "إنَّ اِسمَ الجَلسَةِ العَامّ مَرئيٌّ لِلأشخَاصِ الَّذِينَ تَتَواصَلُ مَعَهُم"; +"device_details_rename_prompt_title" = "اِسمُ الجَلسَة"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_last_seen" = "آخِرُ ظُهور\n"; +"device_details_identifier" = "المُعَرِّف\n"; +"device_details_name" = "الاِسمُ العَامّ\n"; + +// Devices +"device_details_title" = "مَعلُومَاتُ الجَلسَة\n"; +"notification_settings_room_rule_title" = "الغُرفَة: '%@'"; +"settings_enter_validation_token_for" = "أدخِل رَمزَ المُصادَقَة لِـ %@:"; +"settings_enable_push_notifications" = "تَفعِيلُ دَفعِ الإِشعَارات"; +"settings_enable_inapp_notifications" = "تَفعِيلُ الإِشعَاراتِ دَاخِلَ التَّطبِيق"; + +// Settings +"settings" = "الإعدَادَات"; +"room_displayname_more_than_two_members" = "العُضو %@ وَعَدَد %@ آخَرُون"; +"room_displayname_two_members" = "العُضو %@ وَ %@"; + +// room display name +"room_displayname_empty_room" = "غُرفَةٌ فَارِغَة"; +"notice_in_reply_to" = "رَدًّا عَلَى"; +"notice_sticker" = "مُلصَق"; +"notice_crypto_error_unknown_inbound_session_id" = "إنَّ جَلسَةَ المُرسِل لَم تُرسِل إلَينا المَفاتيح لِهَذِهِ الرِّسَالَة."; +"notice_crypto_unable_to_decrypt" = "** يَتَعَذَّرُ فَكَّ التَّعميَة: %@ **"; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "لَقَد جَعَلَ %@ الرَّسائِلَ المُستَقبَليَّة مَرئيَّةٌ لِلجَميع، مُنذُ أنِ اِنضَمُّوا."; +"notice_room_history_visible_to_members_from_joined_point" = "لَقَد جَعَلَ %@ تَأريخَ الغُرفَةِ المُستَقبَليّ مَرئيٌّ لِجَميعِ أعضاءِ الغُرفَة، مِنَ النُّقطَةِ الَّتي اِنضَمُّوا فِيهَا."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "لَقَد جَعَلَ %@ الرَّسائِلَ المُستَقبَليَّة مَرئيَّةٌ لِلجَميع، مُنذُ أن تَمَّت دَعوَتُهُم."; +"notice_room_history_visible_to_members_from_invited_point" = "لَقَد جَعَلَ %@ تَأريخَ الغُرفَةِ المُستَقبَليّ مَرئيٌّ لِجَميعِ أعضاءِ الغُرفَة، مِنَ النُّقطَةِ الَّتي تَمَّت دَعوَتُهُم فِيهَا."; +"notice_room_history_visible_to_members_for_dm" = "لَقَد جَعَلَ %@ الرَّسائِلَ المُستَقبَليَّة مَرئيَّةٌ لِجَميعِ أعضاءِ الغُرفَة."; +"notice_room_history_visible_to_members" = "لَقَد جَعَلَ %@ تَأريخَ الغُرفَةِ المُستَقبَليّ مَرئيٌّ لِجَميعِ أعضاءِ الغُرفَة."; +"notice_room_history_visible_to_anyone" = "لَقَد جَعَلَ %@ تَأريخَ الغُرفَةِ المُستَقبَليّ مَرئيٌّ لِأَيّ شَخص."; +"notice_error_unknown_event_type" = "حَدثٌ غَيرُ مَعرُوفِ النَّوع"; +"notice_error_unexpected_event" = "حَدَثٌ غَيرُ مُتَوَقَّع"; +"notice_error_unsupported_event" = "حَدَثٌ غَيرُ مَدعُوم"; +"notice_redaction" = "لَقَد نَقَّحَ %@ حَدَث (المُعَرِّف: %@)"; +"notice_feedback" = "حَدَثُ اِنطِباع (المُعَرِّف: %@): %@"; +"notice_unsupported_attachment" = "مُرفَقٌ غَيرُ مَدعُوم: %@"; +"notice_invalid_attachment" = "مُرفَقٌ غَيرُ صَالِح"; +"notice_file_attachment" = "مُرفَقُ مَلَفّ"; +"notice_location_attachment" = "مُرفَقُ مَوقِعٍ جُغرَافِيّ"; +"notice_video_attachment" = "مُرفَق مَقطَع مَرئي"; +"notice_audio_attachment" = "مُرفَق صَوت"; +"notice_image_attachment" = "مُرفَق صُّورَة"; +"notice_encryption_enabled_unknown_algorithm" = "لَقَد شَغَّلَ %1$@ تَعميَة النِّهايَة-إلى-النِّهايَة (خَوارِزميَّة غَير مُتَعَرَّف عَليها %2$@)."; +"notice_encryption_enabled_ok" = "لَقَد شَغَّلَ %@ تَعميَة النِّهايَة-إلى-النِّهايَة."; +"power_level" = "مُستَوى القُوَّة"; +"public" = "عَامّ"; +"private" = "خاصّ"; +"default" = "الاِفتراضي"; +"not_supported_yet" = "غَيرُ مَدعُومَةٍ حَتَّى الآن"; +"error_common_message" = "لَقَد حَدَثَ خَطَأ. يُرجَى المُحاوَلَة مَرَّةً أُخرَى."; +"error" = "خَطَأ"; +"unsent" = "غَيرُ مُرسَلَة"; +"offline" = "غَيرُ مُتَّصِل"; + +// Others +"user_id_title" = "مُعَرِّف المُستَخدِم:"; +"e2e_passphrase_not_match" = "عِبارَاتُ المُرُورِ يَجِبُ أن تَكُونَ مُتَطابَقة"; +"e2e_passphrase_create" = "إنشاءُ عِبارَةِ مُرُور"; +"e2e_passphrase_empty" = "عِبارَةُ المُرُورِ يَجِبُ ألَا تَكونَ خَالِيَة"; +"e2e_passphrase_confirm" = "تَأكيدُ عِبارَةِ المُرُور"; +"e2e_export_prompt" = "تُتيحُ لَكَ هَذِهِ العَمَلِيَّة تَصدير مَفاتيح الرَّسائِل الَّتي قَد تَلَقَّيتَها فِي الغُرَف المُعَمّاة إلى مَلَفّ مَحَلِّيّ. يُمكِنُكَ بَعدَ ذَلِكَ أن تَستَورِدَ المَلَفّ إلى عَميل Matrix آخر في المُستَقبَل، لِكَي يَتَمَكَّن هَذَا العَميل أيضًا مِن فَكّ تَعمِيَة هَذِهِ الرَّسائِل.\nالمَلَفّ الَّذِي قَد تَمَّ تَصديرُه سَيَسمَحُ لِأيّ شَخص يُمكِنَهُ قِرَاءته أن يَفُكّ تَعمِيَة أيّ رَسائِل مُعمّاة يُمكِنُكَ رُؤيَتها، لِذَلِك يَجِبُ أن تَكُونَ حَريصًا عَلَى إبقائه آمِن."; +"e2e_export" = "تَصدير"; + +// E2E export +"e2e_export_room_keys" = "تَصديرُ مَفاتيحِ الغُرفَة"; +"e2e_passphrase_enter" = "أدخِل عِبارَةَ المُرُور"; +"e2e_import" = "اِستيراد"; +"e2e_import_prompt" = "تُتِيحُ لَكَ هَذِهِ العَمَلِيَّة اِستيراد المَفاتيح الَّتي قَد صَدَّرتَهَا مُسبقًا مِن عَميل Matrix آخر. أنتَ سَتَتَمَكَّن بَعدَ ذَلِك مِن فَكّ تَّعميَة جَميع رَسائِل الَّتي يُمكِن لِلعَميل الآخر فَكّ تَعمِيَتها.\nإنَّ المَلَفّ الْمُصَدَّر مَحميّ بِعِبارَة مُرُور. يَجِبُ إدخال عِبارَة المُرُور هُنا لِفَكّ تَعمِيَة المَلَفّ."; + +// E2E import +"e2e_import_room_keys" = "اِستيرادُ مَفاتيحِ الغُرفَة"; +"format_time_d" = "ي"; +"format_time_h" = "س"; +"format_time_m" = "د"; + +// Time +"format_time_s" = "ث"; +"search_searching" = "يَجري البَحث..."; + +// Search +"search_no_results" = "لَا تُوجَدُ نَتائِج"; +"group_section" = "المَجمُوعات"; + +// Groups +"group_invite_section" = "الدَّعَوات"; +"contact_local_contacts" = "جِهاتُ الاِتِّصالِ المَحَلِّيَّة"; + +// Contacts +"contact_mx_users" = "مُستَخدِمُو Matrix"; +"attachment_e2e_keys_import" = "يَجري الاِستيراد..."; +"attachment_e2e_keys_file_prompt" = "يَحتَوي هَذَا المَلَفّ عَلَى مَفاتيح تَعمِيَة قَد تمَّ تَصديرُها مِن عَميلِ Matrix.\nهَل تُريدُ عَرضَ مُحتَوى المَلفّ أمِ اِستيرادُ المَفاتيحِ الَّتي يَحويها؟"; + +// Settings screen +"settings_title_config" = "الإعداد"; +"notice_room_third_party_registered_invite_by_you" = "أنتَ قَد قَبلتَ دَعوَة %@"; +"notice_room_third_party_revoked_invite" = "لَقَد ألغَى %@ دَعوَة %@ لِلاِنضِمام إلى الغُرفَة"; +"notice_room_third_party_registered_invite" = "لَقَد قَبَلَ %@ دَعوَةَ %@‏"; +"ssl_only_accept" = "فَقَطّ اِقبَل الشَّهادَة إذا نَشَرَ مُدير الخادِم بَصمَة تُطابِق البَصمَة أعلَاه."; +"ssl_expected_existing_expl" = "لَقَد تَغَيَّرَت الشَّهادَة الَّتي قَد كانَت مَوثُوقَة مُسبَقًا إلى أُخرَى غَير مَوثُوقَة. مِنَ المُمكِن أنَّ الخادِم قَد جَدَّدَ شَهادَته. تَواصَل مَعَ المُدير لِلحُصُول عَلَى البَصمَة المُتَوَقَّعَة."; +"ssl_unexpected_existing_expl" = "تَمَّ تَغيِير الشَّهادَة الَّتي قَد كانَت مَوثُوقة مِن قِبَل هاتِفك. هَذَا غَيرُ طَبِيعِيّ لِلغَايَة. يُوصَى بِعَدَم قُبُول هَذِهِ الشَّهادَة الجَديدَة."; +"ssl_cert_new_account_expl" = "إذَا قَالَ مُديرُ الخادِم أنَّ هَذَا مُتَوَقَّع، فَتَأكَّد مِن تَطابُق البَصمَة أدناه مَعَ البَصمَة الَّتي قَد قَدَّمَها."; +"ssl_cert_not_trust" = "هَذَا قَد يَعني أنَّ شَخصًا ما يَعتَرِضُ حَرَكَة المُرُور الخَاصَّةِ بِك بِخُبث، أو أنَّ هَاتِفَكَ لا يَثِقُ في الشَّهادَة المُقَدَّمَة مِنَ الخادِمِ البَعيد."; +"notification_settings_per_word_info" = "تُطابَق الكَلِمات مَعَ حالَة الأحرُف بِشَكل غَير حَساس، وَقَد تَتَضَمَّن حَرف البَدَل *. وَبِالتَّالي:\nإنَّ foo تَتَطابَق مَعَ السِّلسِلَة foo المُحاطَة بِمُحَدِّدِات الكَلِمَة (مِثل عَلامات التَّرقيم وَالمَسافَة البَيضاء أو بِدايَة/نِهايَة السَّطر).\nإنَّ *foo تَتَطابَق مَعَ أي كَلِمَة تَبدَأُ بِـ foo.\nإنَّ *foo* تَتَطابَق مع أي كَلِمَة مِن هَذَا القَبيل وَتَتَضَمَّن الثَّلاث أحرُف foo."; +"notification_settings_global_info" = "تَُحفَظُ إعدادات الإشعَارات في حِسابِ المُستَخدِم الخاصِّ بِك وَتَتِمُّ مُشارَكَتَها بَينَ جَمِيعِ العُمَلاء الَّذينَ يَدعَمُونَها (بِما في ذَلِكَ إشعَاراتُ سَطحِ المَكتَب).\n\nيَتِمُّ تَطبيق القَواعِد بِالتَرتيب؛ أوَّل قاعِدَة تَتَطَابَق تُحَدِّد النَّتيجَة لِلرِسالَة.\nإذًا: الإِشعَارات وَفقًا لِلكَلِمَة أكثَرُ أولَويَّة مِنَ الإِشعَارات وَفقًا لِلغُرفَة الَّتِي أيضًا أكثَرُ أولَويَّة مِنَ الإِشعَارات وَفقًا لِلمُرسِل.\nبِالنِّسبَةِ لِلقَواعِد المُتَعَدِّدَة مِن نَفسِ النَّوع، تَكُونُ الأَولَويَّة لِأوَّل قاعِدَة تَتَطابَق في القائِمَة."; +"login_error_login_email_not_yet" = "لَم يُنقَر عَلَى الرَّابِط الَّذي في البَريد الإلِكتُرونيّ حَتَّى الآن"; +"ssl_could_not_verify" = "تَعَذَّرَ التَّحَقُّق مِن هُوِيَّة الخَادِم البَعيد."; +"ssl_fingerprint_hash" = "بَصمَة (%@):"; +"ssl_remain_offline" = "تَجاهُل"; +"ssl_logout_account" = "تَسجِيلُ الخُرُوج"; + +// unrecognized SSL certificate +"ssl_trust" = "الوُثُوق"; +"call_invite_expired" = "لَقَد اِنتَهَت صَلاحيَّةُ دَعوَة المُكالَمَة"; +"incoming_voice_call" = "مُكالَمَةٌ صَوتيَّةٌ وَارِدَة"; +"incoming_video_call" = "مُكالَمَةٌ مَرئيَّةٌ وَارِدَة"; +"call_ring" = "تَجري المُكالَمَة…"; +"call_ended" = "اِنتَهَت المُكالَمَة"; +"call_connecting" = "الاِتِّصالُ جارٍ…"; + +// Settings keys + +// call string +"call_waiting" = "يَجري الاِنتِظار…"; +"settings_config_user_id" = "مُعَرِّفُ المُستَخدِم: %@"; +"settings_config_identity_server" = "خادِم الهُويَّة: %@"; + +// gcm section +"settings_config_home_server" = "الخادِم الرَّئيس: %@"; +"notification_settings_notify_all_other" = "أخطِرني لِكَافَّةِ الرَّسائِل/الغُرَف الأُخرَى"; +"notification_settings_by_default" = "بِشَكلٍ اِفتِراضي..."; +"notification_settings_suppress_from_bots" = "كَتْمُ الإشعَارات مِنَ الرُّوبُوتَات"; +"notification_settings_receive_a_call" = "أخطِرني عِندَما أَتَلَقَّى مُكالَمَة"; +"notification_settings_people_join_leave_rooms" = "أخطِرني عِندَما يَنضَمّ الأشخاص أو يُغادِرُوا الغُرَف"; +"notification_settings_invite_to_a_new_room" = "أخطِرني عِندَما أُدعَى إلى غُرفَة جَديدَة"; +"notification_settings_just_sent_to_me" = "أخطِرني بِالصَّوت حَولَ الرَّسَائِل الَّتي أُرسِلَت إليَّ فَقَط"; +"notification_settings_contain_my_display_name" = "أخطِرني بِالصَّوت حَولَ الرَّسَائِل الَّتي تَحتَوي عَلَى الاِسم الظّاهِر الخاصّ بِي"; +"notification_settings_contain_my_user_name" = "أخطِرني بِالصَّوت حَولَ الرَّسَائِل الَّتي تَحتَوي عَلَى اِسم المُستَخدِم الخاصّ بِي"; +"notification_settings_other_alerts" = "تَنبيهاتٌ أُخرَى"; +"notification_settings_select_room" = "حَّدِد غُرفَة"; +"notification_settings_sender_hint" = "user:domain.com@"; +"notification_settings_per_sender_notifications" = "الإِشعَاراتٌ وَفقًا لِلمُرسِل"; +"notification_settings_per_room_notifications" = "الإِشعَاراتُ وَفقًا لِلغُرفَة"; +"notification_settings_custom_sound" = "صَوتٌ مُخَصَّص"; +"notification_settings_highlight" = "إبرَاز"; +"notification_settings_word_to_match" = "كَلِمَةٌ لِلمُطابَقَة"; +"notification_settings_never_notify" = "لا تُخطِرني أبَدًا"; +"notification_settings_always_notify" = "أخطِرني دَومًا"; +"notification_settings_per_word_notifications" = "الإِشعَاراتُ وَفقًا لِلكَلِمَة"; +"notification_settings_enable_notifications_warning" = "جَميعُ الإِشعَاراتِ مُعَطَّلَةٌ حالِيًّا لِكُلِ الأجهِزَة."; +"notification_settings_enable_notifications" = "تَفعِيلُ الإِشعَارات"; + +// Notification settings screen +"notification_settings_disable_all" = "تَعطيلُ كَافَّةِ الإِشعَارات"; +"settings_title_notifications" = "الإِشعَارات"; + +// contacts list screen +"invitation_message" = "أوَدُّ أن أتَحَدَّثَ مَعكَ بِاستِخدامِ matrix. يرُجَى زيارَةُ المَوقِع https://matrix.org لِلحُصُولِ عَلَى مَزيدٍ مِنَ المَعلُومات."; + +// members list Screen + +// accounts list Screen + +// image size selection + +// invitation members list Screen + +// room creation dialog Screen + +// room info dialog Screen + +// room details dialog screen +"room_details_title" = "تَفاصيلُ الغُرفَة"; +"login_error_must_start_http" = "عُنوانُ URL يَجِبُ أن يَبدَأ بِـ //:[s]http"; + +// Login Screen +"login_error_already_logged_in" = "مُسَجِّل الدُّخُولِ بِالفِعل"; +"message_unsaved_changes" = "تُوجَدُ تَغيِيرات غَير مَحفُوظَة. المُغادَرَة سَوفَ تَستَبعِدَها."; +"unban" = "رَفع-الحَظْر"; +"ban" = "حَظْر"; +"kick" = "طَرد"; +"invite" = "اُدعُ"; +"num_members_other" = "عَدَد %@ مُستَخدِم"; +"num_members_one" = "عَدَد %@ مُستَخدِم"; +"membership_ban" = "حَظْر"; +"membership_leave" = "غادَر"; +"membership_invite" = "مَدعُوّ"; +"create_account" = "إنشاءُ حِساب"; +"login" = "تَسجيلُ الدُّخُول"; +"create_room" = "إنشاءُ غُرفَة"; + +// actions +"action_logout" = "تَسجِيلُ الخُرُوج"; +"view" = "الاِطِّلاع"; +"delete" = "حَذف"; +"share" = "مُشارَكَة"; +"redact" = "إزالَة"; +"resend" = "إعادَة الإرسَال"; +"copy_button_name" = "نَسخ"; +"send" = "إرسَال"; +"leave" = "المُغادَرَة"; +"save" = "حِفظ"; +"cancel" = "إلغَاء"; + +// Room Screen + +// general errors + +// Home Screen + +// Last seen time + +// call events + +/* -*- + Automatic localization for en + + The following key/value pairs were extracted from the android i18n file: + /console/src/main/res/values/strings.xml. +*/ + + +// titles + +// button names +"ok" = "حَسَنًا"; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "أنتَ قَد جَعَلتَ الرَّسائِلَ المُستَقبَليَّة مَرئيَّة لِلجَميع، مُنذُ أنِ اِنضَمُّوا."; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "أنتَ قَد جَعَلتَ تَأريخَ الغُرفَةِ المُستَقبَليّ مَرئيٌّ لِجَميعِ أعضاءِ الغُرفَة، مِن النُّقطَة الَّتي اِنضَمُّوا فِيهَا."; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "أنتَ قَد جَعَلتَ الرَّسائِلَ المُستَقبَليَّة مَرئيَّة لِلجَميع، مُنذُ أن تَمَّت دَعوَتُهُم."; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "أنتَ قَد جَعَلتَ تَأريخ الغُرفَة المُستَقبَليّ مَرئيٌّ لِجَميعِ أعضاءِ الغُرفَة، مِنَ النُّقطَةِ الَّتي تَمَّت دَعوَتُهُم فِيهَا."; +"notice_room_history_visible_to_members_by_you_for_dm" = "أنتَ قَد جَعَلتَ الرَّسائِل المُستَقبَليَّة مَرئيَّة لِجَميع أعضاء الغُرفَة."; +"notice_room_history_visible_to_members_by_you" = "أنتَ قَد جَعَلتَ تَأريخَ الغُرفَةِ المُستَقبَليّ مَرئيٌّ لِجَميعِ أعضاءِ الغُرفَة."; +"notice_room_history_visible_to_anyone_by_you" = "أنتَ قَد جَعَلتَ تَأريخ الغُرفَة المُستَقبَليّ مَرئي لِأَيّ شَخص."; +"notice_redaction_by_you" = "أنتَ قَد نَقَّحتَ حَدَث (المُعَرِّف: %@))"; +"notice_encryption_enabled_unknown_algorithm_by_you" = "أنتَ قَد شَغَّلتَ تَعمِيَة النِّهايَة-إلى-النِّهايَة. (خَوارِزميَّة غَير مُتَعَرَّف عَليها %@)."; +"notice_encryption_enabled_ok_by_you" = "أنتَ قَد فعَّلتَ تَعمِيَة النِّهايَة-إلى-النِّهايَة."; +"notice_room_created_by_you_for_dm" = "أنتَ قَد اِنضَمَمت."; +"notice_room_created_by_you" = "أنتَ قَد أنشأتَ الغُرفَة وَهَيَّأَتَها."; +"notice_profile_change_redacted_by_you" = "أنتَ قَد حدَّثتَ مَلفَّكَ الشَّخصي %@"; +"notice_event_redacted_by_you" = " بِواسِطَتِك"; +"notice_room_topic_removed_by_you" = "أنتَ قَد أزلتَ المَوضُوع"; +"notice_room_name_removed_by_you_for_dm" = "أنتَ قَد أزَلتَ الاِسم"; +"notice_room_name_removed_by_you" = "أنتَ قَد أزَلتَ اِسم الغُرفَة"; +"notice_conference_call_request_by_you" = "أنتَ قَد طَلبتَ عَقد اِجتِمَاع VoIP"; +"notice_ended_video_call_by_you" = "أنتَ قَد أنهيتَ المُكالَمَة"; +"notice_answered_video_call_by_you" = "أنتَ قَد أجَبتَ عَلَى المُكالَمَة"; +"notice_placed_video_call_by_you" = "أنتَ قَد أجرَيتَ مُكالَمَةً مَرئيَّة"; +"notice_placed_voice_call_by_you" = "أنتَ قَد أجرَيتَ مُكالَمَةً صَوتيَّة"; +"notice_room_name_changed_by_you_for_dm" = "أنتَ قَد غيَّرتَ الاِسم إلى %@."; +"notice_room_name_changed_by_you" = "أنتَ قَد غيَّرتَ اِسمَ الغُرفَةِ إلى %@."; +"notice_topic_changed_by_you" = "أنتَ قَد غيَّرتَ المَوضُوع إلى \"%@\"."; +"notice_display_name_removed_by_you" = "أنتَ قَد أزَلتَ اِسمكَ الظّاهِر"; +"notice_display_name_changed_from_by_you" = "أنتَ قَد غيَّرتَ اِسمكَ الظّاهِر مِن %@ إلى %@"; +"notice_display_name_set_by_you" = "أنتَ قَد عيَّنتَ اِسمكَ الظّاهِر إلى %@"; +"notice_avatar_url_changed_by_you" = "أنتَ قَد غيَّرتَ صُّورَتكَ الرَّمزية"; +"notice_room_withdraw_by_you" = "أنتَ قَد سَحبتَ دَعوَة %@"; +"notice_room_ban_by_you" = "أنتَ قَد حَظرتَ %@"; +"notice_room_unban_by_you" = "أنتَ قَد رَفعتَ الحَظرَ عَن %@"; +"notice_room_kick_by_you" = "أنتَ قَد طَردتَ %@"; +"notice_room_reject_by_you" = "أنتَ قَد رَفضتَ الدَعوَة"; +"notice_room_join_by_you" = "أنتَ قَد اِنضَمَمت"; +"notice_room_leave_by_you" = "أنتَ قَد غادَرت"; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "أنتَ قد ألغيتَ دَعوَة %@"; +"notice_room_third_party_revoked_invite_by_you" = "أنتَ قَد ألغيتَ دَعوَة %@ لِلاِنضِمام إلى الغُرفَة"; +"notice_room_third_party_invite_by_you_for_dm" = "أنتَ قَد دَعوتَ %@"; +"notice_room_third_party_invite_by_you" = "أنتَ قَد أرسَلتَ دَعوَةً إلى %@ لِلاِنضِمامِ إلى الغُرفَة"; +"notice_room_invite_you" = "لَقَد دَعاكَ %@"; + +// Notice Events with "You" +"notice_room_invite_by_you" = "أنتَ قد دَعوتَ %@"; +"notice_conference_call_finished" = "لَقَد اِنتَهَى اِجتِمَاع VoIP"; +"notice_conference_call_started" = "لَقَد بَدأ اِجتِمَاع VoIP"; +"notice_conference_call_request" = "لَقَد طَلَبَ %@ عَقد اِجتِمَاع VoIP"; +"notice_ended_video_call" = "لَقَد أنهَى %@ المُكالَمَة"; +"notice_answered_video_call" = "لَقَد أجابَ %@ عَلَى المُكالَمَة"; +"notice_placed_video_call" = "لَقَد أجرَى %@ مُكالَمَة مَرئيَّة"; +"notice_placed_voice_call" = "لَقَد أجرَى %@ مُكالَمَة صَوتيَّة"; +"notice_room_name_changed_for_dm" = "لَقَد غيَّرَ %@ الاِسم إلى %@."; +"notice_room_name_changed" = "لَقَد غيَّرَ %@ اِسم الغُرفَة إلى %@."; +"notice_topic_changed" = "لَقَد غيَّرَ %@ المَوضُوع إلى \"%@\"."; +"notice_display_name_removed" = "لَقَد أزالَ %@ اِسمهُ الظّاهِر"; +"notice_display_name_changed_from" = "لَقَد غيَّرَ %@ اِسمُهُ الظّاهِر مِن %@ إلى %@"; +"notice_display_name_set" = "لَقَد عيَّنَ %@ اِسمُهُ الظّاهِر إلى %@"; +"notice_avatar_url_changed" = "لَقَد غيَّرَ %@ صُّورَتَهُ الرَّمزية"; +"notice_room_reason" = ". السَّبَب: %@"; +"notice_room_withdraw" = "لَقَد سَحَبَ %@ دَعوَة %@"; +"notice_room_ban" = "إنَّ %@ قَد حَظَرَ %@"; +"notice_room_unban" = "لَقَد رَفَعَ %@ الحَظرَ عَن %@"; +"notice_room_kick" = "إنَّ %@ قَد طَرَدَ %@"; +"notice_room_reject" = "لَقَد رَفَضَ %@ الدَّعوَة"; +"notice_room_leave" = "لَقَد غَادَرَ %@"; +"notice_room_third_party_invite" = "لَقَد أرسَلَ %@ دَعوَةً إلى %@ لِلاِنضِمامِ إلى الغُرفَة"; +"notice_room_join" = "لَقَد اِنضَمَّ %@"; +"notice_room_third_party_revoked_invite_for_dm" = "لَقَد ألغَى %@ دَعوَة %@"; +"notice_room_third_party_invite_for_dm" = "إنَّ %@ قَد أضافَ %@"; + +/* -*- + Automatic localization for en + + The following key/value pairs were extracted from the android i18n file: + /matrix-sdk/src/main/res/values/strings.xml. +*/ + +"notice_room_invite" = "إنَّ %@ قَد أضافَ %@"; +"language_picker_default_language" = "الاِفتراضي (%@)"; + +// Language picker +"language_picker_title" = "اِختَر لُغَةً"; + +// Country picker +"country_picker_title" = "اِختَر بَلَدًا"; +"local_contacts_access_discovery_warning" = "لاِكتِشاف جِهات الاِتِّصال الَّتي تَستَخدِمُ Matrix بِالفِعل، يُمكِنُ لِـ%@ إرسَال عَناوين البَريد الإلِكتُرونيّ وأرقام الهَواتِف الَّتي في دَفتَرِ العَناوين الخاصِّ بِك إلى خادِمِ هُويَّة Matrix المُختار. يَتِّمُ تَجزِئة البَياناتِ الشَّخصيَّة قَبلَ إرسالِها حَيثُما كانَت مَدعُومَة - يُرجى مُراجَعَة سياسَة الخُصُوصيَّة الخاصَّة بِخادِم الهُويَّة لِلحُصُولِ عَلَى المَزيدِ مِنَ التَّفاصيل."; +"local_contacts_access_discovery_warning_title" = "اِكتِشافُ المُستَخدِمين"; +"local_contacts_access_not_granted" = "اِكتِشافُ المُستَخدِمين مِن جِهاتِ الاِتِّصالِ المَحَلِّيَّة يَتَطَلَّب الوُصُول إلَى جِهاتِ الاِتِّصالِ الخاصّةِ بِك لَكِنّ %@ لَيسَ لَدَيهِ الاِذن لاِستِخدامَها"; +"microphone_access_not_granted_for_call" = "المُكالَمَاتُ المَرئيَّة تَتَطَلَّب الوُصُول إلَى المِيكرُوفُون لَكِنّ %@ لَيسَ لَدَيهِ الاِذن لاِستِخدامه"; + +// Permissions +"camera_access_not_granted_for_call" = "المُكالَمَاتُ المَرئيَّة تَتَطَلَّب الوُصُول إلَى الكاميرة لَكِنّ %@ لَيسَ لَدَيهِ الاِذن لاِستِخدامها"; +"ssl_homeserver_url" = "عُنوانُ URL لِلخادِم الرَّئيس: %@"; +"user_id_placeholder" = "مِثال: bob:homeserver@"; +"network_error_not_reachable" = "يُرجَى التَّحَقُّق مِن اِتِّصالَكَ بِالشَبَكَة"; +"call_more_actions_dialpad" = "لَوحَةُ الاِتِّصَال"; +"call_more_actions_transfer" = "النَّقل"; +"call_more_actions_audio_use_device" = "مُكَبِّر صَوت الجِّهَاز"; +"call_more_actions_audio_use_headset" = "اِستِخدامُ صَوتَ سمَّاعَةِ الرَّأس"; +"call_more_actions_change_audio_device" = "تَغيِيرُ جِهَازِ الصَوت"; +"call_more_actions_unhold" = "الاِستِئنَاف"; +"call_more_actions_hold" = "التَّمَسُّك"; +"call_remote_holded" = "لقد عَلَّقَ %@ المكالمة"; +"call_holded" = "أنتَ قَد عَلَّقتَ المُكالَمَة"; +"notice_declined_video_call_by_you" = "أنتَ قَد رَفَضتَ المُكالَمَة"; +"notice_declined_video_call" = "لَقَد رَفضَ %@ المُكالَمَة"; +"resume_call" = "اِستِئنَاف"; +"call_transfer_to_user" = "التَّحويلُ إلَى %@"; +"call_consulting_with_user" = "اِستِشارَة مَعَ %@"; +"call_video_with_user" = "مُكالَمَةٌ مَرئيَّةٌ مَع %@"; +"call_voice_with_user" = "مُكالَمَةٌ صَوتيَّةٌ مَع %@"; +"call_ringing" = "الرَّنِينُ جَارٍ…"; +"e2e_passphrase_too_short" = "عِبارَة المُرُور قَصِيرَةٌ جِدًا (يَجِبُ ألَّا يَقِلَّ طُولهَا عَن %d أحرف)"; +"microphone_access_not_granted_for_voice_message" = "الرَسائِلُ الصَوتيَّةُ تتطَلَبُ الوصُولَ إلَى المِيكرُوفُون لَكِنَ %@ ليسَ لَديهِ إذنٌ لِاستِخدَامِه"; +"message_reply_to_sender_sent_a_voice_message" = "أرسَلَ رِسَالَةً صَوتيَّة."; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/bg.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/bg.lproj/MatrixKit.strings new file mode 100644 index 000000000..0cb86edfd --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/bg.lproj/MatrixKit.strings @@ -0,0 +1,466 @@ +"view" = "Виж"; +"back" = "Назад"; +"continue" = "Продължи"; +"leave" = "Напусни"; +"invite" = "Покани"; +"retry" = "Опитай отново"; +"cancel" = "Отказ"; +"save" = "Запази"; +// room details dialog screen +"room_details_title" = "Информация"; +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "Регистрация:"; +"login_server_url_placeholder" = "URL (напр. https://matrix.org)"; +"login_home_server_title" = "Home сървър адрес:"; +"login_home_server_info" = "Сървърът Ви съхранява цялата кореспонденция и профилни данни"; +"login_identity_server_title" = "Адрес на сървър за самоличност:"; +"login_identity_server_info" = "Matrix предоставя сървъри за самоличност, които проследяват кои имейли и т.н. на кои Matrix идентификатори принадлежат. В момента съществува само https://matrix.org."; +"login_user_id_placeholder" = "Matrix ID (напр. @ivan:matrix.org или ivan)"; +"login_password_placeholder" = "Парола"; +"login_optional_field" = "по избор"; +"login_display_name_placeholder" = "Име (напр. Иван Георгиев)"; +"login_email_info" = "Задайте имейл адрес, за да позволите на другите потребители да Ви намерят по-лесно в Matrix и да можете да възстановите Вашата парола в бъдеще."; +"login_email_placeholder" = "Имейл адрес"; +"login_prompt_email_token" = "Моля, въведете кода за потвърждение от имейл адреса:"; +"login_error_title" = "Неуспешно влизане в профила"; +"login_error_no_login_flow" = "Не успяхме да извлечем информация за автентикация от този Home сървър"; +"login_error_do_not_support_login_flows" = "В момента не поддържаме методите за влизане, определени от този Home сървър"; +"login_error_registration_is_not_supported" = "В момента не се поддържа регистрация"; +"login_error_forbidden" = "Невалидно потребителско име или парола"; +"login_error_unknown_token" = "Не е разпознат указаният тоукън за достъп"; +"login_error_bad_json" = "Грешно формиран JSON"; +"login_error_not_json" = "Не съдържаше валиден JSON"; +"login_error_limit_exceeded" = "Бяха изпратени твърде много заявки"; +"login_error_user_in_use" = "Това потребителско име е вече заето"; +"login_error_login_email_not_yet" = "Все още не сте кликнали върху връзката в имейла"; +"login_use_fallback" = "Използване на страница заместител"; +"login_leave_fallback" = "Откажи"; +"login_invalid_param" = "Невалиден параметър"; +"register_error_title" = "Неуспешна регистрация"; +"login_error_forgot_password_is_not_supported" = "В момента не се поддържа \"Забравена парола\""; +"login_mobile_device" = "Мобилно устройство"; +"login_tablet_device" = "Таблет"; +"login_desktop_device" = "Работен плот"; +// Action +"no" = "Не"; +"yes" = "Да"; +"abort" = "Прекрати"; +"close" = "Затвори"; +"discard" = "Откажи"; +"dismiss" = "Затвори"; +"sign_up" = "Регистрация"; +"submit" = "Изпрати"; +"submit_code" = "Изпрати код"; +"set_default_power_level" = "Възвръщане нивото на достъп"; +"set_moderator" = "Направи модератор"; +"set_admin" = "Направи администратор"; +"start_chat" = "Започни чат"; +"start_voice_call" = "Започни гласов разговор"; +"start_video_call" = "Започни видео разговор"; +"mention" = "Спомени"; +"select_account" = "Изберете профил"; +"attach_media" = "Прикачи файл от галерията"; +"capture_media" = "Направи снимка/видео"; +"invite_user" = "Покани matrix потребител"; +"resend_message" = "Изпрати съобщението отново"; +"reset_to_default" = "Връщане към ниво по подразбиране"; +"select_all" = "Избери всички"; +"cancel_upload" = "Откажи качването"; +"cancel_download" = "Откажи свалянето"; +"show_details" = "Покажи детайлите"; +"answer_call" = "Отговори на повикването"; +"reject_call" = "Откажи повикването"; +"ignore" = "Игнорирай"; +"unignore" = "Премахни игнорирането"; +// Events formatter +"notice_avatar_changed_too" = "(аватарът също беше променен)"; +"notice_room_name_removed" = "%@ премахна името на стаята"; +"notice_room_topic_removed" = "%@ премахна темата"; +"notice_event_redacted" = "<изтрито%@>"; +"notice_event_redacted_by" = " от %@"; +"notice_event_redacted_reason" = " [причина: %@]"; +"notice_profile_change_redacted" = "%@ обнови своя профил %@"; +"notice_room_created" = "%@ създаде и конфигурира стаята."; +"notice_room_join_rule" = "Правилото за присъединяване е: %@"; +"notice_room_power_level_intro" = "Нивата на достъп на членовете в стаята са:"; +"end_call" = "Прекрати разговора"; +"notice_room_power_level_acting_requirement" = "Минималните нива на достъп, които потребител трябва да има за следните действия са:"; +"notice_room_power_level_event_requirement" = "Минималните нива на достъп отнасящи се към събития са:"; +"notice_room_aliases" = "Адресите на стаята са: %@"; +"notice_room_related_groups" = "Групите, асоциирани с тази стая, са: %@"; +"notice_encrypted_message" = "Шифровано съобщение"; +"notice_encryption_enabled" = "%@ включи шифроването от край до край (алгоритъм %@)"; +"notice_image_attachment" = "прикачена снимка"; +"notice_audio_attachment" = "прикачено аудио"; +"notice_video_attachment" = "прикачено видео"; +"notice_location_attachment" = "прикачено местоположение"; +"notice_file_attachment" = "прикачен файл"; +"notice_invalid_attachment" = "невалидно прикачване"; +"notice_unsupported_attachment" = "Прикачен файл, който не се поддържа: %@"; +"notice_feedback" = "Събитие за обратна връзка (id: %@): %@"; +"notice_redaction" = "%@ изтри събитие (id: %@)"; +"notice_error_unsupported_event" = "Събитие, което не се поддържа"; +"notice_error_unexpected_event" = "Неочаквано събитие"; +"notice_error_unknown_event_type" = "Непознат вид на събитието"; +"notice_room_history_visible_to_anyone" = "%@ направи бъдещата история на стаята видима за всеки."; +"notice_room_history_visible_to_members" = "%@ направи бъдещата история на стаята видима за всички членове."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ направи бъдещата история на стаята видима за всички членове, от момента на поканването им в нея."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ направи бъдещата история на стаята видима за всички членове, от момента на присъединяването им в нея."; +"notice_crypto_unable_to_decrypt" = "** Неуспешно разшифроване: %@ **"; +"notice_crypto_error_unknown_inbound_session_id" = "Сесията на подателя не изпрати ключовете за това съобщение."; +// room display name +"room_displayname_empty_room" = "Празна стая"; +"room_displayname_two_members" = "%@ и %@"; +"room_displayname_more_than_two_members" = "%@ и %u други"; +// Settings +"settings" = "Настройки"; +"settings_enable_inapp_notifications" = "Включване на известия в приложението"; +"settings_enable_push_notifications" = "Включване на известия"; +"settings_enter_validation_token_for" = "Въведете код за потвърждение за %@:"; +"notification_settings_room_rule_title" = "Стая: '%@'"; +// Devices +"device_details_title" = "Информация за сесията\n"; +"device_details_name" = "Публично име\n"; +"device_details_identifier" = "ID\n"; +"device_details_last_seen" = "Последно видян\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_rename_prompt_message" = "Публичното име на сесията е видимо за всеки, с който комуникирате"; +"device_details_delete_prompt_title" = "Автентикация"; +"device_details_delete_prompt_message" = "Тази операция изискра допълнителна автентикация.\nЗа да продължите, моля, въведете Вашата парола."; +// Encryption information +"room_event_encryption_info_title" = "Информация за шифроване от край до край\n\n"; +"room_event_encryption_info_event" = "Информация за събитие\n"; +"room_event_encryption_info_event_user_id" = "ID на потребител\n"; +"room_event_encryption_info_event_identity_key" = "Curve25519 ключ за самоличност\n"; +"room_event_encryption_info_event_fingerprint_key" = "Заявен ключов отпечатък Ed25519\n"; +"room_event_encryption_info_event_algorithm" = "Алгоритъм\n"; +"room_event_encryption_info_event_session_id" = "ID на сесия\n"; +"room_event_encryption_info_event_decryption_error" = "Грешка при разшифроване\n"; +"room_event_encryption_info_event_unencrypted" = "нешифрован"; +"room_event_encryption_info_event_none" = "няма"; +"room_event_encryption_info_device" = "\nИнформация за сесията на подателя\n"; +"room_event_encryption_info_device_unknown" = "неизвестна сесия\n"; +"room_event_encryption_info_device_name" = "Публично име\n"; +"room_event_encryption_info_device_id" = "ID\n"; +"room_event_encryption_info_device_verification" = "Потвърждение\n"; +"room_event_encryption_info_device_fingerprint" = "Ed25519 отпечатък\n"; +"room_event_encryption_info_device_verified" = "Потвърдено"; +"room_event_encryption_info_device_not_verified" = "НЕ е потвърдено"; +"room_event_encryption_info_device_blocked" = "В черния списък"; +"room_event_encryption_info_unblock" = "Отблокирай"; +"room_event_encryption_info_block" = "Блокирай"; +"room_event_encryption_info_verify" = "Потвърди..."; +"room_event_encryption_info_unverify" = "Махни потвържд."; +"room_event_encryption_verify_title" = "Потвърди сесията\n\n"; +"room_event_encryption_verify_ok" = "Потвърди"; +// Account +"account_save_changes" = "Запази промените"; +"account_link_email" = "Свържи имейл"; +"account_linked_emails" = "Свързани имейли"; +"account_email_validation_title" = "Очакване на потвърждение"; +"account_email_validation_message" = "Моля, проверете своя имейл адрес и натиснете връзката, която той съдържа. След като направите това, натиснете продължи."; +"account_email_validation_error" = "Неуспешно потвърждение на имейл адрес. Моля, проверете своя имейл адрес и натиснете връзката, която той съдържа. След като направите това, натиснете продължи"; +"account_msisdn_validation_title" = "Очакване на потвърждение"; +"account_msisdn_validation_message" = "Изпратихме Ви SMS с код за активиране. Моля, въведете този код по-долу."; +"account_msisdn_validation_error" = "Неуспешно потвърждение на телефонен номер."; +"account_error_display_name_change_failed" = "Неуспешна смяна на име"; +"account_error_picture_change_failed" = "Неуспешна смяна на профилната снимка"; +"account_error_matrix_session_is_not_opened" = "Matrix сесията не е отворена"; +"account_error_email_wrong_title" = "Невалиден имейл адрес"; +"account_error_email_wrong_description" = "Това не изглежда да е валиден имейл адрес"; +"account_error_msisdn_wrong_title" = "Невалиден телефонен номер"; +"account_error_msisdn_wrong_description" = "Това не изглежда да е валиден телефонен номер"; +// Room creation +"room_creation_name_title" = "Име на стая:"; +"room_creation_name_placeholder" = "(напр. lunchGroup)"; +"room_creation_alias_title" = "Псевдоним на стая:"; +"room_creation_alias_placeholder" = "(напр. #foo:example.org)"; +"room_creation_alias_placeholder_with_homeserver" = "(напр. #foo%@)"; +"room_creation_participants_title" = "Участници:"; +"room_creation_participants_placeholder" = "(напр. @ivan:homeserver1; @georgi:homeserver2...)"; +// Room +"room_please_select" = "Моля, изберете стая"; +"room_error_join_failed_title" = "Неуспешно присъединяване към стаята"; +"room_error_join_failed_empty_room" = "В момента не е възможно да се присъедините отново към празна стая."; +"room_error_name_edition_not_authorized" = "Нямате право да редактирате името на тази стая"; +"room_error_topic_edition_not_authorized" = "Нямате право да редактирате темата на тази стая"; +"room_error_cannot_load_timeline" = "Неуспешно зареждане на хронологията"; +"room_error_timeline_event_not_found_title" = "Неуспешно зареждане на позицията в хронологията"; +"room_error_timeline_event_not_found" = "Беше направен опит да се зареди конкретна точка в хронологията на тази стая, но не я намери"; +"room_left" = "Вие напуснахте стаята"; +"room_no_power_to_create_conference_call" = "Трябва да имате разрешение за изпращане на покани, за да може да започнете групов разговор в тази стая"; +"room_no_conference_call_in_encrypted_rooms" = "Не се поддържат групови разговори в шифровани стаи"; +// Room members +"room_member_ignore_prompt" = "Сигурни ли сте, че искате да скриете всички съобщения от този потребител?"; +"room_member_power_level_prompt" = "Няма да можете да възвърнете тази промяна, тъй като повишавате този потребител до същото ниво на достъп като Вашето.\nСигурни ли сте?"; +// Attachment +"attachment_size_prompt" = "Изпратете с размер:"; +"attachment_original" = "Оригинален: %@"; +"attachment_small" = "Малък: %@"; +"attachment_medium" = "Среден: %@"; +"attachment_large" = "Голям: %@"; +"attachment_cancel_download" = "Отказване на свалянето?"; +"attachment_cancel_upload" = "Отказване на качването?"; +"attachment_multiselection_size_prompt" = "Изпратете снимките с размер:"; +"attachment_multiselection_original" = "Оригинален"; +"attachment_e2e_keys_file_prompt" = "Файлът съдържа ключове за шифроване, експортирани от Matrix клиент.\nИскате ли да видите съдържанието на файла или да импортирате ключовете, които съдържа?"; +"attachment_e2e_keys_import" = "Импортиране..."; +// Contacts +"contact_mx_users" = "Matrix потребители"; +"contact_local_contacts" = "Локални контакти"; +// Groups +"group_invite_section" = "Покани"; +"group_section" = "Групи"; +// Search +"search_no_results" = "Няма резултати"; +"search_searching" = "В процес на търсене..."; +// Time +"format_time_s" = "сек"; +"format_time_m" = "мин"; +"format_time_h" = "ч"; +"format_time_d" = "д"; +// E2E import +"e2e_import_room_keys" = "Импортиране на ключове за стая"; +"e2e_import_prompt" = "Този процес позволява да импортирате ключове за шифроване, които преди сте експортирали от друг Matrix клиент. Тогава ще можете да разшифровате всяко съобщение, което другият клиент може да разшифрова. Експортираният файл може да бъде предпазен с парола. Трябва да въведете парола тук, за да разшифровате файла."; +"e2e_import" = "Импортирай"; +"e2e_passphrase_enter" = "Въведи парола"; +// E2E export +"e2e_export_room_keys" = "Експортиране на ключове за стая"; +"e2e_export_prompt" = "Този процес Ви позволява да експортирате във файл ключовете за съобщения в шифровани стаи. Така ще можете да импортирате файла в друг Matrix клиент, така че той също да може да разшифрова такива съобщения.\nЕкспортираният файл ще позволи на всеки, който може да го прочете, да разшифрова всяко шифровано съобщение, което можете да видите. Трябва да го държите на сигурно място."; +"e2e_export" = "Експортирай"; +"e2e_passphrase_confirm" = "Потвърди парола"; +"e2e_passphrase_empty" = "Паролата не трябва да е празна"; +"e2e_passphrase_not_match" = "Паролите трябва да съвпадат"; +// Others +"user_id_title" = "ID на потребител:"; +"settings_config_user_id" = "ID на потребител: %@"; +"offline" = "офлайн"; +"unsent" = "Неизпратено"; +"error" = "Грешка"; +"not_supported_yet" = "Все още не се поддържа"; +"default" = "по подразбиране"; +"private" = "Лична"; +"public" = "Публична"; +"power_level" = "Ниво на достъп"; +"network_error_not_reachable" = "Моля, проверете интернет връзката си"; +"user_id_placeholder" = "напр.: @ivan:homeserver"; +"ssl_homeserver_url" = "Адрес на Home сървър: %@"; +// Permissions +"camera_access_not_granted_for_call" = "Видео разговорите изискват достъп до камерата, но %@ няма разрешение да я използва"; +"microphone_access_not_granted_for_call" = "Разговорите изискват достъп до микрофона, но %@ няма разрешение да го използва"; +"local_contacts_access_not_granted" = "Откриване на потребители от локални контакти изисква достъп до контактите Ви, но %@ няма разрешение да ги използва"; +"local_contacts_access_discovery_warning_title" = "Откриване на потребители"; +"local_contacts_access_discovery_warning" = "За да открие контакти използващи Matrix, %@ може да изпрати имейл адресите и телефонните номера от телефонния указател към избрания от вас Matrix сървър за самоличност. Ако се поддържа, личните данни могат да бъдат хеширани преди изпращане - вижте политиката за поверителност на сървъра за самоличност за повече информация."; +// Country picker +"country_picker_title" = "Избор на държава"; +// Language picker +"language_picker_title" = "Избор на език"; +"language_picker_default_language" = "По подразбиране (%@)"; +"notice_room_invite" = "%@ покани %@"; +"notice_room_third_party_invite" = "%@ изпрати покана на %@ да се присъедини към стаята"; +"notice_room_third_party_registered_invite" = "%@ прие поканата за %@"; +"notice_room_join" = "%@ се присъедини"; +"notice_room_leave" = "%@ напусна"; +"notice_room_reject" = "%@ отхвърли поканата"; +"notice_room_kick" = "%@ изгони %@"; +"notice_room_unban" = "%@ отблокира %@"; +"notice_room_ban" = "%@ блокира %@"; +"notice_room_withdraw" = "%@ оттегли поканата си за %@"; +"notice_room_reason" = ". Причина: %@"; +"notice_avatar_url_changed" = "%@ смени своята профилна снимка"; +"notice_display_name_set" = "%@ си сложи име %@"; +"notice_display_name_changed_from" = "%@ смени своето име от %@ на %@"; +"notice_display_name_removed" = "%@ премахна своето име"; +"notice_topic_changed" = "%@ промени темата на \"%@\"."; +"notice_room_name_changed" = "%@ промени името на стаята на %@."; +"notice_placed_voice_call" = "%@ започна гласов разговор"; +"notice_placed_video_call" = "%@ започна видео разговор"; +"notice_answered_video_call" = "%@ отговори на повикването"; +"notice_ended_video_call" = "%@ прекрати разговора"; +"notice_conference_call_request" = "%@ заяви VoIP групов разговор"; +"notice_conference_call_started" = "Започна VoIP конференция"; +"notice_conference_call_finished" = "VoIP конференцията приключи"; +// button names +"ok" = "ОК"; +"send" = "Изпрати"; +"copy_button_name" = "Копирай"; +"resend" = "Изпрати отново"; +"redact" = "Премахни"; +"share" = "Сподели"; +"set_power_level" = "Ниво на достъп"; +"delete" = "Изтрий"; +// actions +"action_logout" = "Излез"; +"create_room" = "Създай стая"; +"login" = "Влез"; +"create_account" = "Създай профил"; +"membership_invite" = "Поканен"; +"membership_leave" = "Напуснал"; +"membership_ban" = "Блокиран"; +"num_members_one" = "%@ потребител"; +"num_members_other" = "%@ потребители"; +"kick" = "Изгони"; +"ban" = "Блокирай"; +"unban" = "Отблокирай"; +"message_unsaved_changes" = "Има незапазени промени. При напускане ще се загубят."; +// Login Screen +"login_error_already_logged_in" = "Вече сте в профила си"; +"login_error_must_start_http" = "URL адресът трябва да започва с http[s]://"; +// contacts list screen +"invitation_message" = "Бих искал да си пиша с Вас в matrix. За повече информация, моля, посетете уебсайта http://matrix.org."; +// Settings screen +"settings_title_config" = "Конфигурация"; +"settings_title_notifications" = "Известия"; +// Notification settings screen +"notification_settings_disable_all" = "Изключване на всички известия"; +"notification_settings_enable_notifications" = "Включване на известия"; +"notification_settings_enable_notifications_warning" = "В момента всички известия към всички устройства са изключени."; +"notification_settings_always_notify" = "Известяване винаги"; +"notification_settings_never_notify" = "Известяване никога"; +"notification_settings_word_to_match" = "дума, която да потърсим"; +"notification_settings_highlight" = "Подчертаване"; +"notification_settings_custom_sound" = "Индивидуален звук"; +"notification_settings_per_room_notifications" = "Известия за конкретна стая"; +"notification_settings_per_sender_notifications" = "Известия за конкретен подател"; +"notification_settings_sender_hint" = "@потребител:domain.com"; +"notification_settings_select_room" = "Избиране на стая"; +"notification_settings_other_alerts" = "Други известия"; +"notification_settings_contain_my_user_name" = "Известявай ме със звук за съобщения, съдържащи потребителското ми име"; +"notification_settings_contain_my_display_name" = "Известявай ме със звук за съобщения, съдържащи името ми"; +"notification_settings_just_sent_to_me" = "Известявай ме със звук за съобщения изпратени само до мен"; +"notification_settings_invite_to_a_new_room" = "Известявай ме, когато съм поканен в нова стая"; +"notification_settings_people_join_leave_rooms" = "Известявай ме, когато хората се присъединяват или напускат стаи"; +"notification_settings_receive_a_call" = "Известявай ме, когато получавам обаждане"; +"notification_settings_suppress_from_bots" = "Блокирай известията от ботове"; +"notification_settings_by_default" = "По подразбиране..."; +"notification_settings_notify_all_other" = "Известявай ме за всички други съобщения/стаи"; +"notification_settings_per_word_notifications" = "Известия за конкретна дума"; +// gcm section +"settings_config_home_server" = "Home сървър: %@"; +"settings_config_identity_server" = "Сървър за самоличност: %@"; +// call string +"call_waiting" = "Изчакване..."; +"call_connecting" = "Свързване…"; +"call_ended" = "Разговорът приключи"; +"call_ring" = "Позвъняване..."; +"incoming_video_call" = "Входящо видео повикване"; +"incoming_voice_call" = "Входящо гласово повикване"; +"call_invite_expired" = "Поканата за разговор изтече"; +// unrecognized SSL certificate +"ssl_trust" = "Довери се"; +"ssl_logout_account" = "Излез"; +"ssl_remain_offline" = "Игнорирай"; +"ssl_fingerprint_hash" = "Отпечатък (%@):"; +"ssl_could_not_verify" = "Неуспешно потвърждаване на самоличността на отдалечения сървър."; +"ssl_cert_not_trust" = "Това може да означава, че някой злонамерено прихваща Вашата връзка, или че телефонът Ви не се доверява на сертификата, предоставен от отдалечения сървър."; +"ssl_cert_new_account_expl" = "Ако администраторът на сървъра е обявил, че това е нормално, уверете се, че отпечатъкът по-долу съвпада с този, предоставен от него."; +"ssl_unexpected_existing_expl" = "Сертификатът е различен от този, на който телефонът Ви се доверява. Това е МНОГО НЕОБИЧАЙНО. Препоръчваме да НЕ ПРИЕМАТЕ този нов серфитикат."; +"ssl_expected_existing_expl" = "Сертификатът се промени от такъв, който е бил доверен, на такъв който вече не е. Сървърът може да е подновил своя сертификат. Свържете се с администратора за правилния отпечатък."; +"ssl_only_accept" = "Приемайте сертификата САМО ако администратора на сървъра е публикувал отпечатък, който съвпада с този по-горе."; +"room_event_encryption_verify_message" = "За да потвърдите, че на това устройство може да се вярва, моля свържете се със собственика му по друг начин (напр. на живо или чрез телефонен разговор) и го попитайте дали ключът, който той вижда в неговите настройки на потребителя за това устройство, съвпада с ключа по-долу:\n\n\tИме на сесията: %@\n\tID на сесията: %@\n\tКлюч на сесията: %@\n\nАко съвпада, моля натиснете бутона за потвърждение по-долу. Ако не, то тогава някой друг имитира тази сесия и вероятно искате вместо това да натиснете бутона за черен списък.\n\nВ бъдеще този процес на потвърждение ще бъде по-лесен."; +"notification_settings_global_info" = "Настройки на известията се пазят в потребителския Ви профил и се споделят измежду всички клиенти, които ги поддържат (включително и известия на работния плот).\n\nПравилата се проверяват по ред; първото съвпадащо правило дефинира резултата за съобщението.\nТака че: Известията за конкретна дума са по-важни от известията за конкретна стая, които пък са по-важни от известията за конкретен потребител.\nАко има няколко еднотипни правила, с приоритет е първото съвпадащо."; +"notification_settings_per_word_info" = "Съвпаденията за думите се правят без взимане под внимание на малка/главна буква. Могат да съдържат и * wildcard символ. Така че:\nfoo намира съвпадения за низа foo, ограден от разделители за дума (напр. пунктуация и празно място или начало/край на ред).\nfoo* намира съвпадения във всяка дума започваща с foo.\n*foo* намира съвпадения във всяка дума включваща някъде в себе си низа foo."; +"notice_sticker" = "стикер"; +"notice_in_reply_to" = "В отговор на"; +"error_common_message" = "Възникна грешка. Моля опитайте пак по-късно."; +// Reply to message +"message_reply_to_sender_sent_an_image" = "изпрати снимка."; +"message_reply_to_sender_sent_a_video" = "изпрати видео."; +"message_reply_to_sender_sent_an_audio_file" = "изпрати аудио файл."; +"message_reply_to_sender_sent_a_file" = "изпрати файл."; +"message_reply_to_message_to_reply_to_prefix" = "В отговор на"; +"login_error_resource_limit_exceeded_title" = "Надхвърлен лимит за ресурс"; +"login_error_resource_limit_exceeded_message_default" = "Този сървър е надхвърлил някой свой лимит."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Този сървър е достигнал своя лимит за потребители на месец."; +"login_error_resource_limit_exceeded_message_contact" = "\n\nМоля, свържете се с администратора на услугата за да продължите да я използвате."; +"login_error_resource_limit_exceeded_contact_button" = "Свържи се с администратора"; +"e2e_passphrase_create" = "Създай парола"; +"account_error_push_not_allowed" = "Уведомленията не са разрешени"; +"notice_room_third_party_revoked_invite" = "%@ оттегли поканата за присъединяването на %@ към стаята"; +"device_details_rename_prompt_title" = "Име на сесията"; +"notice_encryption_enabled_ok" = "%@ включи шифроване от-край-до-край."; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ включи шифроване от-край-до-край (неразпознат алгоритъм %2$@)."; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "Направихте бъдещите съобщения видими за всички, от момента на присъединяването им."; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "Направихте бъдещата история на стаята видима за всички нейни членове, от момента на присъединяването им."; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "Направихте бъдещите съобщения видими за всички, от момента на поканването им."; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "Направихте бъдещата история на стаята видима за всички нейни членове, от момента на поканването им."; +"notice_room_history_visible_to_members_by_you_for_dm" = "Направихте бъдещите съобщения видими за всички членове на стаята."; +"notice_room_history_visible_to_members_by_you" = "Направихте бъдещата история на стаята видима за всички нейни членове."; +"notice_room_history_visible_to_anyone_by_you" = "Направихте бъдещата история на стаята видима за всички."; +"notice_redaction_by_you" = "Редактирахте събитие (идентификатор: %@)"; +"notice_encryption_enabled_unknown_algorithm_by_you" = "Включихте шифроване от-край-до-край (непознат алгоритъм %@)."; +"notice_encryption_enabled_ok_by_you" = "Включихте шифроване от-край-до-край."; +"notice_room_created_by_you_for_dm" = "Присъединихте се."; +"notice_room_created_by_you" = "Създадохте и конфигурирахте стаята."; +"notice_profile_change_redacted_by_you" = "Обновихте %@ профила си"; +"notice_event_redacted_by_you" = " от вас"; +"notice_room_topic_removed_by_you" = "Премахнахте темата"; +"notice_room_name_removed_by_you_for_dm" = "Премахнахте името"; +"notice_room_name_removed_by_you" = "Премахнахте името на стаята"; +"notice_conference_call_request_by_you" = "Направихте заявка за VoIP конференция"; +"notice_ended_video_call_by_you" = "Прекратихте разговора"; +"notice_answered_video_call_by_you" = "Отговорихте на разговора"; +"notice_placed_video_call_by_you" = "Започнахте видео разговор"; +"notice_placed_voice_call_by_you" = "Започнахте гласов разговор"; +"notice_room_name_changed_by_you_for_dm" = "Променихте името на %@."; +"notice_room_name_changed_by_you" = "Променихте името на стаята на %@."; +"notice_topic_changed_by_you" = "Променихте темата на \"%@\"."; +"notice_display_name_removed_by_you" = "Премахнахте името си"; +"notice_display_name_changed_from_by_you" = "Променихте името си от %@ на %@"; +"notice_display_name_set_by_you" = "Променихте името си на %@"; +"notice_avatar_url_changed_by_you" = "Променихте снимката си"; +"notice_room_withdraw_by_you" = "Оттеглихте поканата на %@"; +"notice_room_ban_by_you" = "Блокирахте %@"; +"notice_room_unban_by_you" = "Отблокирахте %@"; +"notice_room_kick_by_you" = "Изгонихте %@"; +"notice_room_reject_by_you" = "Отхвърлихте поканата"; +"notice_room_leave_by_you" = "Напуснахте"; +"notice_room_join_by_you" = "Присъединихте се"; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "Оттеглихте поканата на %@"; +"notice_room_third_party_revoked_invite_by_you" = "Оттеглихте поканата от %@ за присъединяване към стаята"; +"notice_room_third_party_registered_invite_by_you" = "Приехте поканата за %@"; +"notice_room_third_party_invite_by_you_for_dm" = "Поканихте %@"; +"notice_room_third_party_invite_by_you" = "Изпратихте покана към %@ за присъединяване в стаята"; +"notice_room_invite_you" = "%@ ви покани"; + +// Notice Events with "You" +"notice_room_invite_by_you" = "Поканихте %@"; +"notice_room_name_changed_for_dm" = "%@ промени името на %@."; +"notice_room_third_party_revoked_invite_for_dm" = "%@ оттегли поканата от %@"; +"notice_room_third_party_invite_for_dm" = "%@ покани %@"; +"room_left_for_dm" = "Напуснахте"; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ направи бъдещите съобщения видими за всички, от момента на присъединяването им в стаята."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ направи бъдещите съобщения видими за всички, от момента на поканването им в стаята."; +"notice_room_history_visible_to_members_for_dm" = "%@ направи бъдещите съобщения видими за всички членове в стаята."; +"notice_room_aliases_for_dm" = "Псевдонимите са: %@"; +"notice_room_power_level_intro_for_dm" = "Нивата на достъп на членовете са:"; +"notice_room_join_rule_public_by_you_for_dm" = "Направихте стаята публична."; +"notice_room_join_rule_public_by_you" = "Направихте тази стая публична."; +"notice_room_join_rule_public_for_dm" = "%@ направи стаята публична."; +"notice_room_join_rule_public" = "%@ направи стаята публична."; +"notice_room_join_rule_invite_by_you_for_dm" = "Направихте достъпа да е само за поканени."; +"notice_room_join_rule_invite_by_you" = "Направихте тази стая достъпна само за поканени."; +"notice_room_join_rule_invite_for_dm" = "%@ направи достъпа да е само за поканени."; +// New +"notice_room_join_rule_invite" = "%@ направи стаята достъпна само за поканени."; +"notice_room_created_for_dm" = "%@ се присъедини."; +"notice_room_name_removed_for_dm" = "%@ премахна името"; +"call_transfer_to_user" = "Прехвърли към %@"; +"call_consulting_with_user" = "Консултация с %@"; +"call_video_with_user" = "Видео разговор с %@"; +"call_voice_with_user" = "Гласов разговор с %@"; +"call_more_actions_dialpad" = "Панел за набиране"; +"call_more_actions_transfer" = "Прехвърляне"; +"call_more_actions_audio_use_headset" = "Използвай звук от слушалките"; +"call_more_actions_audio_use_device" = "Използвай звук от устройството"; +"call_more_actions_change_audio_device" = "Смени аудио устройството"; +"call_more_actions_unhold" = "Възобнови"; +"call_more_actions_hold" = "Задръж"; +"call_holded" = "Задържахте разговора"; +"call_remote_holded" = "%@ задържа разговора"; +"call_ringing" = "Звънене…"; +"notice_declined_video_call_by_you" = "Отказахте разговора"; +"notice_declined_video_call" = "%@ отказа разговора"; +"e2e_passphrase_too_short" = "Паролата е прекалено кратка (трябва да е дълга поне %d символа)"; +"resume_call" = "Възобнови"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/ca.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/ca.lproj/MatrixKit.strings new file mode 100644 index 000000000..7c376a636 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/ca.lproj/MatrixKit.strings @@ -0,0 +1,363 @@ +"matrix" = "Matrix"; +"login_password_placeholder" = "Contrasenya"; +"login_optional_field" = "opcional"; +"login_email_placeholder" = "Adreça de correu electrònic"; +"view" = "Veure"; +"back" = "Torna"; +"continue" = "Continua"; +"leave" = "Sortir"; +"invite" = "Convida"; +"retry" = "Torna a provar"; +"cancel" = "Cancel·lar"; +"save" = "Desar"; +// Login Screen +"login_create_account" = "Crear compte:"; +"login_server_url_placeholder" = "URL (ex. https://matrix.org)"; +"login_home_server_title" = "Servidor hoste:"; +"login_home_server_info" = "El teu servidor hoste desa totes les teves converses i dades del compte"; +"login_identity_server_title" = "Servidor d'identitat:"; +"login_identity_server_info" = "Matrix proporciona servidors d'identitat per fer un seguiment dels correus electrònics etc. que pertanyen als identificadors Matrix. Actualment només existeix https://matrix.org."; +"login_user_id_placeholder" = "ID a Matrix (ex @bob:matrix.org o bob)"; +"login_display_name_placeholder" = "Mostra nom (ex. Bob Obson)"; +// room details dialog screen +"room_details_title" = "Detalls de la sala"; +"login_email_info" = "Especificar una adreça de correu electrònic permet que altres usuaris et trobin més fàcilment a Matrix i et proporcionarà una manera de restablir la teva contrasenya en el futur."; +"login_prompt_email_token" = "Introduïu el teu codi de validació de correu electrònic:"; +"login_error_title" = "Error d'inici de sessió"; +"login_error_no_login_flow" = "No hem pogut recuperar la informació d'autenticació d'aquest servidor hoste"; +"login_error_do_not_support_login_flows" = "Actualment no admetem cap o tots els fluxos d'inici de sessió definits per aquest servidor hoste"; +"login_error_registration_is_not_supported" = "El registre no és suportat per ara"; +"login_error_forbidden" = "Nom d'usuari/contrasenya invàlid"; +"login_error_unknown_token" = "No s'ha reconegut el token d'accés especificat"; +"login_error_bad_json" = "JSON incorrecte"; +"login_error_not_json" = "No contenia JSON vàlid"; +"login_error_limit_exceeded" = "S'han enviat massa sol·licituds"; +"login_error_user_in_use" = "Aquest nom d'usuari ja està en ús"; +"login_error_login_email_not_yet" = "L'enllaç del correu electrònic que encara no heu fet clic"; +"login_use_fallback" = "Usa la pàgina de devolució"; +"login_leave_fallback" = "Cancel·lar"; +"login_invalid_param" = "Paràmetre invàlid"; +"register_error_title" = "Error de registre"; +"login_error_forgot_password_is_not_supported" = "Contrasenya oblidada encara no es suportat"; +"login_mobile_device" = "Mòbil"; +"login_tablet_device" = "Tableta"; +"login_desktop_device" = "Escriptori"; +// Action +"no" = "No"; +"yes" = "Sí"; +"abort" = "Avortar"; +"close" = "Tanca"; +"discard" = "Descarta"; +"dismiss" = "Omet"; +"sign_up" = "Registre"; +"submit" = "Presentar"; +"submit_code" = "Presentar codi"; +"set_default_power_level" = "Restablir el nivell de potència"; +"set_moderator" = "Establir el Moderador"; +"set_admin" = "Establir l'Administrador"; +"start_chat" = "Iniciar xat"; +"start_voice_call" = "Iniciar trucada de veu"; +"start_video_call" = "Iniciar vídeo trucada"; +"mention" = "Mencionar"; +"select_account" = "Selecciona un compte"; +"attach_media" = "Adjunta mitjans de la biblioteca"; +"capture_media" = "Fes una foto o un vídeo"; +"invite_user" = "Convida usuari Matrix"; +"reset_to_default" = "Estableix per defecte"; +"resend_message" = "Reenvia el missatge"; +"select_all" = "Selecciona-ho tot"; +"cancel_upload" = "Cancel·la la pujada"; +"cancel_download" = "Cancel·la la descàrrega"; +"show_details" = "Mostra els detalls"; +"answer_call" = "Respon la trucada"; +"reject_call" = "Rebutja la trucada"; +"end_call" = "Penja"; +"ignore" = "Ignora"; +"unignore" = "Deixa de ignorar"; +// Events formatter +"notice_avatar_changed_too" = "(també s'ha canviat l'avatar)"; +"notice_room_name_removed" = "%@ ha eliminat el nom de la sala"; +"notice_room_topic_removed" = "%@ ha eliminat el tema"; +"notice_event_redacted" = ""; +"notice_event_redacted_by" = " per %@"; +"notice_event_redacted_reason" = " [motiu: %@]"; +"notice_profile_change_redacted" = "%@ ha actualitzat el seu perfil %@"; +"notice_room_created" = "%@ ha creat la sala"; +"notice_room_join_rule" = "La norma per entrar és: %@"; +"notice_room_power_level_intro" = "El nivell de potència dels participants és:"; +"notice_room_power_level_acting_requirement" = "Els nivells de potència mínims que un usuari ha de tenir abans d'actuar són:"; +"notice_room_power_level_event_requirement" = "Els nivells mínims de potència relacionats als esdeveniments són:"; +"notice_room_aliases" = "Els àlies de la sala són: %@"; +"notice_room_related_groups" = "Els grups associats amb aquesta sala són: %@"; +"notice_encrypted_message" = "Missatge xifrat"; +"notice_encryption_enabled" = "%@ ha activat el xifrat punt a punt (algoritme %@)"; +"notice_image_attachment" = "adjunt d'imatge"; +"notice_audio_attachment" = "adjunt d'àudio"; +"notice_video_attachment" = "adjunt de vídeo"; +"notice_location_attachment" = "adjunt de localització"; +"notice_file_attachment" = "adjunt de fitxer"; +"notice_invalid_attachment" = "adjunt invàlid"; +"notice_unsupported_attachment" = "Adjunt no suportat: %@"; +"notice_feedback" = "Esdeveniment de resposta (id: %@): %@"; +"notice_redaction" = "%@ ha redactat un esdeveniment (id: %@)"; +"notice_error_unsupported_event" = "Esdeveniment no suportat"; +"notice_error_unexpected_event" = "Esdeveniment inesperat"; +"notice_error_unknown_event_type" = "Tipus desconegut d'esdeveniment"; +"notice_room_history_visible_to_anyone" = "%@ ha fet visible per qualsevol l'històric futur de la sala."; +"notice_room_history_visible_to_members" = "%@ ha fet visible per tots els participants l'històric futur de la sala."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ ha fet visible l'històric futur de la sala per a tots els membres, a partir de que hi són convidats."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ ha fet visible l'històric futur de la sala a tots els membres, des de que entren a la sala."; +"notice_crypto_unable_to_decrypt" = "** No es pot desxifrar: %@ **"; +"notice_crypto_error_unknown_inbound_session_id" = "El dispositiu del remitent no ha enviat les claus per aquest missatge."; +// room display name +"room_displayname_empty_room" = "Sala buida"; +"room_displayname_two_members" = "%@ i %@"; +"room_displayname_more_than_two_members" = "%@ i %u més"; +// Settings +"settings" = "Configuració"; +"settings_enable_inapp_notifications" = "Habilitar les notificacions de les App integrades"; +"settings_enable_push_notifications" = "Activar notificacions push"; +"settings_enter_validation_token_for" = "Introduir el codi de validació per %@:"; +"notification_settings_room_rule_title" = "Sala: '%@'"; +// Devices +"device_details_title" = "Informació del dispositiu\n"; +"device_details_name" = "Nom\n"; +"device_details_identifier" = "ID del dispositiu\n"; +"device_details_last_seen" = "Vist per últim cop\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_rename_prompt_message" = "Nom del dispositiu:"; +"device_details_delete_prompt_title" = "Autenticació"; +"device_details_delete_prompt_message" = "Aquesta operació necessita que t'autentiquis.\nPer continuar, introdueix la teva contrasenya."; +// Encryption information +"room_event_encryption_info_title" = "Informació del xifrat punt a punt\n\n"; +"room_event_encryption_info_event" = "Informació del esdeveniment\n"; +"room_event_encryption_info_event_user_id" = "ID d'usuari\n"; +"room_event_encryption_info_event_identity_key" = "Clau de la identitat Curve25519\n"; +"room_event_encryption_info_event_fingerprint_key" = "Empremta digital Ed25519 reclamada\n"; +"room_event_encryption_info_event_algorithm" = "Algoritme\n"; +"room_event_encryption_info_event_session_id" = "ID de la sessió\n"; +"room_event_encryption_info_event_decryption_error" = "Error de desxifrat\n"; +"room_event_encryption_info_event_unencrypted" = "no xifrat"; +"room_event_encryption_info_event_none" = "cap"; +"room_event_encryption_info_device" = "\nInformació del dispositiu del remitent\n"; +"room_event_encryption_info_device_unknown" = "dispositiu desconegut\n"; +"room_event_encryption_info_device_name" = "Nom\n"; +"room_event_encryption_info_device_id" = "ID del dispositiu\n"; +"room_event_encryption_info_device_verification" = "Verificació\n"; +"room_event_encryption_info_device_fingerprint" = "Empremta digital Ed25519\n"; +"room_event_encryption_info_device_verified" = "Verificat"; +"room_event_encryption_info_device_not_verified" = "NO verificat"; +"room_event_encryption_info_device_blocked" = "Bloquejat"; +"room_event_encryption_info_verify" = "Verifica..."; +"room_event_encryption_info_unverify" = "No verificar"; +"room_event_encryption_info_block" = "Llista negre"; +"room_event_encryption_info_unblock" = "Desbloquejar"; +"room_event_encryption_verify_title" = "Verifica el dispositiu\n\n"; +"room_event_encryption_verify_message" = "Per a verificar que aquest dispositiu pot ser confiable si us plau contacta el seu propietari per altres mijans (ex. trucant-lo al telèfon) i pregunta-li si la clau que veu a la configuració d'usuari d'aquest dispositiu coincideix amb la clau següent:\n\n\tNom del dispositiu: %@\n\tID del dispositiu: %@\n\tClau del dispositiu: %@\n\nSi coincideix, prem el botó verificar de sota. Si no coincideix es que algú altre està interceptant aquest dispositiu i probablement vols prema el botó de bloquejar a canvi.\n\nEn el futur aquest procés de verificació serà més sofisticat."; +"room_event_encryption_verify_ok" = "Verifica"; +// Account +"account_save_changes" = "Desar canvis"; +"account_link_email" = "Vincular correu electrònic"; +"account_linked_emails" = "Correus electrònics vinculats"; +"account_email_validation_title" = "Verificació pendent"; +"account_email_validation_message" = "Revisa el teu correu electrònic i fes clic a l'enllaç que conté. Un cop fet això, fes clic a continua."; +"account_email_validation_error" = "No ha estat possible verificar l'adreça de correu electrònic. Mira el correu electrònic i fes clic en l'enllaç que conté. Un cop fet això, fes clic per continuar"; +"account_msisdn_validation_title" = "Verificació pendent"; +"account_msisdn_validation_message" = "Hem enviat un SMS amb un codi d'activació. Introdueix aquest codi a continuació."; +"account_msisdn_validation_error" = "No es pot verificar el número de telèfon."; +"account_error_display_name_change_failed" = "Ha fallat el canvi del nom a mostrar"; +"account_error_picture_change_failed" = "Ha fallat el canvi de foto"; +"account_error_matrix_session_is_not_opened" = "No està oberta la sessió Matrix"; +"account_error_email_wrong_title" = "Adreça de correu electrònic no valida"; +"account_error_email_wrong_description" = "Aquest no sembla ser un correu electrònic vàlid"; +"account_error_msisdn_wrong_title" = "Número de telèfon invalid"; +"account_error_msisdn_wrong_description" = "Aquest no sembla ser un número de telèfon vàlid"; +// Room creation +"room_creation_name_title" = "Nom de la sala:"; +"room_creation_name_placeholder" = "(ex. collaesmorzar)"; +"room_creation_alias_title" = "Àlies de sala:"; +"room_creation_alias_placeholder" = "(ex. #foo:exemple.org)"; +"room_creation_alias_placeholder_with_homeserver" = "(ex. #foo%@)"; +"room_creation_participants_title" = "Participants:"; +"room_creation_participants_placeholder" = "(ex. @jordi:servidorhoste1; @juan:servidorhoste2...)"; +// Room +"room_please_select" = "Si us plau tria una sala"; +"room_error_join_failed_title" = "No s'ha pogut entrar a la sala"; +"room_error_join_failed_empty_room" = "Actualment no es pot tornar a entrar a una sala buida."; +"room_error_name_edition_not_authorized" = "No tens permís per editar el nom d'aquesta sala"; +"room_error_topic_edition_not_authorized" = "No tens permís per editar el tema d'aquesta sala"; +"room_error_cannot_load_timeline" = "No s'ha pogut carregar la línia de temps"; +"room_error_timeline_event_not_found_title" = "No s'ha pogut carregar la posició de la línia de temps"; +"room_error_timeline_event_not_found" = "Aquesta aplicació estava intentant carregar un punt especific en la línia de temps d'aquesta sala però no l'ha trobat"; +"room_left" = "Has sortit de la sala"; +"room_no_power_to_create_conference_call" = "Necessites permís per a convidar a iniciar una conferència en aquesta sala"; +"room_no_conference_call_in_encrypted_rooms" = "No es poden fer conferències en sales xifrades"; +// Room members +"room_member_ignore_prompt" = "Estàs segur que vols amagar tots els missatges d'aquest usuari?"; +"room_member_power_level_prompt" = "Si puges aquest usuari al mateix nivell de poder que el teu després no podràs desfer el canvi.\nEstàs segur?"; +// Attachment +"attachment_size_prompt" = "Ho vols enviar com a:"; +"attachment_original" = "Mida actual: %@"; +"attachment_small" = "Petit: %@"; +"attachment_medium" = "Mitjà: %@"; +"attachment_large" = "Gran: %@"; +"attachment_cancel_download" = "Cancel·lar la descàrrega?"; +"attachment_cancel_upload" = "Cancel·lar la pujada?"; +"attachment_multiselection_size_prompt" = "Vols enviar imatges com a:"; +"attachment_multiselection_original" = "Mida actual"; +"attachment_e2e_keys_file_prompt" = "Aquest fitxer conté claus xifrades exportades des d'un client Matrix.\nVols veure el contingut del fitxer o importar les claus que conté?"; +"attachment_e2e_keys_import" = "Importa..."; +// Contacts +"contact_mx_users" = "Usuaris de Matrix"; +"contact_local_contacts" = "Contactes locals"; +// Groups +"group_invite_section" = "Convits"; +"group_section" = "Grups"; +// Search +"search_no_results" = "Sense resultats"; +"search_searching" = "Cercant..."; +// Time +"format_time_s" = "s"; +"format_time_m" = "m"; +"format_time_h" = "h"; +"format_time_d" = "d"; +// E2E import +"e2e_import_room_keys" = "Importa les claus de la sala"; +"e2e_import_prompt" = "Aquest procés et pemet importar les claus de xifratge que previament has exportat des de un altre client Matrix. Després podràs desxifrar qualsevol missatge que l'altre client pugui xifrar.\nEl fitxer exportat està protegit amb una contrasenya. Hauries de introduir la contrasenya aquí per desxifrar-l'ho."; +"e2e_import" = "Importa"; +"e2e_passphrase_enter" = "Introduir contrasenya"; +// E2E export +"e2e_export_room_keys" = "Exporta les claus E2E de la sala"; +"e2e_export_prompt" = "Aquest procés et permet exportar a un fitxer local les claus dels missatges que has rebut de sales xifrades. A continuació, podràs importar el fitxer a un altre client Matrix en el futur, de manera que aquest client també podrà desxifrar aquests missatges.\nEl fitxer exportat permetrà que qualsevol que pugui llegir-lo per desxifrar qualsevol dels missatges xifrats que tu pots veure, així que has de tenir cura de mantenir-lo segur."; +"e2e_export" = "Exporta les claus E2E"; +"e2e_passphrase_confirm" = "Confirma la contrasenya"; +"e2e_passphrase_empty" = "La contrasenya no ha de estar buida"; +"e2e_passphrase_not_match" = "Les contrasenyes han de coincidir"; +// Others +"user_id_title" = "ID d'usuari:"; +"offline" = "fora de línia"; +"unsent" = "No enviats"; +"error" = "Error"; +"not_supported_yet" = "Encara no suportat"; +"default" = "per defecte"; +"private" = "Privat"; +"public" = "Públic"; +"power_level" = "Nivell de potència"; +"network_error_not_reachable" = "Si us plau verifica la teva connexió de xarxa"; +"user_id_placeholder" = "ex: @jordi:servidorhoste"; +"ssl_homeserver_url" = "URL del Servidor Hoste: %@"; +// Permissions +"camera_access_not_granted_for_call" = "Les vídeo trucades necessiten permís per accedir a la Càmera però %@ no té permís per utilitzar-la"; +"microphone_access_not_granted_for_call" = "Les trucades necessiten accedir al Micròfon però %@ no té permís per utilitzar-lo"; +"local_contacts_access_not_granted" = "El descobriment dels usuaris en els contactes locals requereix l'accés als vostres contactes, però %@ no té permís per utilitzar-lo"; +"local_contacts_access_discovery_warning_title" = "Descobriment d'usuaris"; +"local_contacts_access_discovery_warning" = "%@ vol pujar les adreces de correu i els números de telèfon dels teus Contactes per a poder descobrir usuaris"; +// Country picker +"country_picker_title" = "Escull un país"; +// Language picker +"language_picker_title" = "Escull l'idioma"; +"language_picker_default_language" = "Per defecte (%@)"; +"notice_room_invite" = "%@ ha convidat a %@"; +"notice_room_third_party_invite" = "%@ ha enviat una invitació %@ per entrar a la sala"; +"notice_room_third_party_registered_invite" = "%@ ha acceptat la invitació per a %@"; +"notice_room_join" = "%@ s'ha unit a la sala"; +"notice_room_leave" = "%@ ha marxat"; +"notice_room_reject" = "%@ ha rebutjat la invitació"; +"notice_room_kick" = "%@ ha fet fora a %@"; +"notice_room_unban" = "%@ ha readmès a %@"; +"notice_room_ban" = "%@ ha expulsat a %@"; +"notice_room_withdraw" = "%@ ha anul·lat la invitació de %@"; +"notice_room_reason" = ". Motiu: %@"; +"notice_avatar_url_changed" = "%@ ha canviat el seu avatar"; +"notice_display_name_set" = "%@ ha canviat el seu nom a %@"; +"notice_display_name_changed_from" = "%@ ha canviat el seu nom de %@ a %@"; +"notice_display_name_removed" = "%@ ha eliminat el seu nom visible"; +"notice_topic_changed" = "%@ ha canviat el tema a: %@"; +"notice_room_name_changed" = "%@ ha canviat el nom de la sala a: %@"; +"notice_placed_voice_call" = "%@ ha iniciat una trucada de veu"; +"notice_placed_video_call" = "%@ ha iniciat una vídeo conferència"; +"notice_answered_video_call" = "%@ ha contestat la trucada"; +"notice_ended_video_call" = "%@ ha finalitzat la trucada"; +"notice_conference_call_request" = "%@ ha sol·licitat una conferència VoIP"; +"notice_conference_call_started" = "Conferència VoIP iniciada"; +"notice_conference_call_finished" = "Conferència VoIP finalitzada"; +// button names +"ok" = "D'acord"; +"send" = "Envia"; +"copy_button_name" = "Copia"; +"resend" = "Reenvia"; +"redact" = "Elimina"; +"share" = "Comparteix"; +"set_power_level" = "Nivell de potència"; +"delete" = "Esborra"; +// actions +"action_logout" = "Tancar sessió"; +"create_room" = "Crear sala"; +"login" = "Iniciar sessió"; +"create_account" = "Crear compte"; +"membership_invite" = "Convidat"; +"membership_leave" = "Ha sortit"; +"membership_ban" = "Expulsat"; +"num_members_one" = "usuari %@"; +"num_members_other" = "Usuaris de %@"; +"kick" = "Fer fora"; +"ban" = "Expulsa"; +"unban" = "Readmetre"; +"message_unsaved_changes" = "Hi ha canvis no desats. Si ho deixes es perdran."; +// Login Screen +"login_error_already_logged_in" = "Ja autenticat"; +"login_error_must_start_http" = "La URL ha de començar per http[s]://"; +// contacts list screen +"invitation_message" = "M'agradaría xatejar amb tu amb matrix. Si us plau visita el lloc http://matrix.org per a més informació."; +// Settings screen +"settings_title_config" = "Configuració"; +"settings_title_notifications" = "Notificacions"; +// Notification settings screen +"notification_settings_disable_all" = "Desactiva totes les notificacions"; +"notification_settings_enable_notifications" = "Activa les notificacions"; +"notification_settings_enable_notifications_warning" = "Totes les notificacions de tots els dispositius estan actualment desactivades."; +"notification_settings_global_info" = "Els ajustos de les notificacions son desades en el teu compte i compartides amb tots els clients que les suporten (incloent les notificacions d'escriptori).\n\nLes normes son aplicades en ordre; la primera norma que coincideix defineix el resultat del missatge.\nPer tant: es notificacions per paraula són més importants que les notificacions per sales i aquestes són més importants que les notificacions per remitents.\nPer a diverses normes del mateix tipus, la primera de la llista que coincideix té prioritat."; +"notification_settings_per_word_notifications" = "Notificacions per paraula"; +"notification_settings_per_word_info" = "Les paraules coincideixen amb el cas de forma insensible i poden incloure un comodí *. Per tant:\nfoo coincideix amb la cadena foo rodejada de delimitadors de paraules (p. ex. puntuació i espai en blanc o inici/final de línia).\nfoo* coincideix amb qualsevol paraula que començi per foo.\n* foo* coincideix amb qualsevol paraula que inclogui les 3 lletres foo."; +"notification_settings_always_notify" = "Notifica sempre"; +"notification_settings_never_notify" = "Mai notifica"; +"notification_settings_word_to_match" = "paraula coincident"; +"notification_settings_highlight" = "Ressaltat"; +"notification_settings_custom_sound" = "So personaltizat"; +"notification_settings_per_room_notifications" = "Notificacions per sala"; +"notification_settings_per_sender_notifications" = "Notificacions per remitent"; +"notification_settings_sender_hint" = "@usuari:domini.com"; +"notification_settings_select_room" = "Escull una sala"; +"notification_settings_other_alerts" = "Altres avisos"; +"notification_settings_contain_my_user_name" = "Notificar-me amb un so els missatges que continguin el meu nom d'usuari"; +"notification_settings_contain_my_display_name" = "Notificar-me amb un so els missatges que continguin el meu nom visible"; +"notification_settings_just_sent_to_me" = "Notificar-me amb un so els missatges enviats per mi"; +"notification_settings_invite_to_a_new_room" = "Notificar-me quan sigui convidat a una nova sala"; +"notification_settings_people_join_leave_rooms" = "Notificar-me quan algú entri o marxi de les sales"; +"notification_settings_receive_a_call" = "Notificar-me quan rebi una trucada"; +"notification_settings_suppress_from_bots" = "Suprimir les notificacions de robots"; +"notification_settings_by_default" = "Per defecte..."; +"notification_settings_notify_all_other" = "Notifica tots els altres missatges/sales"; +// gcm section +"settings_config_home_server" = "Servidor hoste: %@"; +"settings_config_identity_server" = "Servidor d'identitat: %@"; +"settings_config_user_id" = "ID d'usuari: %@"; +// call string +"call_waiting" = "Esperant..."; +"call_connecting" = "Establint la trucada..."; +"call_ended" = "Trucada finalitzada"; +"call_ring" = "Trucant..."; +"incoming_video_call" = "Vídeo trucada entrant"; +"incoming_voice_call" = "Trucada de veu entrant"; +"call_invite_expired" = "Invitació de trucada ha caducat"; +// unrecognized SSL certificate +"ssl_trust" = "Confia"; +"ssl_logout_account" = "Tancar sessió"; +"ssl_remain_offline" = "Ignora"; +"ssl_fingerprint_hash" = "Empremta digital (%@):"; +"ssl_could_not_verify" = "No s'ha pogut verificar la identitat del servidor remot."; +"ssl_cert_not_trust" = "Això pot voler dir que algú està maliciosament interceptant el tràfic o que el teu telèfon no confia en el certificat proporcionat pel servidor remot."; +"ssl_cert_new_account_expl" = "Si l'administrador del servidor ha dit que això és correcte, assegura't que la següent empremta digital coincideix amb la que t'ha donat."; +"ssl_unexpected_existing_expl" = "El certificat ha canviat respecte al que el teu telèfon hi havia confiat. Això es MOLT INUSUAL. Es recomana que NO ACCEPTIS aquest nou certificat."; +"ssl_expected_existing_expl" = "El certificat ha canviat del prèviament confiat a un que no es confiable. El servidor pot haver renovat el certificat. Posa't en contacte amb l'administrador del servidor per obtenir l'empremta digital desitjada."; +"ssl_only_accept" = "NOMÉS accepteu el certificat si l'administrador del servidor ha publicat una empremta digital que coincideixi amb l'anterior."; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/cy.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/cy.lproj/MatrixKit.strings new file mode 100644 index 000000000..6bed7d575 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/cy.lproj/MatrixKit.strings @@ -0,0 +1,383 @@ +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "Creu cyfrif:"; +"login_server_url_placeholder" = "URL (e.e. https://matrix.org)"; +"login_home_server_title" = "URL Hafanweinydd:"; +"login_home_server_info" = "Mae'ch hafanweinydd yn cadw'ch holl sgyrsiau a gwybodaeth cyfrif"; +"login_identity_server_title" = "URL Gweinydd Adnabod:"; +"login_identity_server_info" = "Mae Matrix yn darparu gweinyddwyr adnabod i olrhain pa e-byst ac ati sy'n perthyn i IDau Matrix. Dim ond https://matrix.org sy'n bodoli ar hyn o bryd."; +"login_user_id_placeholder" = "ID Matrix (e.g. @bob:matrix.org neu bob)"; +"login_password_placeholder" = "Cyfrinair"; +"login_optional_field" = "dewisol"; +"login_display_name_placeholder" = "Enw arddangos (e.e. Jac y Jwc)"; +"login_email_info" = "Mae nodi cyfeiriad e-bost yn caniatáu i ddefnyddwyr eraill ddod o hyd i chi ar Matrix yn haws, a bydd yn rhoi ffordd i chi ailosod eich cyfrinair yn y dyfodol."; +"login_email_placeholder" = "Cyfeiriad E-bost"; +"login_prompt_email_token" = "Rhowch eich tocyn gwirio e-bost:"; +"login_error_title" = "Methwyd Mewngofnodi"; +"login_error_no_login_flow" = "Methom ni â chasglu gwybodaeth gwirio gan y Hafanweinydd hwn"; +"login_error_do_not_support_login_flows" = "Ar hyn o bryd nid ydym yn cefnogi unrhyw lif mewngofnodi, neu'rrhan ohono, a ddiffinnir gan y Hafanweinydd hwn"; +"login_error_registration_is_not_supported" = "Ni chefnogir cofrestru ar hyn o bryd"; +"login_error_forbidden" = "Enw defnyddiwr/cyfrinair annilys"; +"login_error_unknown_token" = "Ni chydnabuwyd y tocyn mynediad a nodwyd"; +"login_error_bad_json" = "JSON camffurfiedig"; +"login_error_not_json" = "Nid oedd yn cynnwys JSON dilys"; +"login_error_limit_exceeded" = "Mae gormod o geisiadau wedi'u hanfon"; +"login_error_user_in_use" = "Defnyddir yr enw defnyddiwr hwn eisoes"; +"login_error_login_email_not_yet" = "Nid yw'r ddolen e-bost wedi'i chlicio eto"; +"login_use_fallback" = "Defnyddiwch dudalen wrth gefn"; +"login_leave_fallback" = "Canslo"; +"login_invalid_param" = "Paramadr Annilys"; +"register_error_title" = "Methwyd Cofrestri"; +"login_error_forgot_password_is_not_supported" = "Ni chefnogir cyfrinair anghofedig ar hyn o bryd"; +"login_mobile_device" = "Ffôn Symudol"; +"login_tablet_device" = "Llechen"; +"login_desktop_device" = "Cyfrifiadur"; +"login_error_resource_limit_exceeded_title" = "Ty hwnt i'r Terfyn Adnoddau"; +"login_error_resource_limit_exceeded_message_default" = "Mae'r hafanweinydd hwn wedi rhagori ar un o'i derfynau adnoddau."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Mae'r hafanweinydd hwn wedi cyrraedd ei derfyn Defnyddiwr Gweithredol Misol."; +"login_error_resource_limit_exceeded_message_contact" = "\n\nCysylltwch â'ch gweinyddwr gwasanaeth i barhau i ddefnyddio'r gwasanaeth hwn."; +"login_error_resource_limit_exceeded_contact_button" = "Cysylltu â Gweinyddwr"; +// Action +"no" = "Na"; +"yes" = "Ie"; +"abort" = "Cefnu"; +"back" = "Yn ôl"; +"close" = "Cau"; +"continue" = "Parhau"; +"discard" = "Taflu"; +"dismiss" = "Wfftio"; +"retry" = "Ailgynnig"; +"sign_up" = "Cofrestri"; +"submit" = "Anfon"; +"submit_code" = "Anfon côd"; +"set_power_level" = "Gosod Lefel Pŵer"; +"set_default_power_level" = "Ail-osod Lefel Pŵer"; +"set_moderator" = "Gosod Cymredolwr"; +"set_admin" = "Gosod Gweinyddwr"; +"start_chat" = "Dechrau Sgwrs"; +"start_voice_call" = "Dechrau Galwad Llais"; +"start_video_call" = "Dechrau Galwad Fideo"; +"mention" = "Crybwyll"; +"select_account" = "Dewis cyfrif"; +"attach_media" = "Ategu Cyfryngau o'r Llyfrgell"; +"capture_media" = "Tynnu Llun/Fideo"; +"invite_user" = "Gwahodd Defnyddiwr Matrix"; +"reset_to_default" = "Ailosod i'r diofyn"; +"resend_message" = "Ail-anfon y neges"; +"select_all" = "Dewis y cyfan"; +"cancel_upload" = "Canslo Uwchlwytho"; +"cancel_download" = "Canslo Lawrlwytho"; +"show_details" = "Dangos Manylion"; +"answer_call" = "Ateb Galwad"; +"reject_call" = "Gwrthod Galwad"; +"end_call" = "Gorffen Galwad"; +"ignore" = "Anwybyddu"; +"unignore" = "Dad-anwybyddu"; +// Events formatter +"notice_avatar_changed_too" = "(newidiwyd rhithffurf hefyd)"; +"notice_room_name_removed" = "Tynnodd %@ enw'r ystafell"; +"notice_room_topic_removed" = "Fe wnaeth %@ ddileu'r pwnc"; +"notice_event_redacted" = ""; +"notice_event_redacted_by" = " gan %@"; +"notice_event_redacted_reason" = " [rheswm: %@]"; +"notice_profile_change_redacted" = "Diweddarodd %@ eu proffil %@"; +"notice_room_created" = "Creodd %@ yr ystafell"; +"notice_room_join_rule" = "Y rheol ymuno yw: %@"; +"notice_room_power_level_intro" = "Lefel pŵer aelodau'r ystafell yw:"; +"notice_room_power_level_acting_requirement" = "Y lefelau pŵer lleiaf y mae'n rhaid i ddefnyddiwr eu cael cyn gweithredu yw:"; +"notice_room_power_level_event_requirement" = "Isafswm y lefelau pŵer sy'n gysylltiedig â digwyddiadau yw:"; +"notice_room_aliases" = "Arallenwau'r ystafell yw: %@"; +"notice_room_related_groups" = "Y grwpiau sy'n gysylltiedig â'r ystafell hon yw: %@"; +"notice_encrypted_message" = "Neges amgryptiedig"; +"notice_encryption_enabled" = "Trodd %@ ar amgryptio o'r dechrau i'r diwedd (algorithm %@)"; +"notice_image_attachment" = "atodiad llun"; +"notice_audio_attachment" = "atodiad sain"; +"notice_video_attachment" = "atodiad fideo"; +"notice_location_attachment" = "atodiad lleoliad"; +"notice_file_attachment" = "atodiad ffeil"; +"notice_invalid_attachment" = "atodiad annilys"; +"notice_unsupported_attachment" = "Atodiad heb gefnogaeth: %@"; +"notice_feedback" = "Digwyddiad adborth (id: %@): %@"; +"notice_redaction" = "Golygodd %@ ddigwyddiad (id: %@)"; +"notice_error_unsupported_event" = "Digwyddiad heb gefnogaeth"; +"notice_error_unexpected_event" = "Digwyddiad annisgwyl"; +"notice_error_unknown_event_type" = "Math digwyddiad anhysbys"; +"notice_room_history_visible_to_anyone" = "Gwnaeth %@ hanes ystafell y dyfodol yn weladwy i unrhyw un."; +"notice_room_history_visible_to_members" = "Gwnaeth %@ hanes ystafell y dyfodol yn weladwy i holl aelodau'r ystafell."; +"notice_room_history_visible_to_members_from_invited_point" = "Gwnaeth %@ hanes ystafell y dyfodol yn weladwy i holl aelodau'r ystafell, o'r pwynt y cawsant eu gwahodd."; +"notice_room_history_visible_to_members_from_joined_point" = "Gwnaeth %@ hanes ystafell y dyfodol yn weladwy i holl aelodau'r ystafell, o'r pwynt yr ymunon nhw ag ef."; +"notice_crypto_unable_to_decrypt" = "** Methu dadgryptio: %@ **"; +"notice_crypto_error_unknown_inbound_session_id" = "Nid yw sesiwn yr anfonwr wedi anfon yr allweddi ar gyfer y neges hon atom."; +"notice_sticker" = "sticer"; +"notice_in_reply_to" = "Mewn ateb i"; +// room display name +"room_displayname_empty_room" = "Ystafell gwag"; +"room_displayname_two_members" = "%@ a %@"; +"room_displayname_more_than_two_members" = "%@ a %@ eraill"; +// Settings +"settings" = "Gosodiadau"; +"settings_enable_inapp_notifications" = "Galluogi hysbysiadau Mewn-App"; +"settings_enable_push_notifications" = "Galluogi gwth-hysbysiadau"; +"settings_enter_validation_token_for" = "Rhowch docyn dilysu ar gyfer %@:"; +"notification_settings_room_rule_title" = "Ystafell: '%@'"; +// Devices +"device_details_title" = "Gwybodaeth sesiwn\n"; +"device_details_name" = "Enw Cyhoeddus\n"; +"device_details_identifier" = "ID\n"; +"device_details_last_seen" = "Gwelwyd ddiweddaf\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_rename_prompt_title" = "Enw Sesiwn"; +"device_details_rename_prompt_message" = "Mae enw cyhoeddus sessiwn yn weladwy i'r bobl rydych chi'n cyfathrebu â nhw"; +"device_details_delete_prompt_title" = "Dilysu"; +"device_details_delete_prompt_message" = "Mae angen dilysu ychwanegol ar gyfer y gweithgaredd hon.\nI barhau, nodwch eich cyfrinair."; +// Encryption information +"room_event_encryption_info_title" = "Gwybodaeth amgryptio dechrau i'r diwedd\n\n"; +"room_event_encryption_info_event" = "Gwybodaeth digwyddiad\n"; +"room_event_encryption_info_event_user_id" = "ID Defnyddiwr\n"; +"room_event_encryption_info_event_identity_key" = "Allwedd adnabod Curve25519\n"; +"room_event_encryption_info_event_fingerprint_key" = "Allwedd llofnod Ed25519 â hawliedig\n"; +"room_event_encryption_info_event_algorithm" = "Algorithm\n"; +"room_event_encryption_info_event_session_id" = "ID Sesiwn\n"; +"room_event_encryption_info_event_decryption_error" = "Gwall Dadgryptio\n"; +"room_event_encryption_info_event_unencrypted" = "digryptiedig"; +"room_event_encryption_info_event_none" = "dim"; +"room_event_encryption_info_device" = "\nGwybodaeth am sesiwn yr anfonwr\n"; +"room_event_encryption_info_device_unknown" = "sesiwn anhysbys\n"; +"room_event_encryption_info_device_name" = "Enw Cyhoeddus\n"; +"room_event_encryption_info_device_id" = "ID\n"; +"room_event_encryption_info_device_verification" = "Gwirio\n"; +"room_event_encryption_info_device_fingerprint" = "Llofnod Ed25519\n"; +"room_event_encryption_info_device_verified" = "Wedi Gwirio"; +"room_event_encryption_info_device_not_verified" = "HEB ei wirio"; +"room_event_encryption_info_device_blocked" = "Gwaharddiedig"; +"room_event_encryption_info_verify" = "Gwirio…"; +"room_event_encryption_info_unverify" = "Dad-wirio"; +"room_event_encryption_info_block" = "Gwahardd"; +"room_event_encryption_info_unblock" = "Dad-wahardd"; +"room_event_encryption_verify_title" = "Gwirio sesiwn\n\n"; +"room_event_encryption_verify_message" = "I wirio y gellir ymddiried yn y sesiwn hon, cysylltwch â'i pherchennog gan ddefnyddio rhyw fodd arall (e.e. yn bersonol neu alwad ffôn) a gofynnwch iddynt a yw'r allwedd a welant yn eu Gosodiadau Defnyddiwr ar gyfer y sesiwn hon yn cyfateb i'r allwedd isod:\n\nEnw'r sesiwn: %@\nID y sesiwn: %@\nAllwedd y sesiwn: %@\n\nOs yw'n cyd-fynd, pwyswch y botwm gwirio isod. Os nad yw'n gwneud hynny, yna mae rhywun arall yn rhyng-gipio'r sesiwn hon ac mae'n debyg eich y dylech wasgu'r botwm gwahardd yn lle.\n\nYn y dyfodol bydd y broses gwirio hon yn fwy soffistigedig."; +"room_event_encryption_verify_ok" = "Gwirio"; +// Account +"account_save_changes" = "Cadw newidiadau"; +"account_link_email" = "Dolen E-bost"; +"account_linked_emails" = "E-byst cysylltiedig"; +"account_email_validation_title" = "Aros am wirio"; +"account_email_validation_message" = "Gwiriwch eich e-bost a chliciwch ar y ddolen sydd ynddo. Ar ôl gwneud hyn, cliciwch parhau."; +"account_email_validation_error" = "Methu gwirio cyfeiriad e-bost. Gwiriwch eich e-bost a chliciwch ar y ddolen sydd ynddo. Ar ôl gwneud hyn, cliciwch parhau"; +"account_msisdn_validation_title" = "Aros am wirio"; +"account_msisdn_validation_message" = "Rydyn ni wedi anfon neges testyn gyda chod actifadu. Rhowch y cod hwn isod."; +"account_msisdn_validation_error" = "Methu gwirio rhif ffôn."; +"account_error_display_name_change_failed" = "Methwyd newid enw arddangos"; +"account_error_picture_change_failed" = "Methwyd newid llun"; +"account_error_matrix_session_is_not_opened" = "Nid yw 'r sesiwn Matrix yn agored"; +"account_error_email_wrong_title" = "Cyfeiriad E-bost Annilys"; +"account_error_email_wrong_description" = "Nid yw hwn yn edrych fel cyfeiriad e-bost dilys"; +"account_error_msisdn_wrong_title" = "Rhif Ffôn Annilys"; +"account_error_msisdn_wrong_description" = "Nid yw hwn yn edrych fel rhif ffôn dilys"; +"account_error_push_not_allowed" = "Ni chaniateir hysbysiadau"; +// Room creation +"room_creation_name_title" = "Enw ystafell:"; +"room_creation_name_placeholder" = "(e.e. criwCinio)"; +"room_creation_alias_title" = "Arallenw ystafell:"; +"room_creation_alias_placeholder" = "(e.e. #foo:example.org)"; +"room_creation_alias_placeholder_with_homeserver" = "(e.e. #foo%@)"; +"room_creation_participants_title" = "Cyfranogwyr:"; +"room_creation_participants_placeholder" = "(e.e. @daf:hafanweinydd1; @gwil:hafanweinydd2...)"; +// Room +"room_please_select" = "Dewisiwch ystafell"; +"room_error_join_failed_title" = "Methwyd ymuno â'r ystafell"; +"room_error_join_failed_empty_room" = "Ar hyn o bryd nid yw'n bosibl ail-ymuno ag ystafell wag."; +"room_error_name_edition_not_authorized" = "Nid oes gennych awdurdod i olygu enw'r ystafell hon"; +"room_error_topic_edition_not_authorized" = "Nid oes gennych awdurdod i olygu pwnc yr ystafell hon"; +"room_error_cannot_load_timeline" = "Methwyd llwytho llinell amser"; +"room_error_timeline_event_not_found_title" = "Methwyd llwytho safle llinell amser"; +"room_error_timeline_event_not_found" = "Roedd y rhaglen yn ceisio llwytho pwynt penodol yn llinell amser yr ystafell hon ond nid oedd yn gallu dod o hyd iddo"; +"room_left" = "Gadawsoch yr ystafell"; +"room_no_power_to_create_conference_call" = "Mae angen caniatâd arnoch i wahodd i ddechrau cynhadledd yn yr ystafell hon"; +"room_no_conference_call_in_encrypted_rooms" = "Ni chefnogir galwadau cynhadledd mewn ystafelloedd wedi'u hamgryptio"; +// Reply to message +"message_reply_to_sender_sent_an_image" = "anfonwyd llun."; +"message_reply_to_sender_sent_a_video" = "anfonwyd fideo."; +"message_reply_to_sender_sent_an_audio_file" = "anfonwyd ffeil sain."; +"message_reply_to_sender_sent_a_file" = "anfonwyd ffeil."; +"message_reply_to_message_to_reply_to_prefix" = "Mewn ateb i"; +// Room members +"room_member_ignore_prompt" = "Ydych chi'n siŵr eich bod chi eisiau cuddio pob neges oddi wrth y defnyddiwr hwn?"; +"room_member_power_level_prompt" = "Ni fyddwch yn gallu dadwneud y newid hwn gan eich bod yn hyrwyddo'r defnyddiwr i gael yr un lefel pŵer â chi'ch hun.\nYdych chi'n siwr?"; +// Attachment +"attachment_size_prompt" = "Ydych chi am anfon fel:"; +"attachment_original" = "Maint Gwirioneddol: %@"; +"attachment_small" = "Bach: %@"; +"attachment_medium" = "Canolig: %@"; +"attachment_large" = "Mawr: %@"; +"attachment_cancel_download" = "Canslo y lawrlwythiad?"; +"attachment_cancel_upload" = "Canslo yr uwchlwythiad?"; +"attachment_multiselection_size_prompt" = "Hoffech chi anfon llun fel:"; +"attachment_multiselection_original" = "Maint Gwirioneddol"; +"attachment_e2e_keys_file_prompt" = "Mae'r ffeil hon yn cynnwys allweddi amgryptio a allfudwyd o gleient Matrix.\nYdych chi eisiau gweld cynnwys y ffeil neu fewnfudo'r allweddi sydd ynddo?"; +"attachment_e2e_keys_import" = "Mewnfudo..."; +// Contacts +"contact_mx_users" = "Defnyddwyr Matrix"; +"contact_local_contacts" = "Cysylltiadau Lleol"; +// Groups +"group_invite_section" = "Gwahoddiadau"; +"group_section" = "Grwpiau"; +// Search +"search_no_results" = "Dum Canluniadau"; +"search_searching" = "Chwilio ar y gweill..."; +// Time +"format_time_s" = "e"; +"format_time_m" = "m"; +"format_time_h" = "a"; +"format_time_d" = "d"; +// E2E import +"e2e_import_room_keys" = "Mewnfudo allweddi ystafell"; +"e2e_import_prompt" = "Mae'r broses hon yn caniatáu ichi fewnfudo allweddi amgryptio yr oeddech wedi'u hallfudo o'r blaen o gleient Matrix arall. Yna byddwch yn gallu dadgryptio unrhyw negeseuon y gallai'r cleient arall eu dadgryptio.\nMae'r ffeil allfudo wedi'i gwarchod gyda chyfrinair. Dylech nodi'r cyfrinair yma, i ddadgryptio'r ffeil."; +"e2e_import" = "Mewnfudo"; +"e2e_passphrase_enter" = "Rhowch cyfrinair"; +// E2E export +"e2e_export_room_keys" = "Allfudo allweddi ystafell"; +"e2e_export_prompt" = "Mae'r broses hon yn caniatáu ichi allfudo i ffeil leol yr allweddi ar gyfer negeseuon rydych wedi'u derbyn mewn ystafelloedd amgryptiedig. Yna byddwch chi'n gallu mewnfudo'r ffeil i gleient Matrix arall yn y dyfodol, fel y bydd y cleient hwnnw hefyd yn gallu dadgryptio'r negeseuon hyn.\nBydd y ffeil a allfudir yn caniatáu i unrhyw un sy'n gallu ei darllen ddadgryptio unrhyw negeseuon amgryptiedig y gallwch eu gweld, felly dylech fod yn ofalus i'w cadw'n ddiogel."; +"e2e_export" = "Allfudo"; +"e2e_passphrase_confirm" = "Cadarnhau cyfrinair"; +"e2e_passphrase_empty" = "Ni chaniateir cyfrinair gwag"; +"e2e_passphrase_not_match" = "Rhaid i'r cyfrineiriau gyfateb"; +"e2e_passphrase_create" = "Creu cyfrinair"; +// Others +"user_id_title" = "ID Defnyddiwr:"; +"offline" = "all-lein"; +"unsent" = "Heb eu danfon"; +"error" = "Gwall"; +"error_common_message" = "Digwyddodd gwall. Rhowch gynning eto nes ymlaen."; +"not_supported_yet" = "Heb ei gefnogi eto"; +"default" = "diofyn"; +"private" = "Preifat"; +"public" = "Cyhoeddus"; +"power_level" = "Lefel Pŵer"; +"network_error_not_reachable" = "Gwiriwch eich cysylltedd rhwydwaith"; +"user_id_placeholder" = "eng: @gwil:hafanweinydd"; +"ssl_homeserver_url" = "URL Hafanweinydd: %@"; +// Permissions +"camera_access_not_granted_for_call" = "Mae galwadau fideo angen mynediad i'r Camera ond nid oes gan %@ ganiatâd i'w ddefnyddio"; +"microphone_access_not_granted_for_call" = "Mae galwadau fideo angen mynediad i'r Meicroffon ond nid oes gan %@ ganiatâd i'w ddefnyddio"; +"local_contacts_access_not_granted" = "Mae darganfyddiad defnyddwyr o gysylltiadau lleol angen mynediad i'ch cysylltiadau ond nid oes gan %@ ganiatâd i'w ddefnyddio"; +"local_contacts_access_discovery_warning_title" = "Darganfod defnyddwyr"; +"local_contacts_access_discovery_warning" = "I ddarganfod cysylltiadau sydd eisoes yn defnyddio Matrix, gall %@ anfon cyfeiriadau e-bost a rhifau ffôn yn eich llyfr cyfeiriadau at y gweinydd adnabod Matrix o'ch dewis. Pan gânt eu cefnogi, mae data personol yn cael ei amgodio cyn ei anfon - gwiriwch bolisi preifatrwydd eich gweinydd adnabod i gael mwy o fanylion."; +// Country picker +"country_picker_title" = "Dewiswch wlad"; +// Language picker +"language_picker_title" = "Dewiswch iaith"; +"language_picker_default_language" = "Diofyn (%@)"; +"notice_room_invite" = "Mae %@ wedi gwahodd %@"; +"notice_room_third_party_invite" = "Anfonodd %@ wahoddiad i %@ i ymuno â'r ystafell"; +"notice_room_third_party_registered_invite" = "Derbyniodd %@ y gwahoddiad am %@"; +"notice_room_third_party_revoked_invite" = "Tynnodd %@ y gwahoddiad i %@ ymuno â'r ystafell"; +"notice_room_join" = "Ymunodd %@"; +"notice_room_leave" = "Gadawodd %@"; +"notice_room_reject" = "Gwrthododd %@ y gwahoddiad"; +"notice_room_kick" = "Ciciodd %@ %@"; +"notice_room_unban" = "Dad-waharddodd %@ %@"; +"notice_room_ban" = "Gwaharddod %@ %@"; +"notice_room_withdraw" = "Tynnodd %@ wahoddiad %@ yn ôl"; +"notice_room_reason" = ". Rheswm: %@"; +"notice_avatar_url_changed" = "Newidiodd %@ eu rhithffurf"; +"notice_display_name_set" = "Gosododd %@ eu henw arddangos i %@"; +"notice_display_name_changed_from" = "Newidiodd %@ eu henw arddangos o %@ i %@"; +"notice_display_name_removed" = "Tynnodd %@ eu henw arddangos"; +"notice_topic_changed" = "Newidiodd %@ y pwnc i: %@"; +"notice_room_name_changed" = "Newidiodd %@ enw'r ystafell i: %@"; +"notice_placed_voice_call" = "Gosododd %@ alwad llais"; +"notice_placed_video_call" = "Gosododd %@ alwad fideo"; +"notice_answered_video_call" = "Atebodd %@ y galwad"; +"notice_ended_video_call" = "Gorffenodd %@ y galwad"; +"notice_conference_call_request" = "Gofynnodd %@ am gynhadledd VoIP"; +"notice_conference_call_started" = "Dechreuwyd cynhadledd VoIP"; +"notice_conference_call_finished" = "Gorffenwyd cynhadledd VoIP"; +// button names +"ok" = "Iawn"; +"cancel" = "Canslo"; +"save" = "Cadw"; +"leave" = "Gadael"; +"send" = "Anfon"; +"copy_button_name" = "Copi"; +"resend" = "Ail-anfon"; +"redact" = "Tynnu"; +"share" = "Rhannu"; +"delete" = "Dileu"; +"view" = "Gweld"; +// actions +"action_logout" = "Allgofnodi"; +"create_room" = "Creu Ystafell"; +"login" = "Mewngofnodi"; +"create_account" = "Creu Cyfrif"; +"membership_invite" = "Gwahoddwyd"; +"membership_leave" = "Gadawodd"; +"membership_ban" = "Gwaharddedig"; +"num_members_one" = "%@ defnyddiwr"; +"num_members_other" = "%@ defnyddiwr"; +"invite" = "Gwahodd"; +"kick" = "Cic"; +"ban" = "Gwahardd"; +"unban" = "Dad-wahardd"; +"message_unsaved_changes" = "Mae yna newidiadau heb eu cadw. Bydd gadael yn golygu eu colli."; +// Login Screen +"login_error_already_logged_in" = "Wedi mewngofnodi eisoes"; +"login_error_must_start_http" = "Rhaid i URL ddechrau â http[s]://"; +// room details dialog screen +"room_details_title" = "Manylion Ystafell"; +// contacts list screen +"invitation_message" = "Hoffwn sgwrsio â chi gyda Matrix. Os gwelwch yn dda, ewch i'r wefan https://matrix.org i gael mwy o wybodaeth."; +// Settings screen +"settings_title_config" = "Gosodiadau"; +"settings_title_notifications" = "Hysbysebiadau"; +// Notification settings screen +"notification_settings_disable_all" = "Analluogi pob hysbysiad"; +"notification_settings_enable_notifications" = "Galluogi hysbysiadau"; +"notification_settings_enable_notifications_warning" = "Ar hyn o bryd mae pob hysbysiad wedi eu hanalluogi ar gyfer pob dyfais."; +"notification_settings_global_info" = "Mae gosodiadau hysbysiadau yn cael eu cadw i'ch cyfrif defnyddiwr ac yn cael eu rhannu rhwng yr holl gleientiaid sy'n eu cefnogi (gan gynnwys hysbysiadau cyfrifiadur).\n\nCymhwysir rheolau mewn trefn; mae'r rheol gyntaf sy'n cyfateb yn diffinio canlyniad y neges.\nFelly: Mae hysbysiadau fesul gair yn bwysicach na hysbysiadau fesul ystafell sy'n bwysicach na hysbysiadau fesul anfonwr.\nAr gyfer rheolau lluosog o'r un math, mae'r un gyntaf yn y rhestr sy'n cyfateb yn cael blaenoriaeth."; +"notification_settings_per_word_notifications" = "Hysbysiadau fesul gair"; +"notification_settings_per_word_info" = "Mae geiriau'n cyfateb priflythrennau a rhai bach, a gallant gynnwys * cerdyn gwyllt. Felly:\nmae foo yn cyd-fynd â'r testyn foo wedi'i amgylchynu gan amffinyddion geiriau (e.e. atalnodi a gofod gwyn neu ddechrau / diwedd llinell).\nmae foo* yn cyfateb i unrhyw air o'r fath sy'n dechrau foo.\nmae *foo* yn cyfateb i unrhyw air o'r fath sy'n cynnwys y 3 llythyren foo."; +"notification_settings_always_notify" = "Rhowch wybod bob amser"; +"notification_settings_never_notify" = "Peidiwch byth â hysbysu"; +"notification_settings_word_to_match" = "gair yw gyfateb"; +"notification_settings_highlight" = "Amlygiad"; +"notification_settings_custom_sound" = "Sain addasol"; +"notification_settings_per_room_notifications" = "Hysbysiadau fesul ystafell"; +"notification_settings_per_sender_notifications" = "Hysbysiadau fesul anfonwr"; +"notification_settings_sender_hint" = "@defnyddiwr:parth.com"; +"notification_settings_select_room" = "Dewisiwch ystafell"; +"notification_settings_other_alerts" = "Rhybuddion Eraill"; +"notification_settings_contain_my_user_name" = "Rhowch wybod i mi gyda sain am negeseuon sy'n cynnwys fy enw defnyddiwr"; +"notification_settings_contain_my_display_name" = "Rhowch wybod i mi gyda sain am negeseuon sy'n cynnwys fy enw arddangos"; +"notification_settings_just_sent_to_me" = "Rhowch wybod i mi gyda sain am negeseuon â anfonwyd ataf yn unig"; +"notification_settings_invite_to_a_new_room" = "Rhowch wybod i mi pan gaf wahoddiad i ystafell newydd"; +"notification_settings_people_join_leave_rooms" = "Rhowch wybod i mi pan fydd pobl yn ymuno neu'n gadael ystafelloedd"; +"notification_settings_receive_a_call" = "Rhowch wybod i mi pan fyddaf yn derbyn galwad"; +"notification_settings_suppress_from_bots" = "Atal hysbysiadau o botiau"; +"notification_settings_by_default" = "Yn ddiofyn..."; +"notification_settings_notify_all_other" = "Hysbysu am yr holl negeseuon / ystafelloedd eraill"; +// gcm section +"settings_config_home_server" = "Hafanweinydd: %@"; +"settings_config_identity_server" = "Gweinydd adnabod: %@"; +"settings_config_user_id" = "ID Defnyddiwr: %@"; +// call string +"call_waiting" = "Aros..."; +"call_connecting" = "Cysylltu galwad..."; +"call_ended" = "Gorffenwyd y galwad"; +"call_ring" = "Yn galw..."; +"incoming_video_call" = "Galwad Fideo sy'n dod i mewn"; +"incoming_voice_call" = "Galwad Llais sy'n dod i mewn"; +"call_invite_expired" = "Gwahoddiad Galwad wedi dod i ben"; +// unrecognized SSL certificate +"ssl_trust" = "Ymddiried"; +"ssl_logout_account" = "Allgofnodi"; +"ssl_remain_offline" = "Anwybyddu"; +"ssl_fingerprint_hash" = "Llofnod (%@):"; +"ssl_could_not_verify" = "Methwyd gwirio gweinyddwr adnabod pell."; +"ssl_cert_not_trust" = "Gallai hyn olygu bod rhywun yn rhyng-gipio eich traffig yn faleisus, neu nad yw'ch ffôn yn ymddiried yn y dystysgrif a ddarperir gan y gweinydd pell."; +"ssl_cert_new_account_expl" = "Os yw gweinyddwr y gweinydd wedi dweud bod disgwyl hyn, sicrhewch fod yr llofnod isod yn cyfateb i'r llofnod a ddarperir ganddynt."; +"ssl_unexpected_existing_expl" = "Mae'r dystysgrif wedi newid o un yr oedd eich ffôn yn ymddiried ynddo. Mae hyn yn ANNISGWYL IAWN. Argymhellir i chi BEIDIO Â DERBYN y dystysgrif newydd hon."; +"ssl_expected_existing_expl" = "Mae'r dystysgrif wedi newid o un yr ymddiriedwyd ynddo o'r blaen i un nad oes ymddiried ynddo. Efallai bod y gweinydd wedi adnewyddu ei dystysgrif. Cysylltwch â gweinyddwr y gweinydd i gael y llofnod disgwyliedig."; +"ssl_only_accept" = "Peidiwch a derbyn y dystysgrif ONIBAI bod gweinyddwr y gweinydd wedi cyhoeddi llofnod sy'n cyfateb i'r un uchod."; +"notice_encryption_enabled_ok" = "Trodd %@ amgryptio o'r dechrau i'r diwedd ymlaen."; +"notice_encryption_enabled_unknown_algorithm" = "Trodd %@ amgryptio o'r dechrau i'r diwedd ymlaen (algorithm anghydnabyddedig %2$@)."; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/da.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/da.lproj/MatrixKit.strings new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/da.lproj/MatrixKit.strings @@ -0,0 +1 @@ + diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/de.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/de.lproj/MatrixKit.strings new file mode 100644 index 000000000..fd15a50c4 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/de.lproj/MatrixKit.strings @@ -0,0 +1,499 @@ +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "Konto erstellen:"; +"login_server_url_placeholder" = "URL (z.B. https://matrix.org)"; +"login_home_server_title" = "Heimserver-URL:"; +"login_home_server_info" = "Dein Heimserver speichert alle deine Gespräche und Benutzerkontodaten"; +"login_identity_server_title" = "Identitätsserver-URL:"; +"login_identity_server_info" = "Matrix stellt Identitätsserver bereit, um feststellen zu können, welche E-Mail-Adressen, etc. zu welchen Matrix-IDs gehören. Momentan existiert nur https://vector.im."; +"login_user_id_placeholder" = "Matrix-ID (z.B. @bob:matrix.org oder bob)"; +"login_password_placeholder" = "Passwort"; +"login_optional_field" = "optional"; +"login_display_name_placeholder" = "Anzeigename (z.B. Peter Pan)"; +"login_email_placeholder" = "E-Mail-Adresse"; +"login_error_title" = "Anmeldung fehlgeschlagen"; +"login_email_info" = "Die Eingabe einer E-Mail-Adresse erleichtert es anderen Benutzern, dich auf Matrix zu finden. Außerdem kannst du mit der hinterlegten E-Mail-Adresse dein Passwort zurücksetzen."; +"login_prompt_email_token" = "Gib das E-Mail-Validierungstoken ein:"; +"login_error_no_login_flow" = "Die Authentifizierungsinformation von diesem Heimserver konnte nicht abgerufen werden"; +"login_error_do_not_support_login_flows" = "Momentan werden einige oder alle der von diesem Heimserver definierten Authentifizierungspfade nicht unterstützt"; +"login_error_registration_is_not_supported" = "Registrierung wird momentan nicht unterstützt"; +"login_error_forbidden" = "Benutzername oder Passwort ungültig"; +"login_error_unknown_token" = "Das angegebene Zugriffstoken wurde nicht erkannt"; +"login_error_bad_json" = "Deformiertes JSON"; +"login_error_not_json" = "Enthielt kein valides JSON"; +"login_error_limit_exceeded" = "Zu viele Anfragen wurden gesendet"; +"login_error_user_in_use" = "Dieser Benutzername wird bereits verwendet"; +"login_error_login_email_not_yet" = "Der Email-Link wurde noch nicht angeklickt"; +"login_use_fallback" = "Benutze die Ersatzseite"; +"login_leave_fallback" = "Abbrechen"; +"login_invalid_param" = "Ungültiger Parameter"; +"register_error_title" = "Registrierung fehlgeschlagen"; +"login_error_forgot_password_is_not_supported" = "\"Passwort vergessen\" wird momentan nicht unterstützt"; +"login_mobile_device" = "Mobilgerät"; +"login_tablet_device" = "Tablet"; +"login_desktop_device" = "Desktop"; +"login_error_resource_limit_exceeded_title" = "Ressourcengrenzwert überschritten"; +"login_error_resource_limit_exceeded_message_default" = "Dieser Heimserver hat eine seiner Ressourcengrenzwerte überschritten."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Dieser Heimserver hat sein Grenzwert an monatlich aktiven Nutzern erreicht."; +"login_error_resource_limit_exceeded_message_contact" = "\n\nBitte kontaktiere deinen Dienstadministrator um mit der Nutzung dieses Dienstes fortzufahren."; +"login_error_resource_limit_exceeded_contact_button" = "Kontaktiere Administrator"; +// Action +"no" = "Nein"; +"yes" = "Ja"; +"abort" = "Abbrechen"; +"back" = "Zurück"; +"close" = "Schließen"; +"continue" = "Fortsetzen"; +"discard" = "Verwerfen"; +"dismiss" = "Ablehnen"; +"retry" = "Wiederholen"; +"sign_up" = "Registrieren"; +"submit" = "Absenden"; +"submit_code" = "Code übermitteln"; +"set_power_level" = "Berechtigungsstufe setzen"; +"set_default_power_level" = "Berechtigungsstufe zurücksetzen"; +"set_moderator" = "Moderator setzen"; +"set_admin" = "Administrator setzen"; +"start_chat" = "Chat starten"; +"start_voice_call" = "Sprachanruf starten"; +"start_video_call" = "Videoanruf starten"; +"mention" = "Erwähnung"; +"select_account" = "Wähle ein Konto"; +"attach_media" = "Medien aus der Bibliothek anhängen"; +"capture_media" = "Foto/Video aufnehmen"; +"invite_user" = "Matrixbenutzer einladen"; +"reset_to_default" = "Auf Standardeinstellungen zurücksetzen"; +"resend_message" = "Nachricht erneut senden"; +"select_all" = "Alles auswählen"; +"cancel_upload" = "Hochladen abbrechen"; +"cancel_download" = "Herunterladen abbrechen"; +"show_details" = "Zeige Details"; +"answer_call" = "Anruf annehmen"; +"reject_call" = "Anruf abweisen"; +"end_call" = "Anruf beenden"; +"ignore" = "Ignorieren"; +"unignore" = "Ignorieren aufheben"; +// Events formatter +"notice_avatar_changed_too" = "(Avatar wurde auch geändert)"; +"notice_room_name_removed" = "%@ hat den Raumnamen gelöscht"; +"notice_room_name_removed_for_dm" = "%@ hat den Namen gelöscht"; +"notice_room_topic_removed" = "%@ hat das Raumthema gelöscht"; +"notice_event_redacted" = ""; +"notice_event_redacted_by" = " durch %@"; +"notice_event_redacted_reason" = " [Grund: %@]"; +"notice_profile_change_redacted" = "%@ aktualisierte sein Profil %@"; +"notice_room_created" = "%@ hat den Raum erstellt und konfiguriert."; +"notice_room_created_for_dm" = "%@ ist beigetreten."; +// Old +"notice_room_join_rule" = "Die Beitrittsregel ist: %@"; +// New +"notice_room_join_rule_invite" = "%@ hat den Raum auf \"nur-einladen\" gestellt."; +"notice_room_join_rule_invite_for_dm" = "%@ hat auf Einladungen beschränkt."; +"notice_room_join_rule_invite_by_you" = "Du hast den Raumbeitritt auf Einladungen beschränkt."; +"notice_room_join_rule_invite_by_you_for_dm" = "Du hast dies auf Einladungen beschränkt."; +"notice_room_join_rule_public" = "%@ hat den Raum öffentlich gemacht."; +"notice_room_join_rule_public_for_dm" = "%@ hat es öffentlich gemacht."; +"notice_room_join_rule_public_by_you" = "Du hast den Raum öffentlich gemacht."; +"notice_room_join_rule_public_by_you_for_dm" = "Du hast es öffentlich gemacht."; +"notice_room_power_level_intro" = "Die Berechtigungsstufe der Teilnehmer ist:"; +"notice_room_power_level_intro_for_dm" = "Die Berechtigungsstufe der Teilnehmer ist:"; +"notice_room_power_level_acting_requirement" = "Die minimale Berechtigungsstufe, die ein Benutzer zum Handeln benötigt:"; +"notice_room_power_level_event_requirement" = "Die minimalen Berechtigungsstufen bezogen auf Ereignisse:"; +"notice_room_aliases" = "Die Raumnamenaliase sind: %@"; +"notice_room_aliases_for_dm" = "Die Namenaliase sind: %@"; +"notice_room_related_groups" = "Die Gruppen, die mit diesem Raum verknüpft sind: %@"; +"notice_encrypted_message" = "Verschlüsselte Nachricht"; +"notice_encryption_enabled_ok" = "%@ hat die Ende-zu-Ende-Verschlüsselung aktiviert."; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ hat die Ende-zu-Ende-Verschlüsselung aktiviert (unbekannter Algorithmus %2$@)."; +"notice_image_attachment" = "angehängtes Bild"; +"notice_audio_attachment" = "Audioanhang"; +"notice_video_attachment" = "Videoanhang"; +"notice_location_attachment" = "Standortanhang"; +"notice_file_attachment" = "Dateianhang"; +"notice_invalid_attachment" = "Ungültige Anhang"; +"notice_unsupported_attachment" = "Nicht unterstützter Anhang: %@"; +"notice_feedback" = "Rückmeldeereignis (id: %@): %@"; +"notice_redaction" = "%@ hat Ereignis geschwärzt (id: %@)"; +"notice_error_unsupported_event" = "Nicht unterstütztes Ereignis"; +"notice_error_unexpected_event" = "Unerwartetes Ereignis"; +"notice_error_unknown_event_type" = "Unbekannter Ereignistyp"; +"notice_room_history_visible_to_anyone" = "%@ hat den zukünftigen Raumverlauf für alle sichtbar gemacht."; +"notice_room_history_visible_to_members" = "%@ hat den zukünftigen Raumverlauf für alle Raumteilnehmer sichtbar gemacht."; +"notice_room_history_visible_to_members_for_dm" = "%@ hat die folgenden Nachrichten für alle Teilnehmer des Raumes sichtbar gemacht."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ hat den zukünftigen Raumverlauf für alle Raumteilnehmer ab deren Einladung sichtbar gemacht."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ hat den zukünftigen Verlauf für alle Raumteilnehmer ab deren Einladung sichtbar gemacht."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ hat den zukünftigen Raumverlauf für alle Raumteilnehmer ab deren Einladung sichtbar gemacht."; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ hat den zukünftigen Verlauf für alle Raumteilnehmer ab deren Einladung sichtbar gemacht."; +"notice_crypto_unable_to_decrypt" = "** Entschlüsselung nicht möglich: %@ **"; +"notice_crypto_error_unknown_inbound_session_id" = "Die Sitzung des Absenders hat uns keine Schlüssel für diese Nachricht gesendet."; +"notice_sticker" = "Aufkleber"; +"notice_in_reply_to" = "Als Antwort auf"; +// room display name +"room_displayname_empty_room" = "Leerer Raum"; +"room_displayname_two_members" = "%@ und %@"; +"room_displayname_more_than_two_members" = "%@ und %u andere"; +// Settings +"settings" = "Einstellungen"; +"settings_enable_inapp_notifications" = "Benachrichtigungen innerhalb der App aktivieren"; +"settings_enable_push_notifications" = "Puschbenachrichtigungen aktivieren"; +"settings_enter_validation_token_for" = "Eingabe Validierungstoken für %@:"; +"notification_settings_room_rule_title" = "Raum: '%@'"; +// Devices +"device_details_title" = "Sitzungsinformation\n"; +"device_details_name" = "Öffentlicher Name\n"; +"device_details_identifier" = "ID\n"; +"device_details_last_seen" = "Zuletzt gesehen:\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_rename_prompt_title" = "Sitzungsname"; +"device_details_rename_prompt_message" = "Der öffentliche Name einer Sitzung ist für Personen sichtbar, mit denen Sie kommunizieren"; +"device_details_delete_prompt_title" = "Authentifizierung"; +"device_details_delete_prompt_message" = "Diese Aktion erfordert zusätzliche Authentifizierung.\nBitte gib dein Passwort ein."; +// Encryption information +"room_event_encryption_info_title" = "Ende-zu-Ende Verschlüsselungsinformation\n\n"; +"room_event_encryption_info_event" = "Ereignis Information\n"; +"room_event_encryption_info_event_user_id" = "Benutzer-ID\n"; +"room_event_encryption_info_event_identity_key" = "Curve25519 Identitätsschlüssel\n"; +"room_event_encryption_info_event_fingerprint_key" = "Verlangter Ed25519-Fingerabdruck\n"; +"room_event_encryption_info_event_algorithm" = "Algorithmus\n"; +"room_event_encryption_info_event_session_id" = "Sitzungs-ID\n"; +"room_event_encryption_info_event_decryption_error" = "Entschlüsselungsfehler\n"; +"room_event_encryption_info_event_unencrypted" = "nicht verschlüsselt"; +"room_event_encryption_info_event_none" = "keine"; +"room_event_encryption_info_device" = "\nAbsendersitzungsinformation\n"; +"room_event_encryption_info_device_unknown" = "Unbekannte Sitzung\n"; +"room_event_encryption_info_device_name" = "Öffentlicher Name\n"; +"room_event_encryption_info_device_id" = "ID\n"; +"room_event_encryption_info_device_verification" = "Überprüfung\n"; +"room_event_encryption_info_device_fingerprint" = "Ed25519-Fingerabdruck\n"; +"room_event_encryption_info_device_verified" = "Überprüft"; +"room_event_encryption_info_device_not_verified" = "NICHT verifiziert"; +"room_event_encryption_info_device_blocked" = "auf schwarzer Liste"; +"room_event_encryption_info_verify" = "Überprüfe..."; +"room_event_encryption_info_unverify" = "Verifizierung widerrufen"; +"room_event_encryption_info_block" = "Blockieren"; +"room_event_encryption_info_unblock" = "Blockierung aufheben"; +"room_event_encryption_verify_title" = "Überprüfe Sitzung\n\n"; +"room_event_encryption_verify_message" = "Um zu prüfen, dass dieser Sitzung vertraut werden kann, kontaktiere bitte den Eigentümer über einen anderen Weg (z.B. in Person oder mit einem Telefonanruf) und frage ihn, ob der Schlüssel, den er in seinen Benutzereinstellungen für diese Sitzung sieht, dem folgenden Schlüssel entspricht:\n\n\tSitzungs-Name: %@\n\tSitzungs-ID: %@\n\tSitzungsschlüssel: %@\n\nWenn es übereinstimmt, die \"Überprüfe\" Schaltfläche drücken. Wenn nicht, dann hört jemand anderes diese Sitzung ab und du willst stattdessen vermutlich die Schaltfläche \"Blockieren\" drücken.\n\nIn Zukunft wird dieser Überprüfungsprozess ausgefeilter sein."; +"room_event_encryption_verify_ok" = "Überprüfe"; +// Account +"account_save_changes" = "Änderungen speichern"; +"account_link_email" = "Verbinde E-Mail-Adresse"; +"account_linked_emails" = "Verbundene E-Mail-Adressen"; +"account_email_validation_title" = "Verifizierung ausstehend"; +"account_email_validation_message" = "Bitte prüfe deine E-Mails und klicke auf den enthaltenen Link. Wenn dies erledigt ist, klicke auf \"Fortsetzen\"."; +"account_email_validation_error" = "Kann E-Mail-Adresse nicht verifizieren. Bitte prüfe deine E-Mails und klicke auf den enthaltenen Link. Wenn das erledigt ist, Schaltfläche \"Fortfahren\" drücken"; +"account_msisdn_validation_title" = "Verifizierung ausstehend"; +"account_msisdn_validation_message" = "Wir haben eine SMS mit einem Aktivierungscode gesendet. Bitte den Code unten eingeben."; +"account_msisdn_validation_error" = "Kann Telefonnummer nicht verifizieren."; +"account_error_display_name_change_failed" = "Änderung des Anzeigenamens fehlgeschlagen"; +"account_error_picture_change_failed" = "Änderung des Bildes fehlgeschlagen"; +"account_error_matrix_session_is_not_opened" = "Matrixsitzung ist nicht geöffnet"; +"account_error_email_wrong_title" = "Ungültige E-Mail-Adresse"; +"account_error_email_wrong_description" = "Sieht nicht aus wie eine gültige E-Mail-Adresse"; +"account_error_msisdn_wrong_title" = "Ungültige Telefonnummer"; +"account_error_msisdn_wrong_description" = "Sieht nicht wie eine valide Telefonnummer aus"; +"account_error_push_not_allowed" = "Benachrichtigungen nicht erlaubt"; +// Room creation +"room_creation_name_title" = "Raumname:"; +"room_creation_name_placeholder" = "(z.B. MittagessenGruppe)"; +"room_creation_alias_title" = "Raumalias:"; +"room_creation_alias_placeholder" = "(z.B. #foo:example.org)"; +"room_creation_alias_placeholder_with_homeserver" = "(z.B. #foo%@)"; +"room_creation_participants_title" = "Teilnehmer:"; +"room_creation_participants_placeholder" = "(z.B. @laura:heimserver1; @thomas:heimserver2...)"; +// Room +"room_please_select" = "Bitte wähle einen Raum"; +"room_error_join_failed_title" = "Konnte Raum nicht betreten"; +"room_error_join_failed_empty_room" = "Es ist aktuell nicht möglich einen leeren Raum zu betreten."; +"room_error_name_edition_not_authorized" = "Du bist nicht authorisiert den Raumnamen zu ändern"; +"room_error_topic_edition_not_authorized" = "Du bist nicht authorisiert das Raumthema zu ändern"; +"room_error_cannot_load_timeline" = "Konnte Verlauf nicht laden"; +"room_error_timeline_event_not_found_title" = "Konnte Position im Verlauf nicht laden"; +"room_error_timeline_event_not_found" = "Konnte spezifischen Punkt im Verlauf dieses Raumes nicht finden"; +"room_left" = "Du hast den Raum verlassen"; +"room_left_for_dm" = "Du hast die Unterhaltung verlassen"; +"room_no_power_to_create_conference_call" = "Einladungsberechtigung benötigt, um Konferenz in diesem Raum zu starten"; +"room_no_conference_call_in_encrypted_rooms" = "Konferenzgespräche sind in verschlüsselten Räumen nicht unterstützt"; +// Reply to message +"message_reply_to_sender_sent_an_image" = "sandte ein Bild."; +"message_reply_to_sender_sent_a_video" = "sandte ein Video."; +"message_reply_to_sender_sent_an_audio_file" = "sandte eine Audiodatei."; +"message_reply_to_sender_sent_a_file" = "sandte eine Datei."; +"message_reply_to_message_to_reply_to_prefix" = "Als Antwort auf"; +// Room members +"room_member_ignore_prompt" = "Sicher, dass alle Nachrichten von diesem Benutzer versteckt werden sollen?"; +"room_member_power_level_prompt" = "Du kannst diese Änderung nicht rückgangig machen, weil du dem Benutzer die gleiche Berechtigungsstufe gibst, die du selbst hast.\nBist du sicher?"; +// Attachment +"attachment_size_prompt" = "Möchtest du senden als:"; +"attachment_original" = "Originalgröße (%@)"; +"attachment_small" = "Klein (~%@)"; +"attachment_medium" = "Mittel (~%@)"; +"attachment_large" = "Groß (~%@)"; +"attachment_cancel_download" = "Herunterladen abbrechen?"; +"attachment_cancel_upload" = "Hochladen abbrechen?"; +"attachment_multiselection_size_prompt" = "Bilder senden als:"; +"attachment_multiselection_original" = "Originalgröße"; +"attachment_e2e_keys_file_prompt" = "Diese Datei enthält von einem Matrixclient exportierte Schlüssel.\nMöchtest du den Dateiinhalt sehen oder die Schlüssel importieren?"; +"attachment_e2e_keys_import" = "Importiere..."; +// Contacts +"contact_mx_users" = "Matrixbenutzer"; +"contact_local_contacts" = "Lokale Kontakte"; +// Groups +"group_invite_section" = "Einladungen"; +"group_section" = "Gruppen"; +// Search +"search_no_results" = "Nichts gefunden"; +"search_searching" = "Suche wird durchgeführt..."; +// Time +"format_time_s" = "s"; +"format_time_m" = "m"; +"format_time_h" = "h"; +"format_time_d" = "t"; +// E2E import +"e2e_import_room_keys" = "Importiere Raumschlüssel"; +"e2e_import_prompt" = "Dieser Prozess erlaubt es dir, Schlüssel zu importieren, die du vorher von einem anderen Matrixclient exportiert hast. Du kannst anschließend alle Nachrichten entschlüsseln, die auch bereits der andere Client entschlüsseln konnte.\nDie Exportdatei ist mit einer Passphrase geschützt. Gib die Passphrase hier ein, um die Datei zu importieren."; +"e2e_import" = "Importieren"; +"e2e_passphrase_enter" = "Passphrase eingeben"; +// E2E export +"e2e_export_room_keys" = "Exportiere Raumschlüssel"; +"e2e_export_prompt" = "Dieser Prozeß erlaubt den Export von Schlüsseln, die du in verschlüsselten Räumen empfangen hast, in eine lokale Datei. Du kannst dann die Datei in einem anderen Matrixclient in Zukunft importieren, so dass dieser Client die Nachrichten auch entschlüsseln kann.\nDie exportierte Datei wird jedem der sie lesen kann erlauben, alle verschlüsselten Nachrichten sehen können, also verwahre die Datei sicher."; +"e2e_export" = "Exportiere"; +"e2e_passphrase_confirm" = "Passphrase bestätigen"; +"e2e_passphrase_empty" = "Die Passphrase darf nicht leer sein"; +"e2e_passphrase_not_match" = "Passphrasen stimmen nicht überein"; +"e2e_passphrase_create" = "Passphrase erzeugen"; +// Others +"user_id_title" = "Benutzer-ID:"; +"offline" = "offline"; +"unsent" = "Nicht gesendet"; +"error" = "Fehler"; +"error_common_message" = "Ein Fehler trat auf. Bitte später erneut probieren."; +"not_supported_yet" = "Noch nicht unterstützt"; +"default" = "Standard"; +"private" = "Privat"; +"public" = "Öffentlich"; +"power_level" = "Berechtigungsstufe"; +"network_error_not_reachable" = "Bitte Netzwerkverbindung prüfen"; +"user_id_placeholder" = "z. B.: @thomas:heimserver"; +"ssl_homeserver_url" = "Heimserver URL: %@"; +// Permissions +"camera_access_not_granted_for_call" = "Video-Anrufe benötigen Zugriff auf die Kamera, aber %@ hat keine Berechtigung"; +"microphone_access_not_granted_for_call" = "Anrufe benötigen Zugriff auf das Mikrofon, aber %@ hat keine Berechtigung"; +"local_contacts_access_not_granted" = "Finden von Benutzern in lokalen Kontakten benötigt Zugriff auf die Kontakte, aber %@ hat keine Berechtigung"; +"local_contacts_access_discovery_warning_title" = "Benutzer finden"; +"local_contacts_access_discovery_warning" = "Um Kontakte zu erkennen, die Matrix bereits verwenden, kann %@ E-Mail-Adressen und Telefonnummern in Ihrem Adressbuch an den von Ihnen ausgewählten Matrix-Identitätsserver senden. Sofern dies unterstützt wird, werden personenbezogene Daten vor dem Senden gehasht. Weitere Informationen finden Sie in den Datenschutzrichtlinien Ihres Identitätsservers."; +// Country picker +"country_picker_title" = "Wähle ein Land"; +// Language picker +"language_picker_title" = "Wähle eine Sprache"; +"language_picker_default_language" = "Standard (%@)"; +"notice_room_invite" = "%@ hat %@ eingeladen"; +"notice_room_third_party_invite" = "%@ sendete eine Einladung an %@ den Raum zu betreten"; +"notice_room_third_party_invite_for_dm" = "%@ hat %@ eingeladen"; +"notice_room_third_party_registered_invite" = "%@ akzeptierte die Einladung für %@"; +"notice_room_third_party_revoked_invite" = "%@ hat die Einladung für %@, dem Raum beizutreten, zurückgezogen"; +"notice_room_third_party_revoked_invite_for_dm" = "%@ hat %@'s Einladung zurückgezogen"; +"notice_room_join" = "%@ betrat den Raum"; +"notice_room_leave" = "%@ hat den Raum verlassen"; +"notice_room_reject" = "%@ lehnte die Einladung ab"; +"notice_room_kick" = "%@ hat %@ entfernt"; +"notice_room_unban" = "%@ entsperrte %@"; +"notice_room_ban" = "%@ sperrte %@"; +"notice_room_withdraw" = "%@ hat %@s Einladung zurückgezogen"; +"notice_room_reason" = ". Grund: %@"; +"notice_avatar_url_changed" = "%@ hat den eigenen Avatar geändert"; +"notice_display_name_set" = "%@ setzte den Anzeigenamen auf %@"; +"notice_display_name_changed_from" = "%@ änderte den Anzeigenamen von %@ auf %@"; +"notice_display_name_removed" = "%@ hat den Anzeigenamen entfernt"; +"notice_topic_changed" = "%@ wechselte das Thema zu %@."; +"notice_room_name_changed" = "%@ änderte den Raumnamen zu %@."; +"notice_room_name_changed_for_dm" = "%@ änderte den Raumnamen zu %@."; +"notice_placed_voice_call" = "%@ tätigte einen Sprachanruf"; +"notice_placed_video_call" = "%@ tätigte einen Videoanruf"; +"notice_answered_video_call" = "%@ hat den Anruf angenommen"; +"notice_ended_video_call" = "%@ hat den Anruf beendet"; +"notice_conference_call_request" = "%@ hat eine VoIP-Konferenz angefragt"; +"notice_conference_call_started" = "VoIP-Konferenz gestartet"; +"notice_conference_call_finished" = "VoIP-Konferenz beendet"; +// Notice Events with "You" +"notice_room_invite_by_you" = "Du hast %@ eingeladen"; +"notice_room_invite_you" = "%@ hat Dich eingeladen"; +"notice_room_third_party_invite_by_you" = "Du hast an %@ eine Einladung gesendet dem Raum beizutreten"; +"notice_room_third_party_invite_by_you_for_dm" = "Du hast %@ eingeladen"; +"notice_room_third_party_registered_invite_by_you" = "Du hast die Einladung für %@ angenommen"; +"notice_room_third_party_revoked_invite_by_you" = "Du hast die Einladung dem Raum %@ beizutreten abgelehnt"; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "Du hast die Einladung %@'s zurückgezogen"; +"notice_room_join_by_you" = "Du bist beigetreten"; +"notice_room_leave_by_you" = "Du bist ausgetreten"; +"notice_room_reject_by_you" = "Du hast die Einladung abgelehnt"; +"notice_room_kick_by_you" = "Du hast %@ entfernt"; +"notice_room_unban_by_you" = "Du hast %@ entbannt"; +"notice_room_ban_by_you" = "Du hast %@ gebannt"; +"notice_room_withdraw_by_you" = "Du hast die Einladung von %@ zurückgenommen"; +"notice_avatar_url_changed_by_you" = "Du hast dein Profilbild geändert"; +"notice_display_name_set_by_you" = "Du hast deinen Anzeigenamen auf %@ geändert"; +"notice_display_name_changed_from_by_you" = "Du hast deinen Anzeigenamen von %@ zu %@ geändert"; +"notice_display_name_removed_by_you" = "Du hast deinen Anzeigenamen entfernt"; +"notice_topic_changed_by_you" = "Du hast Das Thema zu %@ geändert."; +"notice_room_name_changed_by_you" = "Du hast den Raumnamen zu %@ geändert."; +"notice_room_name_changed_by_you_for_dm" = "Du hast den Namen zu %@ geändert."; +"notice_placed_voice_call_by_you" = "Du hast einen Audioanruf gestartet"; +"notice_placed_video_call_by_you" = "Du hast einen Videoanruf gestartet"; +"notice_answered_video_call_by_you" = "Du hast den Anruf angenommen"; +"notice_ended_video_call_by_you" = "Du hast den Anruf beendet"; +"notice_conference_call_request_by_you" = "Du hast eine VoIP-Konferenz angefordert"; +"notice_room_name_removed_by_you" = "Du hast den Raumnamen entfernt"; +"notice_room_name_removed_by_you_for_dm" = "Du hast den Namen entfernt"; +"notice_room_topic_removed_by_you" = "Du hast das Raumthema entfernt"; +"notice_event_redacted_by_you" = " von dir"; +"notice_profile_change_redacted_by_you" = "Du hast dein Profil %@ aktualisiert"; +"notice_room_created_by_you" = "Du hast den Raum erstellt und konfiguriert."; +"notice_room_created_by_you_for_dm" = "Du bist beigetreten."; +"notice_encryption_enabled_ok_by_you" = "Du hast Ende-zu-Ende-Verschlüsselung aktiviert."; +"notice_encryption_enabled_unknown_algorithm_by_you" = "Du hast Ende-zu-Ende-Verschlüsselung aktiviert (unbekannter Algorithmus %@)."; +"notice_redaction_by_you" = "Du hast ein Ereignis geschwärzt (ID: %@)"; +"notice_room_history_visible_to_anyone_by_you" = "Du hast den zukünftigen Nachrichtenverlauf für jeden sichtbar gemacht."; +"notice_room_history_visible_to_members_by_you" = "Du hast den zukünftigen Nachrichtenverlauf für alle Mitglieder des Raums sichtbar gemacht."; +"notice_room_history_visible_to_members_by_you_for_dm" = "Du hast den zukünftigen Nachrichtenverlauf für alle Mitglieder des Raums sichtbar gemacht."; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "Du hast den zukünftigen Nachrichtenverlauf für alle Mitglieder des Raums ab deren Teilnahme sichtbar gemacht."; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "Du hast den zukünftigen Nachrichtenverlauf für alle sichtbar gemacht, sobald sie eingeladen werden."; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "Du hast den zukünftigen Nachrichtenverlauf für alle Mitglieder des Raums ab deren Teilnahme sichtbar gemacht."; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "Du hast den zukünftigen Nachrichtenverlauf für alle Mitglieder des Raums sichtbar gemacht, ab deren Teilnahme."; +// Room Screen + +// general errors + +// Home Screen + +// Last seen time + +// call events + +// titles + +// button names +"ok" = "OK"; +"cancel" = "Abbruch"; +"save" = "Speichern"; +"leave" = "Verlassen"; +"send" = "Senden"; +"copy_button_name" = "Kopieren"; +"resend" = "Erneut senden"; +"redact" = "Entfernen"; +"share" = "Teilen"; +"set_power_level" = "Berechtigungslevel"; +"delete" = "Löschen"; +"view" = "Ansehen"; +// actions +"action_logout" = "Abmelden"; +"create_room" = "Erstelle Raum"; +"login" = "Anmelden"; +"create_account" = "Erstelle Konto"; +"membership_invite" = "Eingeladen"; +"membership_leave" = "Verlassen"; +"membership_ban" = "Gesperrt"; +"num_members_one" = "%@ Benutzer"; +"num_members_other" = "%@ Benutzer"; +"invite" = "Einladen"; +"kick" = "Entfernen"; +"ban" = "Sperren"; +"unban" = "Entsperren"; +"message_unsaved_changes" = "Es gibt nicht gespeicherte Änderungen. Verlassen wird diese verwerfen."; +// Login Screen +"login_error_already_logged_in" = "Bereits angemeldet"; +"login_error_must_start_http" = "URL muss mit http[s]:// anfangen"; +// members list Screen + +// accounts list Screen + +// image size selection + +// invitation members list Screen + +// room creation dialog Screen + +// room info dialog Screen + +// room details dialog screen +"room_details_title" = "Raumdetails"; +// contacts list screen +"invitation_message" = "Ich würde gerne über Matrix mit dir chatten. Du kannst dich auf https://matrix.org darüber informieren."; +// Settings screen +"settings_title_config" = "Konfiguration"; +"settings_title_notifications" = "Benachrichtigungen"; +// Notification settings screen +"notification_settings_disable_all" = "Alle Benachrichtigungen ausschalten"; +"notification_settings_enable_notifications" = "Benachrichtigungen einschalten"; +"notification_settings_enable_notifications_warning" = "Alle Benachrichtigungen sind momentan für alle Geräte ausgeschaltet."; +"notification_settings_global_info" = "Benachrichtigungseinstellungen werden in deinem Benutzerkonto gespeichert und zwischen allen Clients die das unterstützen geteilt (inklusive Desktop Benachrichtigungen). \n\nRegeln werden der Reihe nach angewandt; die erste Regel, die zutrifft, bestimmt das Resultat für die Nachricht.\nPro-Wort-Benachrichtigungen sind wichtiger als Pro-Raum-Benachrichtigungen, die wichtiger sind wie Pro-Absender-Benachrichtigungen.\nBei mehrfachen Regeln des gleichen Typs wird die erste in der Liste die zutrifft angewendet."; +"notification_settings_per_word_notifications" = "Pro-Wort-Benachrichtigungen"; +"notification_settings_per_word_info" = "Suchwörter ignorieren Groß-/Kleinschreibung und können ein *-Platzhalter enthalten. Beispiele:\nfoo findet den String foo umgeben durch Trennzeichen (Satzzeichen, Leerzeichen, Zeilenanfang/ende).\nfoo* findet Worte die mit foo beginnen.\n*foo* findet jedes Wort das foo an beliebiger Stelle enthält."; +"notification_settings_always_notify" = "Immer benachrichtigen"; +"notification_settings_never_notify" = "Nie benachrichtigen"; +"notification_settings_word_to_match" = "übereinstimmende Wörter"; +"notification_settings_highlight" = "Hervorheben"; +"notification_settings_custom_sound" = "Individueller Klang"; +"notification_settings_per_room_notifications" = "Pro-Raum-Benachrichtigungen"; +"notification_settings_per_sender_notifications" = "Pro-Absender-Benachrichtigungen"; +"notification_settings_sender_hint" = "@benutzer:domaene.com"; +"notification_settings_select_room" = "Wähle einen Raum"; +"notification_settings_other_alerts" = "Andere Alarme"; +"notification_settings_contain_my_user_name" = "Klänge bei Nachrichten die meinen Benutzernamen enthalten"; +"notification_settings_contain_my_display_name" = "Klänge bei Nachrichten die meinen Anzeigenamen enthalten"; +"notification_settings_just_sent_to_me" = "Mich über gerade empfangene Nachrichten mit einem Klang informieren"; +"notification_settings_invite_to_a_new_room" = "Benachrichtige, wenn ich zu einem neuen Raum eingeladen werde"; +"notification_settings_people_join_leave_rooms" = "Benachrichtige, wenn Benutzer einen Raum betreten oder verlassen"; +"notification_settings_receive_a_call" = "Benachrichtige, wenn ich einen Anruf erhalte"; +"notification_settings_suppress_from_bots" = "Unterdrücke Benachrichtigungen von Bots"; +"notification_settings_by_default" = "Als Standard..."; +"notification_settings_notify_all_other" = "Benachrichtige für alle andereren Nachrichten/Räume"; +// gcm section +"settings_config_home_server" = "Heimserver: %@"; +"settings_config_identity_server" = "Identitätsserver: %@"; +"settings_config_user_id" = "Benutzer-ID: %@"; +// Settings keys + +// call string +"call_waiting" = "Warte..."; +"call_connecting" = "Verbinden…"; +"call_ended" = "Anruf beendet"; +"call_ring" = "Rufe an..."; +"incoming_video_call" = "Eingehender Videoanruf"; +"incoming_voice_call" = "Eingehender Sprachanruf"; +"call_invite_expired" = "Anrufeinladung abgelaufen"; +// unrecognized SSL certificate +"ssl_trust" = "Vertrauensstellung"; +"ssl_logout_account" = "Abmelden"; +"ssl_remain_offline" = "Ignorieren"; +"ssl_fingerprint_hash" = "Fingerabdruck (%@):"; +"ssl_could_not_verify" = "Konnte die Identität des Servers nicht verifizieren."; +"ssl_cert_not_trust" = "Das kann bedeuten, dass jemand den Datenverkehr mitliest, oder dass dein Gerät dem Zertifikat des Servers nicht vertraut."; +"ssl_cert_new_account_expl" = "Wenn der Server Administrator gesagt hat, dass dies erwartet wird, stelle sicher, dass der Fingerabdruck unten dem vom Administrator mitgeteilten Fingerabdruck entspricht."; +"ssl_unexpected_existing_expl" = "Das Zertifikat des Servers hat sich geändert, es ist nicht mehr das vertraute Zertifikat. Das ist SEHR UNGEWÖHNLICH! Es wird empfohlen das neue Zertifikat NICHT ZU AKZEPTIEREN."; +"ssl_expected_existing_expl" = "Das Zertifikat des Servers hat sich geändert, es ist nicht mehr das vertraute Zertifikat. Der Server könnte sein Zertifikat erneuert haben. Kontaktiere den Serveradministrator um den Fingerabdruck zu überprüfen."; +"ssl_only_accept" = "Akzeptiere AUSSCHLIESSLICH Zertifikate für die der Serveradministrator einen Fingerprint veröffentlicht hat."; +"call_more_actions_transfer" = "Übertragung"; +"call_more_actions_audio_use_headset" = "Kopfhörer verwenden"; +"call_more_actions_change_audio_device" = "Audiogerät ändern"; +"call_more_actions_unhold" = "Fortsetzen"; +"call_more_actions_hold" = "Halten"; +"call_holded" = "Du hast den Anurf pausiert"; +"call_remote_holded" = "%@ hat den Anruf pausiert"; +"notice_declined_video_call_by_you" = "Du hast den Anruf abgelehnt"; +"notice_declined_video_call" = "%@ hat den Anruf abgelehnt"; +"resume_call" = "Fortsetzen"; +"call_more_actions_dialpad" = "Ziffernblatt"; +"call_more_actions_audio_use_device" = "Lautsprecher"; +"call_transfer_to_user" = "Durchstellen zu %@"; +"call_video_with_user" = "Videoanruf mit %@"; +"call_voice_with_user" = "Sprachanruf mit %@"; +"call_ringing" = "Läuten…"; +"e2e_passphrase_too_short" = "Passphrase zu kurz (Minimum sind %d Zeichen)"; +"call_consulting_with_user" = "Bei %@ anfragen"; +"microphone_access_not_granted_for_voice_message" = "%@ fehlt die Berechtigung, für Sprachnachrichten auf das Mikrofon zuzugreifen"; +"message_reply_to_sender_sent_a_voice_message" = "hat eine Sprachnachricht gesendet."; +"attachment_size_prompt_title" = "Größe zum Senden"; +"attachment_large_with_resolution" = "Groß %@ (~%@)"; +"attachment_medium_with_resolution" = "Mittel %@ (~%@)"; +"attachment_small_with_resolution" = "Klein %@ (~%@)"; +"attachment_size_prompt_message" = "Du kannst dies in den Einstellungen ausschalten."; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/en.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/en.lproj/MatrixKit.strings new file mode 100644 index 000000000..f9e40406a --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/en.lproj/MatrixKit.strings @@ -0,0 +1,581 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +/* *********************** */ +/* iOS specific */ +/* *********************** */ + +"matrix" = "Matrix"; + +// Login Screen +"login_create_account" = "Create account:"; +"login_server_url_placeholder" = "URL (e.g. https://matrix.org)"; +"login_home_server_title" = "Homeserver URL:"; +"login_home_server_info" = "Your homeserver stores all your conversations and account data"; +"login_identity_server_title" = "Identity server URL:"; +"login_identity_server_info" = "Matrix provides identity servers to track which emails etc. belong to which Matrix IDs. Only https://matrix.org currently exists."; +"login_user_id_placeholder" = "Matrix ID (e.g. @bob:matrix.org or bob)"; +"login_password_placeholder" = "Password"; +"login_optional_field" = "optional"; +"login_display_name_placeholder" = "Display name (e.g. Bob Obson)"; +"login_email_info" = "Specify an email address lets other users find you on Matrix more easily, and will give you a way to reset your password in the future."; +"login_email_placeholder" = "Email address"; +"login_prompt_email_token" = "Please enter your email validation token:"; +"login_error_title" = "Login Failed"; +"login_error_no_login_flow" = "We failed to retrieve authentication information from this homeserver"; +"login_error_do_not_support_login_flows" = "Currently we do not support any or all login flows defined by this homeserver"; +"login_error_registration_is_not_supported" = "Registration is not currently supported"; +"login_error_forbidden" = "Invalid username/password"; +"login_error_unknown_token" = "The access token specified was not recognised"; +"login_error_bad_json" = "Malformed JSON"; +"login_error_not_json" = "Did not contain valid JSON"; +"login_error_limit_exceeded" = "Too many requests have been sent"; +"login_error_user_in_use" = "This user name is already used"; +"login_error_login_email_not_yet" = "The email link which has not been clicked yet"; +"login_use_fallback" = "Use fallback page"; +"login_leave_fallback" = "Cancel"; +"login_invalid_param" = "Invalid parameter"; +"register_error_title" = "Registration Failed"; +"login_error_forgot_password_is_not_supported" = "Forgot password is not currently supported"; +"login_mobile_device"="Mobile"; +"login_tablet_device"="Tablet"; +"login_desktop_device"="Desktop"; +"login_error_resource_limit_exceeded_title" = "Resource Limit Exceeded"; +"login_error_resource_limit_exceeded_message_default" = "This homeserver has exceeded one of its resource limits."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "This homeserver has hit its Monthly Active User limit."; +"login_error_resource_limit_exceeded_message_contact" = "\n\nPlease contact your service administrator to continue using this service."; +"login_error_resource_limit_exceeded_contact_button" = "Contact Administrator"; + +// Action +"no" = "No"; +"yes" = "Yes"; +"abort" = "Abort"; +"back" = "Back"; +"close" = "Close"; +"continue" = "Continue"; +"discard" = "Discard"; +"dismiss" = "Dismiss"; +"retry" = "Retry"; +"sign_up" = "Sign up"; +"submit" = "Submit"; +"submit_code" = "Submit code"; +"set_power_level" = "Set Power Level"; +"set_default_power_level" = "Reset Power Level"; +"set_moderator" = "Set Moderator"; +"set_admin" = "Set Admin"; +"start_chat" = "Start Chat"; +"start_voice_call" = "Start Voice Call"; +"start_video_call" = "Start Video Call"; +"mention" = "Mention"; +"select_account" = "Select an account"; +"attach_media" = "Attach Media from Library"; +"capture_media" = "Take Photo/Video"; +"invite_user" = "Invite matrix User"; +"reset_to_default" = "Reset to default"; +"resend_message" = "Resend the message"; +"select_all" = "Select All"; +"cancel_upload" = "Cancel Upload"; +"cancel_download" = "Cancel Download"; +"show_details" = "Show Details"; +"answer_call" = "Answer Call"; +"reject_call" = "Reject Call"; +"end_call" = "End Call"; +"resume_call" = "Resume"; +"ignore" = "Ignore"; +"unignore" = "Unignore"; + +// Events formatter +"notice_avatar_changed_too" = "(avatar was changed too)"; +"notice_room_name_removed" = "%@ removed the room name"; +"notice_room_name_removed_for_dm" = "%@ removed the name"; +"notice_room_topic_removed" = "%@ removed the topic"; +"notice_event_redacted" = ""; +"notice_event_redacted_by" = " by %@"; +"notice_event_redacted_reason" = " [reason: %@]"; +"notice_profile_change_redacted" = "%@ updated their profile %@"; +"notice_room_created" = "%@ created and configured the room."; +"notice_room_created_for_dm" = "%@ joined."; +// Old +"notice_room_join_rule" = "The join rule is: %@"; +// New +"notice_room_join_rule_invite" = "%@ made the room invite only."; +"notice_room_join_rule_invite_for_dm" = "%@ made this invite only."; +"notice_room_join_rule_invite_by_you" = "You made the room invite only."; +"notice_room_join_rule_invite_by_you_for_dm" = "You made this invite only."; +"notice_room_join_rule_public" = "%@ made the room public."; +"notice_room_join_rule_public_for_dm" = "%@ made this public."; +"notice_room_join_rule_public_by_you" = "You made the room public."; +"notice_room_join_rule_public_by_you_for_dm" = "You made this public."; +"notice_room_power_level_intro" = "The power level of room members are:"; +"notice_room_power_level_intro_for_dm" = "The power level of members are:"; +"notice_room_power_level_acting_requirement" = "The minimum power levels that a user must have before acting are:"; +"notice_room_power_level_event_requirement" = "The minimum power levels related to events are:"; +"notice_room_aliases" = "The room aliases are: %@"; +"notice_room_aliases_for_dm" = "The aliases are: %@"; +"notice_room_related_groups" = "The groups associated with this room are: %@"; +"notice_encrypted_message" = "Encrypted message"; +"notice_encryption_enabled_ok" = "%@ turned on end-to-end encryption."; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ turned on end-to-end encryption (unrecognised algorithm %2$@)."; +"notice_image_attachment" = "image attachment"; +"notice_audio_attachment" = "audio attachment"; +"notice_video_attachment" = "video attachment"; +"notice_location_attachment" = "location attachment"; +"notice_file_attachment" = "file attachment"; +"notice_invalid_attachment" = "invalid attachment"; +"notice_unsupported_attachment" = "Unsupported attachment: %@"; +"notice_feedback" = "Feedback event (id: %@): %@"; +"notice_redaction" = "%@ redacted an event (id: %@)"; +"notice_error_unsupported_event" = "Unsupported event"; +"notice_error_unexpected_event" = "Unexpected event"; +"notice_error_unknown_event_type" = "Unknown event type"; +"notice_room_history_visible_to_anyone" = "%@ made future room history visible to anyone."; +"notice_room_history_visible_to_members" = "%@ made future room history visible to all room members."; +"notice_room_history_visible_to_members_for_dm" = "%@ made future messages visible to all room members."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ made future room history visible to all room members, from the point they are invited."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ made future messages visible to everyone, from when they get invited."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ made future room history visible to all room members, from the point they joined."; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ made future messages visible to everyone, from when they joined."; +"notice_crypto_unable_to_decrypt" = "** Unable to decrypt: %@ **"; +"notice_crypto_error_unknown_inbound_session_id" = "The sender's session has not sent us the keys for this message."; +"notice_sticker" = "sticker"; +"notice_in_reply_to" = "In reply to"; + +// room display name +"room_displayname_empty_room" = "Empty room"; +"room_displayname_two_members" = "%@ and %@"; +"room_displayname_more_than_two_members" = "%@ and %@ others"; +"room_displayname_all_other_members_left" = "%@ (Left)"; + +// Settings +"settings" = "Settings"; +"settings_enable_inapp_notifications" = "Enable In-App notifications"; +"settings_enable_push_notifications" = "Enable push notifications"; +"settings_enter_validation_token_for" = "Enter validation token for %@:"; + +"notification_settings_room_rule_title" = "Room: '%@'"; + +// Devices +"device_details_title" = "Session information\n"; +"device_details_name" = "Public Name\n"; +"device_details_identifier" = "ID\n"; +"device_details_last_seen" = "Last seen\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_rename_prompt_title" = "Session Name"; +"device_details_rename_prompt_message" = "A session's public name is visible to people you communicate with"; +"device_details_delete_prompt_title" = "Authentication"; +"device_details_delete_prompt_message" = "This operation requires additional authentication.\nTo continue, please enter your password."; + +// Encryption information +"room_event_encryption_info_title" = "End-to-end encryption information\n\n"; +"room_event_encryption_info_event" = "Event information\n"; +"room_event_encryption_info_event_user_id" = "User ID\n"; +"room_event_encryption_info_event_identity_key" = "Curve25519 identity key\n"; +"room_event_encryption_info_event_fingerprint_key" = "Claimed Ed25519 fingerprint key\n"; +"room_event_encryption_info_event_algorithm" = "Algorithm\n"; +"room_event_encryption_info_event_session_id" = "Session ID\n"; +"room_event_encryption_info_event_decryption_error" = "Decryption error\n"; +"room_event_encryption_info_event_unencrypted" = "unencrypted"; +"room_event_encryption_info_event_none" = "none"; +"room_event_encryption_info_device" = "\nSender session information\n"; +"room_event_encryption_info_device_unknown" = "unknown session\n"; +"room_event_encryption_info_device_name" = "Public Name\n"; +"room_event_encryption_info_device_id" = "ID\n"; +"room_event_encryption_info_device_verification" = "Verification\n"; +"room_event_encryption_info_device_fingerprint" = "Ed25519 fingerprint\n"; +"room_event_encryption_info_device_verified" = "Verified"; +"room_event_encryption_info_device_not_verified" = "NOT verified"; +"room_event_encryption_info_device_blocked" = "Blacklisted"; +"room_event_encryption_info_verify" = "Verify..."; +"room_event_encryption_info_unverify" = "Unverify"; +"room_event_encryption_info_block" = "Blacklist"; +"room_event_encryption_info_unblock" = "Unblacklist"; +"room_event_encryption_verify_title" = "Verify session\n\n"; +"room_event_encryption_verify_message" = "To verify that this session can be trusted, please contact its owner using some other means (e.g. in person or a phone call) and ask them whether the key they see in their User Settings for this session matches the key below:\n\n\tSession name: %@\n\tSession ID: %@\n\tSession key: %@\n\nIf it matches, press the verify button below. If it doesnt, then someone else is intercepting this session and you probably want to press the blacklist button instead.\n\nIn future this verification process will be more sophisticated."; +"room_event_encryption_verify_ok" = "Verify"; + +// Account +"account_save_changes" = "Save changes"; +"account_link_email" = "Link Email"; +"account_linked_emails" = "Linked emails"; + +"account_email_validation_title" = "Verification Pending"; +"account_email_validation_message" = "Please check your email and click on the link it contains. Once this is done, click continue."; +"account_email_validation_error" = "Unable to verify email address. Please check your email and click on the link it contains. Once this is done, click continue"; + +"account_msisdn_validation_title" = "Verification Pending"; +"account_msisdn_validation_message" = "We\'ve sent an SMS with an activation code. Please enter this code below."; +"account_msisdn_validation_error" = "Unable to verify phone number."; + +"account_error_display_name_change_failed" = "Display name change failed"; +"account_error_picture_change_failed" = "Picture change failed"; +"account_error_matrix_session_is_not_opened" = "Matrix session is not opened"; +"account_error_email_wrong_title" = "Invalid Email Address"; +"account_error_email_wrong_description" = "This doesn't appear to be a valid email address"; +"account_error_msisdn_wrong_title" = "Invalid Phone Number"; +"account_error_msisdn_wrong_description" = "This doesn't appear to be a valid phone number"; +"account_error_push_not_allowed" = "Notifications not allowed"; + +// Room creation +"room_creation_name_title" = "Room name:"; +"room_creation_name_placeholder" = "(e.g. lunchGroup)"; +"room_creation_alias_title" = "Room alias:"; +"room_creation_alias_placeholder" = "(e.g. #foo:example.org)"; +"room_creation_alias_placeholder_with_homeserver" = "(e.g. #foo%@)"; +"room_creation_participants_title" = "Participants:"; +"room_creation_participants_placeholder" = "(e.g. @bob:homeserver1; @john:homeserver2...)"; + +// Room +"room_please_select" = "Please select a room"; +"room_error_join_failed_title" = "Failed to join room"; +"room_error_join_failed_empty_room" = "It is not currently possible to join an empty room."; +"room_error_name_edition_not_authorized" = "You are not authorized to edit this room name"; +"room_error_topic_edition_not_authorized" = "You are not authorized to edit this room topic"; +"room_error_cannot_load_timeline" = "Failed to load timeline"; +"room_error_timeline_event_not_found_title" = "Failed to load timeline position"; +"room_error_timeline_event_not_found" = "The application was trying to load a specific point in this room's timeline but was unable to find it"; +"room_left" = "You left the room"; +"room_left_for_dm" = "You left"; +"room_no_power_to_create_conference_call" = "You need permission to invite to start a conference in this room"; +"room_no_conference_call_in_encrypted_rooms" = "Conference calls are not supported in encrypted rooms"; + +// Reply to message +"message_reply_to_sender_sent_an_image" = "sent an image."; +"message_reply_to_sender_sent_a_video" = "sent a video."; +"message_reply_to_sender_sent_an_audio_file" = "sent an audio file."; +"message_reply_to_sender_sent_a_voice_message" = "sent a voice message."; +"message_reply_to_sender_sent_a_file" = "sent a file."; +"message_reply_to_message_to_reply_to_prefix" = "In reply to"; + +// Room members +"room_member_ignore_prompt" = "Are you sure you want to hide all messages from this user?"; +"room_member_power_level_prompt" = "You will not be able to undo this change as you are promoting the user to have the same power level as yourself.\nAre you sure?"; + +// Attachment +"attachment_size_prompt" = "Do you want to send as:"; +"attachment_size_prompt_title" = "Confirm size to send"; +"attachment_size_prompt_message" = "You can turn this off in settings."; +"attachment_original" = "Actual Size (%@)"; +"attachment_small" = "Small (~%@)"; +"attachment_medium" = "Medium (~%@)"; +"attachment_large" = "Large (~%@)"; +"attachment_small_with_resolution" = "Small %@ (~%@)"; +"attachment_medium_with_resolution" = "Medium %@ (~%@)"; +"attachment_large_with_resolution" = "Large %@ (~%@)"; +"attachment_cancel_download" = "Cancel the download?"; +"attachment_cancel_upload" = "Cancel the upload?"; +"attachment_multiselection_size_prompt" = "Do you want to send images as:"; +"attachment_multiselection_original" = "Actual Size"; +"attachment_e2e_keys_file_prompt" = "This file contains encryption keys exported from a Matrix client.\nDo you want to view the file content or import the keys it contains?"; +"attachment_e2e_keys_import" = "Import..."; +"attachment_unsupported_preview_title" = "Unable to preview"; +"attachment_unsupported_preview_message" = "This file type is not supported."; + +// Contacts +"contact_mx_users" = "Matrix Users"; +"contact_local_contacts" = "Local Contacts"; + +// Groups +"group_invite_section" = "Invites"; +"group_section" = "Groups"; + +// Search +"search_no_results" = "No Results"; +"search_searching" = "Search in progress..."; + +// Time +"format_time_s" = "s"; +"format_time_m" = "m"; +"format_time_h" = "h"; +"format_time_d" = "d"; + +// E2E import +"e2e_import_room_keys" = "Import room keys"; +"e2e_import_prompt" = "This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.\nThe export file is protected with a passphrase. You should enter the passphrase here, to decrypt the file."; +"e2e_import" = "Import"; +"e2e_passphrase_enter" = "Enter passphrase"; + +// E2E export +"e2e_export_room_keys" = "Export room keys"; +"e2e_export_prompt" = "This process allows you to export the keys for messages you have received in encrypted rooms to a local file. You will then be able to import the file into another Matrix client in the future, so that client will also be able to decrypt these messages.\nThe exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure."; +"e2e_export" = "Export"; +"e2e_passphrase_confirm" = "Confirm passphrase"; +"e2e_passphrase_empty" = "Passphrase must not be empty"; +"e2e_passphrase_too_short" = "Passphrase too short (It must be at a minimum %d characters in length)"; +"e2e_passphrase_not_match" = "Passphrases must match"; +"e2e_passphrase_create" = "Create passphrase"; + +// Others +"user_id_title" = "User ID:"; +"offline" = "offline"; +"unsent" = "Unsent"; +"error" = "Error"; +"error_common_message" = "An error occured. Please try again later."; +"not_supported_yet" = "Not supported yet"; +"default" = "default"; +"private" = "Private"; +"public" = "Public"; +"power_level" = "Power Level"; +"network_error_not_reachable" = "Please check your network connectivity"; +"user_id_placeholder" = "ex: @bob:homeserver"; +"ssl_homeserver_url" = "Homeserver URL: %@"; + +// Permissions +"camera_access_not_granted_for_call" = "Video calls require access to the Camera but %@ doesn't have permission to use it"; +"microphone_access_not_granted_for_call" = "Calls require access to the Microphone but %@ doesn't have permission to use it"; +"local_contacts_access_not_granted" = "Users discovery from local contacts requires access to you contacts but %@ doesn't have permission to use it"; + +"local_contacts_access_discovery_warning_title" = "Users discovery"; +"local_contacts_access_discovery_warning" = "To discover contacts already using Matrix, %@ can send email addresses and phone numbers in your address book to your chosen Matrix identity server. Where supported, personal data is hashed before sending - please check your identity server's privacy policy for more details."; + +"microphone_access_not_granted_for_voice_message" = "Voice messages require access to the Microphone but %@ doesn't have permission to use it"; + +// Country picker +"country_picker_title" = "Choose a country"; + +// Language picker +"language_picker_title" = "Choose a language"; +"language_picker_default_language" = "Default (%@)"; + +/* -*- + Automatic localization for en + + The following key/value pairs were extracted from the android i18n file: + /matrix-sdk/src/main/res/values/strings.xml. +*/ + +"notice_room_invite" = "%@ invited %@"; +"notice_room_third_party_invite" = "%@ sent an invitation to %@ to join the room"; +"notice_room_third_party_invite_for_dm" = "%@ invited %@"; +"notice_room_third_party_registered_invite" = "%@ accepted the invitation for %@"; +"notice_room_third_party_revoked_invite" = "%@ revoked the invitation for %@ to join the room"; +"notice_room_third_party_revoked_invite_for_dm" = "%@ revoked %@'s invitation"; +"notice_room_join" = "%@ joined"; +"notice_room_leave" = "%@ left"; +"notice_room_reject" = "%@ rejected the invitation"; +"notice_room_kick" = "%@ kicked %@"; +"notice_room_unban" = "%@ unbanned %@"; +"notice_room_ban" = "%@ banned %@"; +"notice_room_withdraw" = "%@ withdrew %@'s invitation"; +"notice_room_reason" = ". Reason: %@"; +"notice_avatar_url_changed" = "%@ changed their avatar"; +"notice_display_name_set" = "%@ set their display name to %@"; +"notice_display_name_changed_from" = "%@ changed their display name from %@ to %@"; +"notice_display_name_removed" = "%@ removed their display name"; +"notice_topic_changed" = "%@ changed the topic to \"%@\"."; +"notice_room_name_changed" = "%@ changed the room name to %@."; +"notice_room_name_changed_for_dm" = "%@ changed the name to %@."; +"notice_placed_voice_call" = "%@ placed a voice call"; +"notice_placed_video_call" = "%@ placed a video call"; +"notice_answered_video_call" = "%@ answered the call"; +"notice_ended_video_call" = "%@ ended the call"; +"notice_declined_video_call" = "%@ declined the call"; +"notice_conference_call_request" = "%@ requested a VoIP conference"; +"notice_conference_call_started" = "VoIP conference started"; +"notice_conference_call_finished" = "VoIP conference finished"; + +// Notice Events with "You" +"notice_room_invite_by_you" = "You invited %@"; +"notice_room_invite_you" = "%@ invited you"; +"notice_room_third_party_invite_by_you" = "You sent an invitation to %@ to join the room"; +"notice_room_third_party_invite_by_you_for_dm" = "You invited %@"; +"notice_room_third_party_registered_invite_by_you" = "You accepted the invitation for %@"; +"notice_room_third_party_revoked_invite_by_you" = "You revoked the invitation for %@ to join the room"; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "You revoked %@'s invitation"; +"notice_room_join_by_you" = "You joined"; +"notice_room_leave_by_you" = "You left"; +"notice_room_reject_by_you" = "You rejected the invitation"; +"notice_room_kick_by_you" = "You kicked %@"; +"notice_room_unban_by_you" = "You unbanned %@"; +"notice_room_ban_by_you" = "You banned %@"; +"notice_room_withdraw_by_you" = "You withdrew %@'s invitation"; +"notice_avatar_url_changed_by_you" = "You changed your avatar"; +"notice_display_name_set_by_you" = "You set your display name to %@"; +"notice_display_name_changed_from_by_you" = "You changed your display name from %@ to %@"; +"notice_display_name_removed_by_you" = "You removed your display name"; +"notice_topic_changed_by_you" = "You changed the topic to \"%@\"."; +"notice_room_name_changed_by_you" = "You changed the room name to %@."; +"notice_room_name_changed_by_you_for_dm" = "You changed the name to %@."; +"notice_placed_voice_call_by_you" = "You placed a voice call"; +"notice_placed_video_call_by_you" = "You placed a video call"; +"notice_answered_video_call_by_you" = "You answered the call"; +"notice_ended_video_call_by_you" = "You ended the call"; +"notice_declined_video_call_by_you" = "You declined the call"; +"notice_conference_call_request_by_you" = "You requested a VoIP conference"; +"notice_room_name_removed_by_you" = "You removed the room name"; +"notice_room_name_removed_by_you_for_dm" = "You removed the name"; +"notice_room_topic_removed_by_you" = "You removed the topic"; +"notice_event_redacted_by_you" = " by you"; +"notice_profile_change_redacted_by_you" = "You updated your profile %@"; +"notice_room_created_by_you" = "You created and configured the room."; +"notice_room_created_by_you_for_dm" = "You joined."; +"notice_encryption_enabled_ok_by_you" = "You turned on end-to-end encryption."; +"notice_encryption_enabled_unknown_algorithm_by_you" = "You turned on end-to-end encryption (unrecognised algorithm %@)."; +"notice_redaction_by_you" = "You redacted an event (id: %@)"; +"notice_room_history_visible_to_anyone_by_you" = "You made future room history visible to anyone."; +"notice_room_history_visible_to_members_by_you" = "You made future room history visible to all room members."; +"notice_room_history_visible_to_members_by_you_for_dm" = "You made future messages visible to all room members."; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "You made future room history visible to all room members, from the point they are invited."; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "You made future messages visible to everyone, from when they get invited."; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "You made future room history visible to all room members, from the point they joined."; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "You made future messages visible to everyone, from when they joined."; + +// Room Screen + +// general errors + +// Home Screen + +// Last seen time + +// call events + +/* -*- + Automatic localization for en + + The following key/value pairs were extracted from the android i18n file: + /console/src/main/res/values/strings.xml. +*/ + + +// titles + +// button names +"ok" = "OK"; +"cancel" = "Cancel"; +"save" = "Save"; +"leave" = "Leave"; +"send" = "Send"; +"copy_button_name" = "Copy"; +"resend" = "Resend"; +"redact" = "Remove"; +"share" = "Share"; +"delete" = "Delete"; +"view" = "View"; +"rename" = "Rename"; + +// actions +"action_logout" = "Logout"; +"create_room" = "Create Room"; +"login" = "Login"; +"create_account" = "Create Account"; +"membership_invite" = "Invited"; +"membership_leave" = "Left"; +"membership_ban" = "Banned"; +"num_members_one" = "%@ user"; +"num_members_other" = "%@ users"; +"invite" = "Invite"; +"kick" = "Kick"; +"ban" = "Ban"; +"unban" = "Un-ban"; +"message_unsaved_changes" = "There are unsaved changes. Leaving will discard them."; + +// Login Screen +"login_error_already_logged_in" = "Already logged in"; +"login_error_must_start_http" = "URL must start with http[s]://"; + +// members list Screen + +// accounts list Screen + +// image size selection + +// invitation members list Screen + +// room creation dialog Screen + +// room info dialog Screen + +// room details dialog screen +"room_details_title" = "Room Details"; + +// contacts list screen +"invitation_message" = "I\'d like to chat with you with matrix. Please, visit the website http://matrix.org to have more information."; + +// Settings screen +"settings_title_config" = "Configuration"; +"settings_title_notifications" = "Notifications"; + +// Notification settings screen +"notification_settings_disable_all" = "Disable all notifications"; +"notification_settings_enable_notifications" = "Enable notifications"; +"notification_settings_enable_notifications_warning" = "All notifications are currently disabled for all devices."; +"notification_settings_global_info" = "Notification settings are saved to your user account and are shared between all clients which support them (including desktop notifications).\n\nRules are applied in order; the first rule which matches defines the outcome for the message.\nSo: Per-word notifications are more important than per-room notifications which are more important than per-sender notifications.\nFor multiple rules of the same kind, the first one in the list that matches takes priority."; +"notification_settings_per_word_notifications" = "Per-word notifications"; +"notification_settings_per_word_info" = "Words match case insensitively, and may include a * wildcard. So:\nfoo matches the string foo surrounded by word delimiters (e.g. punctuation and whitespace or start/end of line).\nfoo* matches any such word that begins foo.\n*foo* matches any such word which includes the 3 letters foo."; +"notification_settings_always_notify" = "Always notify"; +"notification_settings_never_notify" = "Never notify"; +"notification_settings_word_to_match" = "word to match"; +"notification_settings_highlight" = "Highlight"; +"notification_settings_custom_sound" = "Custom sound"; +"notification_settings_per_room_notifications" = "Per-room notifications"; +"notification_settings_per_sender_notifications" = "Per-sender notifications"; +"notification_settings_sender_hint" = "\@user:domain.com"; +"notification_settings_select_room" = "Select a room"; +"notification_settings_other_alerts" = "Other Alerts"; +"notification_settings_contain_my_user_name" = "Notify me with sound about messages that contain my user name"; +"notification_settings_contain_my_display_name" = "Notify me with sound about messages that contain my display name"; +"notification_settings_just_sent_to_me" = "Notify me with sound about messages sent just to me"; +"notification_settings_invite_to_a_new_room" = "Notify me when I am invited to a new room"; +"notification_settings_people_join_leave_rooms" = "Notify me when people join or leave rooms"; +"notification_settings_receive_a_call" = "Notify me when I receive a call"; +"notification_settings_suppress_from_bots" = "Suppress notifications from bots"; +"notification_settings_by_default" = "By default..."; +"notification_settings_notify_all_other" = "Notify for all other messages/rooms"; + +// gcm section +"settings_config_home_server" = "Homeserver: %@"; +"settings_config_identity_server" = "Identity server: %@"; +"settings_config_user_id" = "User ID: %@"; + +// Settings keys + +// call string +"call_connecting" = "Connecting…"; +"call_ringing" = "Ringing…"; +"call_ended" = "Call ended"; +"incoming_video_call" = "Incoming Video Call"; +"incoming_voice_call" = "Incoming Voice Call"; +"call_invite_expired" = "Call Invite Expired"; +"call_remote_holded" = "%@ held the call"; +"call_holded" = "You held the call"; +"call_more_actions_hold" = "Hold"; +"call_more_actions_unhold" = "Resume"; +"call_more_actions_change_audio_device" = "Change Audio Device"; +"call_more_actions_audio_use_device" = "Device Speaker"; +"call_more_actions_transfer" = "Transfer"; +"call_more_actions_dialpad" = "Dial pad"; +"call_voice_with_user" = "Voice call with %@"; +"call_video_with_user" = "Video call with %@"; +"call_consulting_with_user" = "Consulting with %@"; +"call_transfer_to_user" = "Transfer to %@"; + +// unrecognized SSL certificate +"ssl_trust" = "Trust"; +"ssl_logout_account" = "Logout"; +"ssl_remain_offline" = "Ignore"; +"ssl_fingerprint_hash" = "Fingerprint (%@):"; +"ssl_could_not_verify" = "Could not verify identity of remote server."; +"ssl_cert_not_trust" = "This could mean that someone is maliciously intercepting your traffic, or that your phone does not trust the certificate provided by the remote server."; +"ssl_cert_new_account_expl" = "If the server administrator has said that this is expected, ensure that the fingerprint below matches the fingerprint provided by them."; +"ssl_unexpected_existing_expl" = "The certificate has changed from one that was trusted by your phone. This is HIGHLY UNUSUAL. It is recommended that you DO NOT ACCEPT this new certificate."; +"ssl_expected_existing_expl" = "The certificate has changed from a previously trusted one to one that is not trusted. The server may have renewed its certificate. Contact the server administrator for the expected fingerprint."; +"ssl_only_accept" = "ONLY accept the certificate if the server administrator has published a fingerprint that matches the one above."; + +"auth_invalid_user_name" = "Invalid username"; +"auth_username_in_use" = "Username in use"; +"auth_reset_password_error_unauthorized" = "Unauthorized"; +"auth_reset_password_error_not_found" = "Not found"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/eo.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/eo.lproj/MatrixKit.strings new file mode 100644 index 000000000..675f3b483 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/eo.lproj/MatrixKit.strings @@ -0,0 +1,533 @@ +"notice_crypto_unable_to_decrypt" = "** Ne eblas malĉifri: %@ **"; +"notice_crypto_error_unknown_inbound_session_id" = "La salutaĵo de la sendanto ne sendis al ni la ŝlosilojn por tiu mesaĝo."; +"notice_in_reply_to" = "Respondanta al"; +// room display name +"room_displayname_empty_room" = "Malplena babilejo"; +"room_displayname_two_members" = "%@ kaj %@"; +"room_displayname_more_than_two_members" = "%@ kaj %@ aliaj"; +// Settings +"settings" = "Agordoj"; +"message_reply_to_message_to_reply_to_prefix" = "Respondanta al"; +"room_event_encryption_verify_title" = "Kontroli salutaĵon\n\n"; +"back" = "Reiri"; +"close" = "Fermi"; +"continue" = "Daŭrigi"; +"retry" = "Reprovi"; +"cancel" = "Nuligi"; +"save" = "Konservi"; +"leave" = "Forlasi"; +"view" = "Vidi"; +"invite" = "Inviti"; + +// members list Screen + +// accounts list Screen + +// image size selection + +// invitation members list Screen + +// room creation dialog Screen + +// room info dialog Screen + +// room details dialog screen +"room_details_title" = "Detaloj pri ĉambro"; +"register_error_title" = "Registriĝo malsukcesis"; +"login_invalid_param" = "Nevalida parametro"; +"login_leave_fallback" = "Nuligi"; +"login_error_login_email_not_yet" = "La retpoŝta ligilo, kiun vi ankoraŭ ne klakis"; +"login_error_user_in_use" = "Tiu ĉi uzantonomo jam estas uzata"; +"login_error_limit_exceeded" = "Tro multaj petoj sendiĝis"; +"login_error_not_json" = "Ne enhavis validajn JSON-datumojn"; +"login_error_bad_json" = "Misformitaj JSON-datumoj"; +"login_error_unknown_token" = "La donita ĵetono de aliro ne rekoniĝis"; +"login_error_forbidden" = "Nevalidaj uzantonomo aŭ pasvorto"; +"login_error_registration_is_not_supported" = "Registriĝo ne estas nun subtenata"; +"login_error_do_not_support_login_flows" = "Nuntempe ni subtenas neniujn manierojn de salutado difinitajn de tiu ĉi hejmservilo"; +"login_error_no_login_flow" = "Ni malsukcesis akiri informojn pri aŭtentikigo de ĉi tiu hejmservilo"; +"login_error_title" = "Malsukcesis saluto"; +"login_prompt_email_token" = "Bonvolu enigi vian ĵetonon de retpoŝta validigo:"; +"login_email_placeholder" = "Retpoŝtadreso"; +"login_email_info" = "Specifu retpoŝtadresojn por ebligi vian pli facilan troviĝon de aliaj uzantoj, kaj ankaŭ por restarigi vian pasvorton okaze de perdo."; +"login_display_name_placeholder" = "Prezenta nomo (ekz. Ivano Ĥlestakov)"; +"login_optional_field" = "malnepra"; +"login_password_placeholder" = "Pasvorto"; +"login_user_id_placeholder" = "Identigilo de Matrix (ekz. @ivano:matrix.org aŭ ivano)"; +"login_identity_server_info" = "Matrix havas identigajn servilojn por scii, kiuj retleteroj ktp. bezonas al kiuj identigiloj de Matrix. Nur https://matrix.org ekzistas nuntempe."; +"login_identity_server_title" = "URL de identiga servilo:"; +"login_home_server_info" = "Via hejmservilo konservas ĉiujn viajn interparolojn kaj datumojn de konto"; +"login_home_server_title" = "URL de hejmservilo:"; +"login_server_url_placeholder" = "URL (ekz. https://matrix.org)"; + +// Login Screen +"login_create_account" = "Krei konton:"; +/* *********************** */ +/* iOS specific */ +/* *********************** */ + +"matrix" = "Matrix"; +"login_desktop_device" = "Tabla komputilo"; +"login_tablet_device" = "Tabulkomputilo"; +"login_mobile_device" = "Telefono"; +"room_event_encryption_verify_message" = "Por certigi, ke ĉi tiu salutaĵo povas esti fidata, bonvolu kontakti ĝian posedanton per alia maniero (ekz. persone aŭ per telefona voko) kaj demandu, ĉu la ŝlosilo, kiun ĝi vidas en siaj agordoj de uzanto por ĉi tiu salutaĵo, akordas kun la ĉi-suba:\n\n\tNomo de salutaĵo: %@\n\tIdentigilo de salutaĵo: %@\n\tŜlosilo de salutaĵo: %@\n\nSe la ŝlosilo akordas, premu la kontrolan butonon ĉi-sube. Se ne, iu alia subaŭskultas la salutaĵon, kaj vi probable volas anstataŭe malpermesi ĝin.\n\nĈi tiu kontrola procedo plifaciliĝos estontece."; +"room_event_encryption_info_device_fingerprint" = "Fingrospuro je Ed25519\n"; +"room_event_encryption_info_event_fingerprint_key" = "Asertita ŝlosilo de fingrospuro je Ed25519\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"notice_feedback" = "Responda okazo (identigilo: %@): %@"; +"discard" = "Forĵeti"; +"abort" = "Ĉesigi"; +"login_use_fallback" = "Uzi repaŝan paĝon"; +"ssl_only_accept" = "Akceptu la atestilon NUR SE administranto de la servilo publikigis fingrospuron, kiu akordas kun la ĉi-supra."; +"ssl_expected_existing_expl" = "La atestilo ŝanĝiĝis de atestilo antaŭe fidata al alia, kiu ne estas fidata. Eble la servilo renovigis sian atestilon. Kontaktu la administranton de la servilo por ricevi la atendindan fingrospuron."; +"ssl_unexpected_existing_expl" = "La atestilo ŝanĝiĝis de tiu, kiun fidis via telefono. Tio estas TRE STRANGA. Oni rekomendas, ke vi NE AKCEPTU ĉi tiun novan atestilon."; +"ssl_cert_new_account_expl" = "Se la administranto de la servilo diris, ke tio atendindas, certigu, ke la ĉi-suba fingrospuro akordas kun la fingrospuro donita de la administranto."; +"ssl_cert_not_trust" = "Tio povus signifi, ke iu malice subaŭskultas vian rettrafikon, aŭ ke via telefono ne fidas la atestilon donitan de la fora servilo."; +"ssl_could_not_verify" = "Ne povis kontroli identecon de fora servilo."; +"ssl_fingerprint_hash" = "Fingrospuro (%@):"; +"ssl_remain_offline" = "Malatenti"; +"ssl_logout_account" = "Adiaŭi"; + +// unrecognized SSL certificate +"ssl_trust" = "Fidi"; +"call_transfer_to_user" = "Transdoni al %@"; +"call_consulting_with_user" = "Konsultante kun %@"; +"call_video_with_user" = "Vidvoko kun %@"; +"call_voice_with_user" = "Voĉvoko kun %@"; +"call_more_actions_dialpad" = "Ciferplato"; +"call_more_actions_transfer" = "Transdoni"; +"call_more_actions_audio_use_device" = "Soni aparate"; +"call_more_actions_audio_use_headset" = "Soni kapaŭskultile"; +"call_more_actions_change_audio_device" = "Ŝanĝi sonaparaton"; +"call_more_actions_unhold" = "Daŭrigi"; +"call_more_actions_hold" = "Paŭzigi"; +"call_holded" = "Vi paŭzigis la vokon"; +"call_remote_holded" = "%@ paŭzigis la vokon"; +"call_invite_expired" = "Inviti al voko atingis tempolimon"; +"incoming_voice_call" = "Envena voĉvoko"; +"incoming_video_call" = "Envena vidvoko"; +"call_ended" = "Voko finiĝis"; +"call_ringing" = "Sonorante…"; + +// Settings keys + +// call string +"call_connecting" = "Konektante…"; +"settings_config_user_id" = "Identigilo de uzanto: %@"; +"settings_config_identity_server" = "Identiga servilo: %@"; + +// gcm section +"settings_config_home_server" = "Hejmservilo: %@"; +"notification_settings_notify_all_other" = "Sciigi por ĉiuj aliaj mesaĝoj/ĉambroj"; +"notification_settings_by_default" = "Implicite…"; +"notification_settings_suppress_from_bots" = "Forteni sciigojn de robotoj"; +"notification_settings_receive_a_call" = "Sciigu min pri ricevitaj vokoj"; +"notification_settings_people_join_leave_rooms" = "Sciigu min pri aliĝoj al aŭ foriroj de ĉambroj"; +"notification_settings_invite_to_a_new_room" = "Sciigu min pri invitoj al novaj ĉambroj"; +"notification_settings_just_sent_to_me" = "Sciigu min per sono pri mesaĝoj, kiuj sendiĝis al mi individue"; +"notification_settings_contain_my_display_name" = "Sciigu min per sono pri mesaĝoj, kiuj enhavas mian prezentan nomon"; +"notification_settings_contain_my_user_name" = "Sciigu min per sono pri mesaĝoj, kiuj enhavas mian uzantonomon"; +"notification_settings_other_alerts" = "Aliaj atentigoj"; +"notification_settings_select_room" = "Elekti ĉambron"; +"notification_settings_sender_hint" = "@uzanto:retnomo.net"; +"notification_settings_per_sender_notifications" = "Sendintulaj sciigoj"; +"notification_settings_per_room_notifications" = "Ĉambraj sciigoj"; +"notification_settings_custom_sound" = "Propra sono"; +"notification_settings_highlight" = "Emfazo"; +"notification_settings_word_to_match" = "akordaj vortoj"; +"notification_settings_never_notify" = "Neniam sciigi"; +"notification_settings_always_notify" = "Ĉiam sciigi"; +"notification_settings_per_word_info" = "Vortoj akordas sendepende de grandeco, kaj povas enhavi la ĵokeron *. Sekve:\nekzemplo akordas kun la tekstoĉeno «ekzemplo» ĉirkaŭita de vortlimiloj (ekz. interpunkcio kaj spaco aŭ komenco/fino de linio).\nekzemplo* akordas kun ĉiu tia vorto, kiu komenciĝas per «ekzemplo».\n*ekzemplo* akordas kun ĉiu tia vorto, kiu enhavas la sinsekvon de literoj «ekzemplo»."; +"notification_settings_per_word_notifications" = "Vortaj sciigoj"; +"notification_settings_global_info" = "Agordoj pri sciigoj estas konservitaj en via konto de uzanto kaj havigitaj al ĉiuj klientoj, kiuj ilin subtenas (inkluzive sciigojn labortablajn).\n\nReguloj aplikiĝas laŭorde; la unua regulo, kiu akordas, difinas la rezulton por la mesaĝo.\nSekve: vortaj sciigoj estas pli gravaj ol ĉambraj sciigoj, kiuj estas pli gravaj ol sendintulaj sciigoj.\nPor pluraj reguloj samspecaj, la unua akorda en la listo estas prioritata."; +"notification_settings_enable_notifications_warning" = "Ĉiuj sciigoj nun estas malŝaltitaj por ĉiuj aparatoj."; +"notification_settings_enable_notifications" = "Ŝalti sciigojn"; + +// Notification settings screen +"notification_settings_disable_all" = "Malŝalti ĉiujn sciigojn"; +"settings_title_notifications" = "Sciigoj"; + +// Settings screen +"settings_title_config" = "Agordaro"; + +// contacts list screen +"invitation_message" = "Mi volus babili kun vi per Matrix. Bonvolu viziti la retpaĝon http://matrix.org por pliaj informoj."; +"login_error_must_start_http" = "URL devas komenciĝi per http[s]://"; + +// Login Screen +"login_error_already_logged_in" = "Jam salutinta"; +"message_unsaved_changes" = "Restas nekonservitaj ŝanĝoj. Forlaso ilin forĵetos."; +"unban" = "Malforbari"; +"ban" = "Forbari"; +"kick" = "Forpeli"; +"num_members_other" = "%@ uzantoj"; +"num_members_one" = "%@ uzanto"; +"membership_ban" = "Forbarita"; +"membership_leave" = "Foririnta"; +"membership_invite" = "Invitita"; +"create_account" = "Krei konton"; +"login" = "Saluti"; +"create_room" = "Krei ĉambron"; + +// actions +"action_logout" = "Adiaŭi"; +"delete" = "Forigi"; +"share" = "Havigi"; +"redact" = "Forigi"; +"resend" = "Resendi"; +"copy_button_name" = "Kopii"; +"send" = "Sendi"; + +// Room Screen + +// general errors + +// Home Screen + +// Last seen time + +// call events + +/* -*- + Automatic localization for en + + The following key/value pairs were extracted from the android i18n file: + /console/src/main/res/values/strings.xml. +*/ + + +// titles + +// button names +"ok" = "Bone"; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "Vi videbligis estontan historion de la ĉambro al ĉiuj ĉambranoj, ekde ties aliĝo."; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "Vi videbligis estontajn mesaĝojn al ĉiuj ĉambranoj, ekde ties invito."; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "Vi videbligis estontan historion de la ĉambro al ĉiuj ĉambranoj, ekde ties invito."; +"notice_room_history_visible_to_members_by_you_for_dm" = "Vi videbligis estontajn mesaĝojn al ĉiuj ĉambranoj."; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "Vi videbligis estontajn mesaĝojn al ĉiuj ĉambranoj, ekde ties aliĝo."; +"notice_room_history_visible_to_members_by_you" = "Vi videbligis estontan historion de ĉambro al ĉiuj ĉambranoj."; +"notice_room_history_visible_to_anyone_by_you" = "Vi videbligis estontan historion de ĉambro al ĉiu ajn."; +"notice_encryption_enabled_unknown_algorithm_by_you" = "Vi ŝaltis tutvojan ĉifradon (nerekonita algoritmo %@)."; +"notice_encryption_enabled_ok_by_you" = "Vi ŝaltis tutvojan ĉifradon."; +"notice_room_created_by_you_for_dm" = "Vi aliĝis."; +"notice_room_created_by_you" = "Vi kreis kaj agordis la ĉambron."; +"notice_profile_change_redacted_by_you" = "Vi ĝisdatigis vian profilon %@"; +"notice_event_redacted_by_you" = " de vi"; +"notice_room_topic_removed_by_you" = "Vi forigis la temon"; +"notice_room_name_removed_by_you_for_dm" = "Vi forigis la nomon"; +"notice_room_name_removed_by_you" = "Vi forigis nomon de la ĉambro"; +"notice_conference_call_request_by_you" = "Vi petis grupan vokon"; +"notice_declined_video_call_by_you" = "Vi rifuzis la vokon"; +"notice_ended_video_call_by_you" = "Vi finis la vokon"; +"notice_answered_video_call_by_you" = "Vi respondis la vokon"; +"notice_placed_video_call_by_you" = "Vi ekigis vidvokon"; +"notice_placed_voice_call_by_you" = "Vi ekigis voĉvokon"; +"notice_room_name_changed_by_you_for_dm" = "Vi ŝanĝis la nomon al %@."; +"notice_room_name_changed_by_you" = "Vi ŝanĝis nomon de la ĉambro al %@."; +"notice_topic_changed_by_you" = "Vi ŝanĝis la temon al «%@»."; +"notice_display_name_removed_by_you" = "Vi forigis vian prezentan nomon"; +"notice_display_name_changed_from_by_you" = "Vi ŝanĝis vian prezentan nomon de %@ al %@"; +"notice_display_name_set_by_you" = "Vi agordis vian prezentan nomon al %@"; +"notice_avatar_url_changed_by_you" = "Vi ŝanĝis vian profilbildon"; +"notice_room_withdraw_by_you" = "Vi nuligis la inviton por %@"; +"notice_room_ban_by_you" = "Vi forbaris uzanton %@"; +"notice_room_unban_by_you" = "Vi malforbaris uzanton %@"; +"notice_room_kick_by_you" = "Vi forpelis uzanton %@"; +"notice_room_reject_by_you" = "Vi rifuzis la inviton"; +"notice_room_leave_by_you" = "Vi foriris"; +"notice_room_join_by_you" = "Vi aliĝis"; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "Vi nuligis la inviton por %@"; +"notice_room_third_party_revoked_invite_by_you" = "Vi nuligis la inviton al la ĉambro por %@"; +"notice_room_third_party_registered_invite_by_you" = "Vi akceptis la inviton por %@"; +"notice_room_third_party_invite_by_you_for_dm" = "Vi invitis uzanton %@"; +"notice_room_third_party_invite_by_you" = "Vi sendis inviton al la ĉambro al %@"; +"notice_room_invite_you" = "%@ invitis vin"; + +// Notice Events with "You" +"notice_room_invite_by_you" = "Vi invitis uzanton %@"; +"notice_conference_call_finished" = "Grupa voko finiĝis"; +"notice_conference_call_started" = "Grupa voko komenciĝis"; +"notice_conference_call_request" = "%@ petis grupan vokon"; +"notice_declined_video_call" = "%@ rifuzis la vokon"; +"notice_ended_video_call" = "%@ finis la vokon"; +"notice_answered_video_call" = "%@ respondis la vokon"; +"notice_placed_video_call" = "%@ ekigis vidvokon"; +"notice_placed_voice_call" = "%@ ekigis voĉvokon"; +"notice_room_name_changed_for_dm" = "%@ ŝanĝis la nomon al %@."; +"notice_room_name_changed" = "%@ ŝanĝis nomon de la ĉambro al %@."; +"notice_topic_changed" = "%@ ŝanĝis la temon al «%@»."; +"notice_display_name_removed" = "%@ forigis sian prezentan nomon"; +"notice_display_name_changed_from" = "%@ ŝanĝis sian prezentan nomon de %@ al %@"; +"notice_display_name_set" = "%@ ŝanĝis sian prezentan nomon al %@"; +"notice_avatar_url_changed" = "%@ ŝanĝis sian profilbildon"; +"notice_room_reason" = ". Kialo: %@"; +"notice_room_withdraw" = "%@ nuligis inviton por %@"; +"notice_room_ban" = "%@ forbaris uzanton %@"; +"notice_room_unban" = "%@ malforbaris uzanton %@"; +"notice_room_kick" = "%@ forpelis uzanton %@"; +"notice_room_reject" = "%@ rifuzis la inviton"; +"notice_room_leave" = "%@ foriris"; +"notice_room_join" = "%@ aliĝis"; +"notice_room_third_party_revoked_invite_for_dm" = "%@ nuligis la inviton por %@"; +"notice_room_third_party_revoked_invite" = "%@ nuligis la inviton al la ĉambro por %@"; +"notice_room_third_party_registered_invite" = "%@ akceptis la inviton por %@"; +"notice_room_third_party_invite_for_dm" = "%@ invitis uzanton %@"; +"notice_room_third_party_invite" = "%@ sendis inviton al la ĉambro al %@"; + +/* -*- + Automatic localization for en + + The following key/value pairs were extracted from the android i18n file: + /matrix-sdk/src/main/res/values/strings.xml. +*/ + +"notice_room_invite" = "%@ invitis uzanton %@"; +"language_picker_default_language" = "Implicita (%@)"; + +// Language picker +"language_picker_title" = "Elektu lingvon"; + +// Country picker +"country_picker_title" = "Elektu landon"; +"local_contacts_access_discovery_warning" = "Por trovi kontaktojn, kiuj jam uzas Matrix-on, %@ povas sendi retpoŝtadresojn kaj telefonnumerojn de via adresaro al via elektita identiga servilo de Matrix. Kiam eblas, personaj datumoj estas haketitaj antaŭ sendo – bonvolu kontroli la privatecan politikon de via identiga servilo por pliaj detaloj."; +"local_contacts_access_discovery_warning_title" = "Trovado de uzantoj"; +"local_contacts_access_not_granted" = "Trovado de uzantoj per lokaj kontaktoj postulas aliron al viaj kontaktoj, sed %@ nun ne rajtas ilin uzi"; +"microphone_access_not_granted_for_call" = "Vokoj postulas aliron al la mikrofono, sed %@ nun ne rajtas ĝin uzi"; + +// Permissions +"camera_access_not_granted_for_call" = "Vidvokoj postulas aliron al la filmilo, sed %@ nun ne rajtas ĝin uzi"; +"ssl_homeserver_url" = "URL de hejmservilo: %@"; +"user_id_placeholder" = "ekz. @kjara:hejmservilo"; +"network_error_not_reachable" = "Bonvolu kontroli vian retkonekton"; +"power_level" = "Povnivelo"; +"public" = "Publika"; +"private" = "Privata"; +"default" = "implicita"; +"not_supported_yet" = "Ankoraŭ ne subtenata"; +"error_common_message" = "Io eraris. Bonvolu reprovi poste."; +"error" = "Eraro"; +"unsent" = "Nesendita"; +"offline" = "eksterrete"; + +// Others +"user_id_title" = "Identigilo de uzanto:"; +"e2e_passphrase_create" = "Krei pasfrazon"; +"e2e_passphrase_not_match" = "Pasfrazoj devas akordi"; +"e2e_passphrase_empty" = "Pasfrazo maldevas esti malplena"; +"e2e_passphrase_confirm" = "Konfirmi pasfrazon"; +"e2e_export" = "Elporti"; +"e2e_export_prompt" = "Ĉi tiu procedo ebligas elporton de ŝlosiloj por mesaĝoj, kiujn vi ricevis en ĉifritaj ĉambroj, al loka dosiero. Poste vi povos enporti tiun dosieron en alian klienton de Matrix, por ke ankaŭ tiu kliento povu malĉifri la mesaĝojn.\nLa elportita dosiero ebligos legadon de videblaj ĉifritaj mesaĝoj al ĉiu, kiu povos ĝin legi; vi do provu ĝin teni en sekura loko."; + +// E2E export +"e2e_export_room_keys" = "Elporti ĉambrajn ŝlosilojn"; +"e2e_passphrase_enter" = "Enigi pasfrazon"; +"e2e_import" = "Enporti"; +"e2e_import_prompt" = "Ĉi tiu procedo ebligas enporti ĉifrajn ŝlosilojn, kiujn vi antaŭe elportis el alia kliento de Matrix. Poste vi povos malĉifri ĉiujn mesaĝojn, kiujn ankaŭ la alia kliento povis malĉifri.\nLa elportitan dosieron protektas pasfrazo. Vi enigu la pasfrazon ĉi tien, por malĉifri la dosieron."; + +// E2E import +"e2e_import_room_keys" = "Enporti ŝlosilojn de ĉambro"; +"format_time_d" = "j"; +"format_time_h" = "h"; +"format_time_m" = "m"; + +// Time +"format_time_s" = "s"; +"search_searching" = "Serĉo progresas…"; + +// Search +"search_no_results" = "Neniuj rezultoj"; +"group_section" = "Grupoj"; + +// Groups +"group_invite_section" = "Invitoj"; +"contact_local_contacts" = "Lokaj kontaktoj"; + +// Contacts +"contact_mx_users" = "Uzantoj de Matrix"; +"attachment_e2e_keys_import" = "Enporti…"; +"attachment_e2e_keys_file_prompt" = "Ĉi tiu dosiero enhavas ĉifrajn ŝlosilojn elportitajn el kliento de Matrix.\nĈu vi volas vidi enhavojn de la dosiero aŭ enporti la enhavatajn ŝlosilojn?"; +"attachment_multiselection_original" = "Originala grando"; +"attachment_multiselection_size_prompt" = "Ĉu vi volas sendi bildojn kiel:"; +"attachment_cancel_upload" = "Ĉu nuligi la alŝuton?"; +"attachment_cancel_download" = "Ĉu nuligi la elŝuton?"; +"attachment_large" = "Granda: %@"; +"attachment_medium" = "Meza: %@"; +"attachment_small" = "Malgranda: %@"; +"attachment_original" = "Originala: %@"; + +// Attachment +"attachment_size_prompt" = "Ĉu vi volas sendi en grando:"; +"room_member_power_level_prompt" = "Vi ne povos malfari ĉi tiun ŝanĝon, ĉar vi povigas la uzanton al la sama nivelo, kiun vi havas.\nĈu vi certas?"; + +// Room members +"room_member_ignore_prompt" = "Ĉu vi certe volas kaŝi ĉiujn mesaĝojn de tiu ĉi uzanto?"; +"message_reply_to_sender_sent_a_file" = "sendis dosieron."; +"message_reply_to_sender_sent_an_audio_file" = "sendis sondosieron."; +"message_reply_to_sender_sent_a_video" = "sendis filmon."; + +// Reply to message +"message_reply_to_sender_sent_an_image" = "sendis bildon."; +"room_no_conference_call_in_encrypted_rooms" = "Grupaj vokoj ne estas subtenataj en ĉifritaj ĉambroj"; +"room_no_power_to_create_conference_call" = "Vi bezonas permeson komenci grupan vokon en ĉi tiu ĉambro"; +"room_left_for_dm" = "Vi foriris"; +"room_left" = "Vi foriris de la ĉambro"; +"room_error_timeline_event_not_found" = "La aplikaĵo provis enlegi precizan punkton en la historio de ĉi tiu ĉambro, sed ne povis ĝin trovi"; +"room_error_timeline_event_not_found_title" = "Malsukcesis enlegi pozicion en historio"; +"room_error_cannot_load_timeline" = "Malsukcesis enlegi historion"; +"room_error_topic_edition_not_authorized" = "Vi ne rajtas redakti temon de ĉi tiu ĉambro"; +"room_error_name_edition_not_authorized" = "Vi ne rajtas redakti nomon de ĉi tiu ĉambro"; +"room_error_join_failed_empty_room" = "ANkoraŭ ne eblas ree aliĝi al malplena ĉambro."; +"room_error_join_failed_title" = "Malsukcesis aliĝi al ĉambro"; + +// Room +"room_please_select" = "Bonvolu elekti ĉambron"; +"room_creation_participants_placeholder" = "(ekz. @kjara:hejmservilo1; @megumi:hejmservilo2…)"; +"room_creation_participants_title" = "Anoj:"; +"room_creation_alias_placeholder_with_homeserver" = "(ekz. #io%@)"; +"room_creation_alias_placeholder" = "(ekz. #io:ekzemplo.net)"; +"room_creation_alias_title" = "Kromnomo de ĉambro:"; +"room_creation_name_placeholder" = "(ekz. tagmanghGrupo)"; + +// Room creation +"room_creation_name_title" = "Nomo de ĉambro:"; +"account_error_push_not_allowed" = "Sciigoj ne estas permesitaj"; +"account_error_msisdn_wrong_description" = "Ĉi tio ne ŝajnas esti valida telefonnumero"; +"account_error_msisdn_wrong_title" = "Nevalida telefonnumero"; +"account_error_email_wrong_description" = "Ĉi tio ne ŝajnas esti valida retpoŝtadreso"; +"account_error_email_wrong_title" = "Nevalida retpoŝtadreso"; +"account_error_matrix_session_is_not_opened" = "Salutaĵo de Matrix ne estas malfermita"; +"account_error_picture_change_failed" = "Malsukcesis ŝanĝo de bildo"; +"account_error_display_name_change_failed" = "Malsukcesis ŝanĝo de prezenta nomo"; +"account_msisdn_validation_error" = "Ne povas kontroli vian telefonnumeron."; +"account_msisdn_validation_message" = "Ni sendis telefonan mesaĝon kun aktiviga kodo. Bonvolu enigi la kodon ĉi-suben."; +"account_msisdn_validation_title" = "Atendanta kontrolo"; +"account_email_validation_error" = "Ne povas kontroli retpoŝtadreson. Bonvolu kontroli vian retpoŝton kaj klaki al la enhavata ligilo. Tion farinte, klaku al «daŭrigi»"; +"account_email_validation_message" = "Bonvolu kontroli vian retpoŝton kaj trovi la enhavatan ligilon. Farinte tion, klaku al «daŭrigi»."; +"account_email_validation_title" = "Atendanta kontrolo"; +"account_linked_emails" = "Alligitaj retpoŝtadresoj"; +"account_link_email" = "Ligi al retpoŝtadreso"; + +// Account +"account_save_changes" = "Konservi ŝanĝojn"; +"room_event_encryption_verify_ok" = "Kontroli"; +"room_event_encryption_info_unverify" = "Malkontroli"; +"room_event_encryption_info_verify" = "Kontroli…"; +"room_event_encryption_info_device_not_verified" = "NE kontrolita"; +"room_event_encryption_info_device_verified" = "Kontrolita"; +"room_event_encryption_info_device_verification" = "Kontrolo\n"; +"room_event_encryption_info_device_id" = "Identigilo\n"; +"room_event_encryption_info_device_name" = "Publika nomo\n"; +"room_event_encryption_info_device_unknown" = "nekonata salutaĵo\n"; +"room_event_encryption_info_device" = "\nInformoj pri salutaĵo de sendinto\n"; +"room_event_encryption_info_event_none" = "neniu"; +"room_event_encryption_info_event_unencrypted" = "neĉifrita"; +"room_event_encryption_info_event_decryption_error" = "Malĉifra eraro\n"; +"room_event_encryption_info_event_session_id" = "Identigilo de salutaĵo\n"; +"room_event_encryption_info_event_algorithm" = "Algoritmo\n"; +"room_event_encryption_info_event_identity_key" = "Identiga ŝlosilo je Curve25519\n"; +"room_event_encryption_info_event_user_id" = "Identigilo de uzanto\n"; +"room_event_encryption_info_event" = "Informoj pri okazo\n"; + +// Encryption information +"room_event_encryption_info_title" = "Informoj pri tutvoja ĉifrado\n\n"; +"device_details_delete_prompt_message" = "Ĉi tio postulas plian aŭtentikigon.\nPor daŭrigi, bonvolu enigi vian pasvorton."; +"device_details_delete_prompt_title" = "Aŭtentikigo"; +"device_details_rename_prompt_message" = "Publika nomo de salutaĵo estas videbla al ĉiu, kun kiu vi komunikas"; +"device_details_rename_prompt_title" = "Nomo de salutaĵo"; +"device_details_last_seen" = "Lastafoje vidita\n"; +"device_details_identifier" = "Identigilo\n"; +"device_details_name" = "Publika nomo\n"; + +// Devices +"device_details_title" = "Informoj pri salutaĵo\n"; +"notification_settings_room_rule_title" = "Ĉambro: «%@»"; +"settings_enter_validation_token_for" = "Enigi validigan pecon por %@:"; +"settings_enable_push_notifications" = "Ŝalti pasivajn sciigojn"; +"settings_enable_inapp_notifications" = "Ŝalti sciigojn en la aplikaĵo"; +"notice_sticker" = "glumarko"; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ videbligis estontajn mesaĝojn al ĉiuj, ekde ties aliĝo."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ videbligis estontan historion de ĉambro al ĉiuj ĉambranoj, ekde ties aliĝo."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ videbligis estontajn mesaĝojn al ĉiuj, ekde ties invito."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ videbligis estontan historion de ĉambro al ĉiuj ĉambranoj, ekde ties invito."; +"notice_room_history_visible_to_members_for_dm" = "%@ videbligis estontajn mesaĝojn al ĉiuj ĉambranoj."; +"notice_room_history_visible_to_members" = "%@ videbligis estontan historion de ĉambro al ĉiuj ĉambranoj."; +"notice_room_history_visible_to_anyone" = "%@ videbligis estontan historion de la ĉambro al ĉiu ajn."; +"notice_error_unknown_event_type" = "Nekonata speco de okazo"; +"notice_error_unexpected_event" = "Neatendita okazo"; +"notice_error_unsupported_event" = "Nesubtenata okazo"; +"notice_unsupported_attachment" = "Nesubtenata kunsendaĵo: %@"; +"notice_invalid_attachment" = "nevalida kunsendaĵo"; +"notice_file_attachment" = "dosiero kunsendita"; +"notice_location_attachment" = "loko kunsendita"; +"notice_video_attachment" = "filmo kunsendita"; +"notice_audio_attachment" = "sono kunsendita"; +"notice_image_attachment" = "bildo kunsendita"; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ ŝaltis tutvojan ĉifradon (nerekonita algoritmo %2$@)."; +"notice_encryption_enabled_ok" = "%@ ŝaltis tutvojan ĉifradon."; +"notice_encrypted_message" = "Ĉifrita mesaĝo"; +"notice_room_related_groups" = "Grupoj rilataj al ĉi tiu ĉambro estas: %@"; +"notice_room_aliases_for_dm" = "La kromnomoj estas: %@"; +"notice_room_aliases" = "Kromnomoj de la ĉamrbo estas: %@"; +"notice_room_power_level_event_requirement" = "La minimumaj povniveloj rilataj al okazoj estas:"; +"notice_room_power_level_acting_requirement" = "La minimuma povnivelo, kiun uzanto bezonas antaŭ agi, estas:"; +"notice_room_power_level_intro_for_dm" = "La povniveloj de ĉambranoj estas:"; +"notice_room_power_level_intro" = "La povniveloj de ĉambranoj estas:"; +"notice_room_join_rule_public_by_you_for_dm" = "Vi publikigis la individuan ĉambron."; +"notice_room_join_rule_public_by_you" = "Vi publikigis la ĉambron."; +"notice_room_join_rule_public_for_dm" = "%@ publikigis la individuan ĉambron."; +"notice_room_join_rule_public" = "%@ publikigis la ĉambron."; +"notice_room_join_rule_invite_by_you_for_dm" = "Vi ekpostulis inviton por aliĝoj."; +"notice_room_join_rule_invite_by_you" = "Vi ekpostulis inviton por aliĝoj."; +"notice_room_join_rule_invite_for_dm" = "%@ ekpostulis inviton por aliĝoj."; +// New +"notice_room_join_rule_invite" = "%@ ekpostulis inviton por aliĝoj."; +// Old +"notice_room_join_rule" = "La regulo de aliĝo estas: %@"; +"notice_room_created_for_dm" = "%@ aliĝis."; +"notice_room_created" = "%@ kreis kaj agordis la ĉambron."; +"notice_profile_change_redacted" = "%@ ĝisdatigis sian profilon %@"; +"notice_event_redacted_reason" = " [kialo: %@]"; +"notice_event_redacted_by" = " de %@"; +"notice_room_topic_removed" = "%@ forigis la temon"; +"notice_room_name_removed_for_dm" = "%@ forigis la nomon"; +"notice_room_name_removed" = "%@ forigis nomon de la ĉambro"; + +// Events formatter +"notice_avatar_changed_too" = "(ankaŭ profilbildo ŝanĝiĝis)"; +"unignore" = "Reatenti"; +"ignore" = "Malatenti"; +"resume_call" = "Daŭrigi"; +"end_call" = "Fini vokon"; +"reject_call" = "Rifuzi vokon"; +"answer_call" = "Respondi vokon"; +"show_details" = "Montri detalojn"; +"cancel_download" = "Nuligi elŝuton"; +"cancel_upload" = "Nuligi alŝuton"; +"select_all" = "Elekti ĉion"; +"resend_message" = "Resendi la mesaĝon"; +"reset_to_default" = "Restarigi implicitan"; +"invite_user" = "Inviton uzanton de Matrix"; +"capture_media" = "Foti/Filmi"; +"attach_media" = "Kunsendi vidaŭdaĵon el vidaŭdaĵujo"; +"select_account" = "Elekti konton"; +"mention" = "Mencii"; +"start_video_call" = "Komenci vidvokon"; +"start_voice_call" = "Komenci voĉvokon"; +"start_chat" = "Komenci babilon"; +"set_admin" = "Igi administranto"; +"set_moderator" = "Igi reguligisto"; +"set_default_power_level" = "Restarigi povnivelon"; +"set_power_level" = "Agordi povnivelon"; +"submit_code" = "Sendi kodon"; +"submit" = "Sendi"; +"sign_up" = "Registriĝi"; +"yes" = "Jes"; + +// Action +"no" = "Ne"; +"login_error_resource_limit_exceeded_contact_button" = "Kontakti administranton"; +"login_error_resource_limit_exceeded_message_contact" = "\n\nBonvolu kontakti la administranton de via servo por plu ĝin uzi."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Ĉi tiu hejmservilo atingis sian monatan limon de aktivaj uzantoj."; +"login_error_resource_limit_exceeded_message_default" = "Ĉi tiu hejmservilo atingis unu el siaj rimedaj limoj."; +"login_error_resource_limit_exceeded_title" = "Rimeda limo estas atingita"; +"login_error_forgot_password_is_not_supported" = "Forgesado de pasvorto nun ne estas subtenata"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/es.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/es.lproj/MatrixKit.strings new file mode 100644 index 000000000..b8d1695da --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/es.lproj/MatrixKit.strings @@ -0,0 +1,399 @@ +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "Crear cuenta:"; +"login_server_url_placeholder" = "URL (p.ej. https://matrix.org)"; +"login_home_server_title" = "Servidor Local:"; +"login_home_server_info" = "Tu servidor local almacena todas tus conversaciones y los datos de tu cuenta"; +"login_identity_server_title" = "URL de servidor de identidad:"; +"login_identity_server_info" = "Matrix proporciona servidores de identidades para rastrear qué correos electrónicos, etc. pertenecen a qué IDs de Matrix. Actualmente solo existe https://matrix.org."; +"login_user_id_placeholder" = "ID de Matrix (p.ej. @juan:matrix.org o juan)"; +"login_password_placeholder" = "Contraseña"; +"login_optional_field" = "opcional"; +"login_display_name_placeholder" = "Nombre público (p.ej. Juan Pérez)"; +"room_creation_participants_placeholder" = "(ej. @juan:servidordomestico1; @juan:servidordomestico2...)"; +"user_id_placeholder" = "ej: @juan:servidordomestico"; +"login_email_info" = "Especificar una dirección de correo electrónico permite que otros usuarios te encuentren en Matrix más fácilmente, y te dará una manera de restablecer tu contraseña en el futuro."; +"login_email_placeholder" = "Dirección de correo electrónico"; +"login_prompt_email_token" = "Por favor ingresa tu código de validación de correo electrónico:"; +"login_error_title" = "No se pudo iniciar sesión"; +"login_error_no_login_flow" = "No pudimos recuperar la información de autenticación de este Servidor Local"; +"login_error_do_not_support_login_flows" = "Actualmente no admitimos cualquiera o todos los flujos de inicio de sesión definidos por este Servidor Local"; +"login_error_registration_is_not_supported" = "Actualmente no es posible registrarse"; +"login_error_forbidden" = "Nombre de usuario/contraseña inválidos"; +"login_error_unknown_token" = "No se reconoció el código de acceso especificado"; +"login_error_bad_json" = "JSON invalido"; +"login_error_not_json" = "No contenía un JSON válido"; +"login_error_limit_exceeded" = "Se enviaron demasiadas solicitudes"; +"login_error_user_in_use" = "Este nombre de usuario ya está en uso"; +"login_error_login_email_not_yet" = "Aún no se ha abierto el enlace del correo electrónico"; +"login_use_fallback" = "Utilizar la página de respaldo"; +"login_leave_fallback" = "Cancelar"; +"login_invalid_param" = "Parámetro inválido"; +"register_error_title" = "Falló el Registro"; +"login_error_forgot_password_is_not_supported" = "Actualmente no es posible restablecer la contraseña"; +"login_mobile_device" = "Móvil"; +"login_tablet_device" = "Tableta"; +"login_desktop_device" = "Escritorio"; +"login_error_resource_limit_exceeded_title" = "Límite de Recursos Excedido"; +"login_error_resource_limit_exceeded_message_default" = "Este servidor local ha excedido uno de sus límites de recursos."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Este servidor local ha alcanzado su límite Mensual de Usuarios Activos."; +"login_error_resource_limit_exceeded_message_contact" = "\n\nPor favor, contacta al administrador de tu proveedor de servicio para continuar utilizando este servicio."; +"login_error_resource_limit_exceeded_contact_button" = "Contacta al Administrador"; +// Action +"no" = "No"; +"yes" = "Sí"; +"abort" = "Anular"; +"back" = "Atrás"; +"close" = "Cerrar"; +"continue" = "Continuar"; +"discard" = "Descartar"; +"dismiss" = "Ignorar"; +"retry" = "Re-intentar"; +"sign_up" = "Registrarse"; +"submit" = "Enviar"; +"submit_code" = "Enviar código"; +"set_default_power_level" = "Restablecer Nivel de Permisos"; +"set_moderator" = "Establecer Moderador"; +"set_admin" = "Establecer como Administrador"; +"start_chat" = "Iniciar Conversación"; +"start_voice_call" = "Iniciar Llamada de Voz"; +"start_video_call" = "Iniciar Llamada de Vídeo"; +"mention" = "Mencionar"; +"select_account" = "Seleccionar una cuenta"; +"attach_media" = "Adjuntar Medios desde Biblioteca"; +"capture_media" = "Tomar Foto/Vídeo"; +"invite_user" = "Invitar Usuario de Matrix"; +"reset_to_default" = "Restablecer valores por defecto"; +"resend_message" = "Reenviar el mensaje"; +"select_all" = "Seleccionar Todo"; +"cancel_upload" = "Cancelar Subida"; +"cancel_download" = "Cancelar Descarga"; +"show_details" = "Mostrar Detalles"; +"answer_call" = "Contestar Llamada"; +"reject_call" = "Rechazar Llamada"; +"end_call" = "Finalizar Llamada"; +"ignore" = "Ignorar"; +"unignore" = "Dejar de Ignorar"; +// Events formatter +"notice_avatar_changed_too" = "(el avatar también se cambió)"; +"notice_room_name_removed" = "%@ eliminó el nombre de la sala"; +"notice_room_topic_removed" = "%@ eliminó el tema"; +"notice_event_redacted" = ""; +"notice_event_redacted_by" = " por %@"; +"notice_event_redacted_reason" = " [motivo: %@]"; +"notice_profile_change_redacted" = "%@ actualizó su perfil %@"; +"notice_room_created" = "%@ ha creado y configurado la sala."; +"notice_room_join_rule" = "La regla para unirse es: %@"; +"notice_room_power_level_intro" = "El nivel de permisos de los miembros de la sala es:"; +"notice_room_power_level_acting_requirement" = "Los niveles de permisos mínimos que un usuario debe tener antes de actuar son:"; +"notice_room_power_level_event_requirement" = "Los niveles de permisos mínimos relacionados con eventos son:"; +"notice_room_aliases" = "Los aliases de la sala son: %@"; +"notice_room_related_groups" = "Los grupos asociados a esta sala son: %@"; +"notice_encrypted_message" = "Mensaje cifrado"; +"notice_encryption_enabled" = "%@ activó el cifrado de extremo a extremo (algoritmo %@)"; +"notice_image_attachment" = "imagen adjunta"; +"notice_audio_attachment" = "audio adjunto"; +"notice_video_attachment" = "vídeo adjunto"; +"notice_location_attachment" = "ubicación adjunta"; +"notice_file_attachment" = "archivo adjunto"; +"notice_invalid_attachment" = "archivo adjunto inválido"; +"notice_unsupported_attachment" = "No se admite el archivo adjunto: %@"; +"notice_feedback" = "Evento de retroalimentación (id: %@): %@"; +"notice_redaction" = "%@ redactó un evento (id: %@)"; +"notice_error_unsupported_event" = "No se admite el evento"; +"notice_error_unexpected_event" = "Evento inesperado"; +"notice_error_unknown_event_type" = "Tipo de evento desconocido"; +"notice_room_history_visible_to_anyone" = "%@ hizo visible el historial futuro de la sala para cualquier persona."; +"notice_room_history_visible_to_members" = "%@ hizo visible el historial futuro de la sala para todos los miembros de la sala."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ hizo visible el historial futuro de la sala para todos los miembros de la sala, desde el momento en que son invitados."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ hizo visible el historial futuro de la sala para todos los miembros de la sala, desde el momento en que se unieron."; +"notice_crypto_unable_to_decrypt" = "** No es posible descifrar: %@ **"; +"notice_crypto_error_unknown_inbound_session_id" = "La sesión emisor no nos ha enviado las claves para este mensaje."; +"notice_sticker" = "pegatina"; +"notice_in_reply_to" = "En respuesta a"; +// room display name +"room_displayname_empty_room" = "Sala vacía"; +"room_displayname_two_members" = "%@ y %@"; +"room_displayname_more_than_two_members" = "%@ y otros %u"; +// Settings +"settings" = "Ajustes"; +"settings_enable_inapp_notifications" = "Habilitar notificaciones de la aplicación"; +"settings_enable_push_notifications" = "Habilitar notificaciones push"; +"settings_enter_validation_token_for" = "Ingresar el código de validación para %@:"; +"notification_settings_room_rule_title" = "Sala: '%@'"; +// Devices +"device_details_title" = "Información de sesión\n"; +"device_details_name" = "Nombre público\n"; +"device_details_identifier" = "ID\n"; +"device_details_last_seen" = "Visto por última vez\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_rename_prompt_message" = "Un nombre público de una sesión es invisible para personas con que Ud. se comunica"; +"device_details_delete_prompt_title" = "Autenticación"; +"device_details_delete_prompt_message" = "Esta operación requiere autenticación adicional.\nPara continuar, ingresa tu contraseña por favor."; +// Encryption information +"room_event_encryption_info_title" = "Información de cifrado de extremo a extremo\n\n"; +"room_event_encryption_info_event" = "Información de evento\n"; +"room_event_encryption_info_event_user_id" = "ID de Usuario\n"; +"room_event_encryption_info_event_identity_key" = "Clave de identidad Curve25519\n"; +"room_event_encryption_info_event_fingerprint_key" = "Clave de huella digital Ed25519 reclamada\n"; +"room_event_encryption_info_event_algorithm" = "Algoritmo\n"; +"room_event_encryption_info_event_session_id" = "ID de Sesión\n"; +"room_event_encryption_info_event_decryption_error" = "Error de descifrado\n"; +"room_event_encryption_info_event_unencrypted" = "sin cifrar"; +"room_event_encryption_info_event_none" = "ninguno"; +"room_event_encryption_info_device" = "\nInformación de la sesión emisora\n"; +"room_event_encryption_info_device_unknown" = "sesión desconocida\n"; +"room_event_encryption_info_device_name" = "Nombre público\n"; +"room_event_encryption_info_device_id" = "ID\n"; +"room_event_encryption_info_device_verification" = "Verificación\n"; +"room_event_encryption_info_device_fingerprint" = "huella digital Ed25519\n"; +"room_event_encryption_info_device_verified" = "Verificado"; +"room_event_encryption_info_device_not_verified" = "SIN verificar"; +"room_event_encryption_info_device_blocked" = "Prohibido"; +"room_event_encryption_info_verify" = "Verificar..."; +"room_event_encryption_info_unverify" = "Anular Verificación"; +"room_event_encryption_info_block" = "Prohibir"; +"room_event_encryption_info_unblock" = "Dejar de Prohibir"; +"room_event_encryption_verify_title" = "Verificar sesión\n\n"; +"room_event_encryption_verify_message" = "Para verificar que este sesión es confiable, por favor contactar a su propietario por algún otro medio (ej. cara a cara o por teléfono) y pregúntale si la clave que ve en sus Ajustes de Usuario para este dispositivo coincide con la clave a continuación:\n\n\tNombre de sesión: %@\n\tID de sesión: %@\n\tClave de sesión: %@\n\nSi coincide, oprime el botón de verificar a continuación. Si no coincide, entonces alguien está interceptando este sesión y probablemente prefieras oprimir el botón de prohibir.\n\nEn el futuro, este proceso de verificación será más sofisticado."; +"room_event_encryption_verify_ok" = "Verificar"; +// Account +"account_save_changes" = "Guardar cambios"; +"account_link_email" = "Añadir Correo Electrónico"; +"account_linked_emails" = "Correos electrónicos añadidos"; +"account_email_validation_title" = "Verificación Pendiente"; +"account_email_validation_message" = "Por favor, consulta tu correo electrónico y haz clic en el enlace que contiene. Una vez hecho esto, haz clic en continuar."; +"account_email_validation_error" = "No es posible verificar la dirección de correo electrónico. Por favor, consulta tu correo electrónico y haz clic en el enlace que contiene. Una vez hecho esto, haz clic en continuar"; +"account_msisdn_validation_title" = "Verificación Pendiente"; +"account_msisdn_validation_message" = "Hemos enviado un SMS con un código de activación. Por favor, ingresa este código a continuación."; +"account_msisdn_validation_error" = "No es posible verificar el número telefónico."; +"account_error_display_name_change_failed" = "El cambio de nombre público falló"; +"account_error_picture_change_failed" = "El cambio de imagen falló"; +"account_error_matrix_session_is_not_opened" = "La sesión de Matrix no está abierta"; +"account_error_email_wrong_title" = "Dirección de Correo Electrónico Inválida"; +"account_error_email_wrong_description" = "Esto no parece ser una dirección de correo electrónico válida"; +"account_error_msisdn_wrong_title" = "Número Telefónico Inválido"; +"account_error_msisdn_wrong_description" = "Esto no parece ser un número telefónico válido"; +// Room creation +"room_creation_name_title" = "Nombre de sala:"; +"room_creation_name_placeholder" = "(ej. grupoDeAlmuerzo)"; +"room_creation_alias_title" = "Alias de sala:"; +"room_creation_alias_placeholder" = "(ej. #foo:ejemplo.org)"; +"room_creation_alias_placeholder_with_homeserver" = "(ej. #foo%@)"; +"room_creation_participants_title" = "Participantes:"; +// Room +"room_please_select" = "Por favor selecciona una sala"; +"room_error_join_failed_title" = "No se pudo unir a la sala"; +"room_error_join_failed_empty_room" = "Ahora mismo no es posible volver a unirse a una sala vacía."; +"room_error_name_edition_not_authorized" = "No estás autorizado a editar el nombre de esta sala"; +"room_error_topic_edition_not_authorized" = "No estás autorizado a editar el tema de esta sala"; +"room_error_cannot_load_timeline" = "No se pudo cargar la línea de tiempo"; +"room_error_timeline_event_not_found_title" = "No se pudo cargar la posición en la línea de tiempo"; +"room_error_timeline_event_not_found" = "La aplicación estaba intentando cargar un momento específico en la línea de tiempo de esta sala pero pudo encontrarlo"; +"room_left" = "Saliste de la sala"; +"room_no_power_to_create_conference_call" = "Necesitas permiso para invitar a iniciar una conferencia en esta sala"; +"room_no_conference_call_in_encrypted_rooms" = "No se admiten llamadas de conferencia en salas cifradas"; +// Reply to message +"message_reply_to_sender_sent_an_image" = "envió una imagen."; +"message_reply_to_sender_sent_a_video" = "envió un vídeo."; +"message_reply_to_sender_sent_an_audio_file" = "envió un archivo de audio."; +"message_reply_to_sender_sent_a_file" = "envió un archivo."; +"message_reply_to_message_to_reply_to_prefix" = "En respuesta a"; +// Room members +"room_member_ignore_prompt" = "¿Seguro que quieres ocultar todos los mensajes de este usuario?"; +"room_member_power_level_prompt" = "No podrás deshacer este cambio porque estás promoviendo al usuario para tener el mismo nivel de autoridad que tú.\n¿Estás seguro?"; +// Attachment +"attachment_size_prompt" = "Quieres enviar como:"; +"attachment_original" = "Tamaño real: %@"; +"attachment_small" = "Pequeño (~%@)"; +"attachment_medium" = "Mediano (~%@)"; +"attachment_large" = "Grande (~%@)"; +"attachment_cancel_download" = "¿Cancelar la descarga?"; +"attachment_cancel_upload" = "¿Cancelar la subida?"; +"attachment_multiselection_size_prompt" = "Quieres enviar imágenes como:"; +"attachment_multiselection_original" = "Tamaño Real"; +"attachment_e2e_keys_file_prompt" = "Este archivo contiene claves de cifrado exportadas de un cliente de Matrix.\n¿Quieres ver el contenido del archivo o importar las claves que contiene?"; +"attachment_e2e_keys_import" = "Importar..."; +// Contacts +"contact_mx_users" = "Usuarios de Matrix"; +"contact_local_contacts" = "Contactos Locales"; +// Groups +"group_invite_section" = "Invitaciones"; +"group_section" = "Grupos"; +// Search +"search_no_results" = "No Hay Resultados"; +"search_searching" = "Búsqueda en curso..."; +// Time +"format_time_s" = "s"; +"format_time_m" = "m"; +"format_time_h" = "h"; +"format_time_d" = "d"; +// E2E import +"e2e_import_room_keys" = "Importar claves de sala"; +"e2e_import_prompt" = "Este proceso permite que importes claves de cifrado que hayas exportado previamente desde otro cliente de Matrix. Luego, podrás descifrar todos los mensajes que el otro cliente podía descifrar.\nEl archivo de exportación está protegido con una frase de contraseña. Debes ingresar la frase de contraseña aquí para descifrar el archivo."; +"e2e_import" = "Importar"; +"e2e_passphrase_enter" = "Ingresar frase de contraseña"; +// E2E export +"e2e_export_room_keys" = "Exportar claves de sala"; +"e2e_export_prompt" = "Este proceso te permite exportar las claves de los mensajes que hayas recibido en salas cifradas a un archivo local. Luego, podrás importar el archivo a otro cliente de Matrix, para que ese cliente también pueda descifrar estos mensajes.\nEl archivo exportado permitirá que cualquier persona que pueda leerlo descifre todos los mensajes cifrados que tú puedas ver, así que debes tener cuidado de mantenerlo seguro."; +"e2e_export" = "Exportar"; +"e2e_passphrase_confirm" = "Confirmar frase de contraseña"; +"e2e_passphrase_empty" = "La frase de contraseña no debe estar vacía"; +"e2e_passphrase_not_match" = "Las frases de contraseña deben coincidir"; +// Others +"user_id_title" = "ID de Usuario:"; +"offline" = "desconectado"; +"unsent" = "No enviado"; +"error" = "Error"; +"error_common_message" = "Ocurrió un error. Por favor inténtalo de nuevo más tarde."; +"not_supported_yet" = "Aún no es posible"; +"default" = "por defecto"; +"private" = "Privado"; +"public" = "Público"; +"power_level" = "Nivel de Autoridad"; +"network_error_not_reachable" = "Por favor comprueba la conectividad de tu red"; +"ssl_homeserver_url" = "URL del Servidor Local: %@"; +// Permissions +"camera_access_not_granted_for_call" = "Las llamadas de vídeo requieren acceso a la Cámara pero %@ no tiene permiso para utilizarla"; +"microphone_access_not_granted_for_call" = "Las llamadas requieren acceso al Micrófono pero %@ no tiene permiso para utilizarlo"; +"local_contacts_access_not_granted" = "El descubrimiento de usuarios desde los contactos locales requiere acceso a tus contactos pero %@ no tiene permiso para utilizarlo"; +"local_contacts_access_discovery_warning_title" = "Descubrimiento de usuarios"; +"local_contacts_access_discovery_warning" = "Para descubrir contactos que ya usan Matrix, %@ puede enviar correos electrónicos y números telefónicos desde tus Contactos hacia el Servidor de Identidades de Matrix. Cuando es posible, los datos personales se procesan antes de enviarlos. Por favor, compruebe la política de privacidad de su servidor de identidad para más detalles."; +// Country picker +"country_picker_title" = "Elige un país"; +// Language picker +"language_picker_title" = "Elige un idioma"; +"language_picker_default_language" = "Por Defecto (%@)"; +"notice_room_invite" = "%@ invitó a %@"; +"notice_room_third_party_invite" = "%@ invitó a %@ a unirse a la sala"; +"notice_room_third_party_registered_invite" = "%@ aceptó la invitación para %@"; +"notice_room_join" = "%@ se unió"; +"notice_room_leave" = "%@ salió"; +"notice_room_reject" = "%@ rechazó la invitación"; +"notice_room_kick" = "%@ expulsó a %@"; +"notice_room_unban" = "%@ le quitó el veto a %@"; +"notice_room_ban" = "%@ vetó a %@"; +"notice_room_withdraw" = "%@ retiró la invitación de %@"; +"notice_room_reason" = ". Motivo: %@"; +"notice_avatar_url_changed" = "%@ cambió su avatar"; +"notice_display_name_set" = "%@ estableció %@ como su nombre público"; +"notice_display_name_changed_from" = "%@ cambió su nombre público de %@ a %@"; +"notice_display_name_removed" = "%@ eliminó su nombre público"; +"notice_topic_changed" = "%@ cambió el tema a: %@"; +"notice_room_name_changed" = "%@ cambió el nombre de la sala a: %@"; +"notice_placed_voice_call" = "%@ realizó una llamada de voz"; +"notice_answered_video_call" = "%@ contestó la llamada"; +"notice_ended_video_call" = "%@ finalizó la llamada"; +"notice_conference_call_request" = "%@ solicitó una conferencia de vozIP"; +"notice_conference_call_started" = "conferencia de vozIP iniciada"; +"notice_conference_call_finished" = "conferencia de vozIP finalizada"; +// button names +"ok" = "Correcto"; +"cancel" = "Cancelar"; +"save" = "Guardar"; +"leave" = "Salir"; +"send" = "Enviar"; +"copy_button_name" = "Copiar"; +"resend" = "Reenviar"; +"redact" = "Eliminar"; +"share" = "Compartir"; +"set_power_level" = "Establecer nivel de permisos"; +"delete" = "Eliminar"; +"view" = "Ver"; +// actions +"action_logout" = "Cerrar Sesión"; +"create_room" = "Crear Sala"; +"login" = "Iniciar Sesión"; +"create_account" = "Crear Cuenta"; +"membership_invite" = "Invitado"; +"membership_leave" = "Salió"; +"membership_ban" = "Vetado"; +"num_members_one" = "%@ usuario"; +"num_members_other" = "%@ usuarios"; +"invite" = "Invitar"; +"kick" = "Echar"; +"ban" = "Vetar"; +"unban" = "Quitar Veto"; +"message_unsaved_changes" = "Hay cambios sin guardar. Salir los descartará."; +// Login Screen +"login_error_already_logged_in" = "Ya ha iniciado sesión"; +"login_error_must_start_http" = "La URL debe comenzar con http[s]://"; +// room details dialog screen +"room_details_title" = "Detalles de Sala"; +// contacts list screen +"invitation_message" = "Me gustaría chatear contigo vía Matrix. Por favor, visita la página http://matrix.org para obtener más información."; +// Settings screen +"settings_title_config" = "Ajustes"; +"settings_title_notifications" = "Notificaciones"; +// Notification settings screen +"notification_settings_disable_all" = "Deshabilitar todas las notificaciones"; +"notification_settings_enable_notifications" = "Habilitar notificaciones"; +"notification_settings_enable_notifications_warning" = "Actualmente, todas las notificaciones están deshabilitadas para todos los dispositivos."; +"notification_settings_global_info" = "Los ajustes de notificaciones se guardan en tu cuenta de usuario y se comparten entre todos los clientes que las admiten (incluyendo las notificaciones de escritorio).\n\nLas reglas se aplican en órden; la primer regla que coincide define el resultado del mensaje.\nEntonces: las notificaciones por palabra son más importantes que las notificaciones por sala, que son más importantes que las notificaciones por emisor.\nPara múltiples reglas del mismo tipo, la primera en la lista que coincide tiene prioridad."; +"notification_settings_per_word_notifications" = "Notificaciones por palabra"; +"notification_settings_per_word_info" = "Las palabras coinciden con mayúsculas y minúsculas, y pueden incluir un * comodín. Entonces:\nfoo coincide con la cadena de caracteres foo rodeada de delimitadores de palabras (ej. puntuación y espacios en blanco o inicios/finales de línea).\nfoo* coincide con cualquier palabra que comience con foo.\n*foo* coincide con cualquier palabra que incluya las 3 letras foo."; +"notification_settings_always_notify" = "Siempre notificar"; +"notification_settings_never_notify" = "Nunca notificar"; +"notification_settings_word_to_match" = "palabra que coincida"; +"notification_settings_highlight" = "Destacar"; +"notification_settings_custom_sound" = "Sonido personalizado"; +"notification_settings_per_room_notifications" = "Notificaciones por sala"; +"notification_settings_per_sender_notifications" = "Notificaciones por emisor"; +"notification_settings_sender_hint" = "@usuario:dominio.com"; +"notification_settings_select_room" = "Selecciona una sala"; +"notification_settings_other_alerts" = "Otras Alertas"; +"notification_settings_contain_my_user_name" = "Notificarme con sonido por mensajes que contienen mi nombre de usuario"; +"notification_settings_contain_my_display_name" = "Notificarme con sonido por mensajes que contienen mi nombre público"; +"notification_settings_just_sent_to_me" = "Notificarme con sonido por mensajes enviados solo a mí"; +"notification_settings_invite_to_a_new_room" = "Notificarme cuando soy invitado a una nueva sala"; +"notification_settings_people_join_leave_rooms" = "Notificarme cuando las personas se unen o salen de las salas"; +"notification_settings_receive_a_call" = "Notificarme cuando reciba una llamada"; +"notification_settings_suppress_from_bots" = "Suprimir notificaciones de bots"; +"notification_settings_by_default" = "Por defecto..."; +"notification_settings_notify_all_other" = "Notificar por todos los demás mensajes/salas"; +// gcm section +"settings_config_home_server" = "Servidor local: %@"; +"settings_config_identity_server" = "Servidor de identidad: %@"; +"settings_config_user_id" = "ID de Usuario: %@"; +// call string +"call_waiting" = "Esperando..."; +"call_connecting" = "Conectando llamada..."; +"call_ended" = "Llamada finalizada"; +"call_ring" = "Llamando..."; +"incoming_video_call" = "Llamada de Vídeo Entrante"; +"incoming_voice_call" = "Llamada de Voz Entrante"; +"call_invite_expired" = "Expiró la Invitación a Llamada"; +// unrecognized SSL certificate +"ssl_trust" = "Confiar"; +"ssl_logout_account" = "Cerrar Sesión"; +"ssl_remain_offline" = "Ignorar"; +"ssl_fingerprint_hash" = "Huella Digital (%@):"; +"ssl_could_not_verify" = "No se pudo verificar la identidad del servidor remoto."; +"ssl_cert_not_trust" = "Esto podría significar que alguien está interceptando tu tráfico maliciosamente, o que tu teléfono no confía en el certificado proporcionado por el servidor remoto."; +"ssl_cert_new_account_expl" = "Si el administrador del servidor dijo que esto es de esperarse, asegúrate que la huella digital que se muestra a continuación coincide con la huella digital proporcionada por el administrador."; +"ssl_unexpected_existing_expl" = "El certificado cambió de uno que era confiable para tu teléfono. Esto es MUY INUSUAL. Se recomienda NO ACEPTAR este nuevo certificado."; +"ssl_expected_existing_expl" = "El certificado cambió de uno que era confiable a uno que no es confiable. El servidor puede haber renovado su certificado. Contacta al administrador del servidor para obtener la huella digital."; +"ssl_only_accept" = "SOLO acepta el certificado si el administrador del servidor ha publicado una huella digital que coincide con la indicada arriba."; +"notice_placed_video_call" = "%@ realizó una llamada de vídeo"; +"e2e_passphrase_create" = "Crear contraseña"; +"notice_encryption_enabled_ok" = "%@ activó encriptación de extremo a extremo"; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ activó encriptación de extremo a extremo (algoritmo %2$@ desconocido)."; +"device_details_rename_prompt_title" = "Nombre de Sesión"; +"account_error_push_not_allowed" = "No se permite notificaciones"; +"notice_room_third_party_revoked_invite" = "%@ revocó la invitación para %@ de unirse a la sala"; +"attachment_size_prompt_message" = "Puedes desactivar esto en ajustes."; +"attachment_size_prompt_title" = "Confirma el tamaño para enviar"; +"message_reply_to_sender_sent_a_voice_message" = "ha enviado un mensaje de voz."; +"room_left_for_dm" = "Te has salido"; +"room_displayname_all_other_members_left" = "%@ (ha salido)"; +"notice_room_join_rule_public_by_you_for_dm" = "Has hecho esta conversación pública."; +"notice_room_join_rule_public_by_you" = "Has hecho la sala pública."; +"notice_room_join_rule_public_for_dm" = "%@ ha hecho esta conversación pública."; +"notice_room_join_rule_public" = "%@ ha hecho pública la sala."; +"notice_room_join_rule_invite_by_you" = "Has hecho que solo se pueda unir a la sala por invitación."; +"notice_room_name_removed_for_dm" = "%@ ha quitado el nombre"; +"notice_room_created_for_dm" = "%@ se ha unido."; +"notice_room_join_rule_invite_for_dm" = "%@ ha hecho que solo sea posible unirse por invitación."; +// New +"notice_room_join_rule_invite" = "%@ ha hecho que la sala solo sea accesible por invitación."; +"resume_call" = "Volver a la llamada"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/et.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/et.lproj/MatrixKit.strings new file mode 100644 index 000000000..017cbbe88 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/et.lproj/MatrixKit.strings @@ -0,0 +1,478 @@ +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "Loo kasutajakonto:"; +"login_server_url_placeholder" = "URL (näiteks https://matrix.org)"; +"login_home_server_title" = "Koduserveri aadress:"; +"login_home_server_info" = "Sinu koduserver salvestab kõik vestlused ning kogu sinu kontoteabe"; +"login_identity_server_title" = "Isikutuvastusserveri aadress:"; +"login_password_placeholder" = "Salasõna"; +"login_optional_field" = "kui soovid"; +"login_display_name_placeholder" = "Kuvatav nimi (näiteks Kadri Maasikas)"; +"login_email_placeholder" = "E-posti aadress"; +"back" = "Tagasi"; +"close" = "Sulge"; +"continue" = "Jätka"; +"retry" = "Proovi uuesti"; +// Settings +"settings" = "Seadistused"; +"settings_enable_inapp_notifications" = "Võta kasutusele rakenduse-sisesed teavitused"; +"settings_enable_push_notifications" = "Võta kasutusele tõuketeavitused"; +"notification_settings_room_rule_title" = "Jututuba: '%@'"; +// Devices +"device_details_title" = "Sessiooniteave\n"; +"device_details_name" = "Avalik nimi\n"; +"device_details_identifier" = "Tunnus\n"; +"device_details_last_seen" = "Viimati nähtud\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_rename_prompt_title" = "Sessiooni nimi"; +"device_details_rename_prompt_message" = "Sessiooni avalik nimi on nähtav neile, kellega sa suhtled"; +"device_details_delete_prompt_title" = "Autentimine"; +"account_error_email_wrong_title" = "Vigane e-posti aadress"; +"account_error_email_wrong_description" = "See ei tundu olema e-posti aadressi moodi"; +"account_error_msisdn_wrong_title" = "Vigane telefoninumber"; +"account_error_msisdn_wrong_description" = "See ei tundu olema telefoninumbri moodi"; +"account_error_push_not_allowed" = "Teavitused ei ole lubatud"; +// Room creation +"room_creation_name_title" = "Jututoa nimi:"; +"room_member_power_level_prompt" = "Sa ei saa seda muudatust hiljem tagasi pöörata, sest annad teisele kasutajale samad õigused, mis sinul on.\nKas sa oled ikka kindel?"; +// Attachment +"attachment_size_prompt" = "Kas sa soovid faili saata:"; +"attachment_original" = "Tegelikus suuruses (%@)"; +"attachment_small" = "Väiksena (%@)"; +"attachment_medium" = "Keskmisena (%@)"; +"attachment_large" = "Suurena (%@)"; +"attachment_cancel_download" = "Kas katkestame allalaadimise?"; +"attachment_cancel_upload" = "Kas katkestame üleslaadimise?"; +"attachment_multiselection_size_prompt" = "Kas sa soovid pilte saata:"; +"attachment_multiselection_original" = "Tegelikus suuruses"; +"attachment_e2e_keys_file_prompt" = "Selles failis leiduvad ühest Matrix'i kliendist eksporditud krüptovõtmed.\nKas sa soovid vaadata faili sisu või importida seal leiduvad võtmeid?"; +"attachment_e2e_keys_import" = "Impordi..."; +// Contacts +"contact_mx_users" = "Matrix'i kasutajad"; +"contact_local_contacts" = "Kohalikud kasutajad"; +// Groups +"group_invite_section" = "Kutsed"; +"group_section" = "Grupid"; +// Search +"search_no_results" = "Tulemusi ei ole"; +"search_searching" = "Otsing on pooleli..."; +// Time +"format_time_s" = "s"; +"format_time_m" = "m"; +"format_time_h" = "t"; +"format_time_d" = "p"; +// E2E import +"e2e_import_room_keys" = "Impordi jututoa krüptovõtmed"; +"error_common_message" = "Ilmnes viga. Palun proovi hiljem uuesti."; +"not_supported_yet" = "Pole veel toetatud"; +"default" = "vaikimisi"; +"public" = "Avalik"; +"cancel" = "Loobu"; +"save" = "Salvesta"; +"leave" = "Lahku"; +"resend" = "Saada uuesti"; +"redact" = "Eemalda"; +"share" = "Jaga"; +"delete" = "Kustuta"; +"view" = "Näita"; +// actions +"action_logout" = "Logi välja"; +"create_room" = "Loo jututuba"; +"login" = "Logi sisse"; +"create_account" = "Loo konto"; +"membership_invite" = "Kutsutud"; +"num_members_one" = "%@ kasutaja"; +"num_members_other" = "%@ kasutajat"; +"invite" = "Kutsu"; +"kick" = "Müksa välja"; +"unban" = "Eemalda suhtluskeeld"; +"login_error_must_start_http" = "Serveri aadressi alguses peab olema http[s]://"; +// room details dialog screen +"room_details_title" = "Jututoa üksikasjad"; +"settings_title_notifications" = "Teavitused"; +// Notification settings screen +"notification_settings_disable_all" = "Lülita kõik teavitused välja"; +"notification_settings_enable_notifications" = "Võta teavitused kasutusele"; +"notification_settings_enable_notifications_warning" = "Kõik teavituste liigid on hetkel kõikidel seadmetel välja lülitatud."; +"login_identity_server_info" = "Matrix'i spetsifikatsioon näeb ette isikutuvastusserverite kasutamist selleks, et tuvastada mis e-posti aadress kuulub mis Matrix'i kasutajale. Hetkel pakub sellist teenust vaid https://matrix.org ."; +"login_user_id_placeholder" = "Matrixi kasutajatunnus (näiteks @kadri:toredomeen.ee või kadri)"; +"login_error_title" = "Sisselogimine ei õnnestunud"; +"login_error_no_login_flow" = "Autentimisteabe laadimine sellest koduserverist ei õnnestunud"; +"login_error_do_not_support_login_flows" = "Hetkel me ei toeta ühtegi sisselogimisloogikat, mida see koduserver kasutab"; +"login_error_registration_is_not_supported" = "Registreerimine ei ole hetkel toetatud"; +"login_error_forbidden" = "Vigane kasutajanimi või salasõna"; +"login_error_bad_json" = "Vigane JSON"; +"login_error_not_json" = "Ei sisaldanud korrektset JSON'it"; +"login_error_limit_exceeded" = "Liiga palju samaaegseid sisselogimispäringuid"; +"login_error_user_in_use" = "See kasutajanimi on juba kasutusel"; +"login_use_fallback" = "Kasuta tagavaralehte"; +"login_leave_fallback" = "Loobu"; +"login_invalid_param" = "Vigane parameeter"; +"register_error_title" = "Registreerimine ei õnnestunud"; +"login_error_forgot_password_is_not_supported" = "Unustatud salasõna funktsionaalsus ei ole hetkel toetatud"; +"login_mobile_device" = "Mobiiltelefon"; +"login_tablet_device" = "Tahvelarvuti"; +"login_desktop_device" = "Töölaud"; +"login_error_resource_limit_exceeded_title" = "Ressursipiir on ületatud"; +"login_error_resource_limit_exceeded_message_default" = "See koduserver ületanud ühe oma ressursipiirangutest."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "See koduserver on saavutanud igakuise aktiivsete kasutajate piiri."; +"login_error_resource_limit_exceeded_message_contact" = "\n\nJätkamaks selle teenuse kasutamist palun võta ühendust oma teenuse haldajaga."; +"login_error_resource_limit_exceeded_contact_button" = "Võta ühendust teenuse haldajaga"; +// Action +"no" = "Ei"; +"yes" = "Jah"; +"abort" = "Katkesta"; +"discard" = "Loobu"; +"dismiss" = "Loobu"; +"sign_up" = "Registreeru"; +"submit" = "Saada"; +"submit_code" = "Saada kood"; +"set_power_level" = "Määra õigused"; +"set_default_power_level" = "Lähtesta õigused"; +"set_moderator" = "Määra moderaatoriks"; +"set_admin" = "Määra peakasutajaks"; +"start_chat" = "Alusta vestlust"; +"start_voice_call" = "Algata häälkõne"; +"start_video_call" = "Algata videokõne"; +"mention" = "Maini"; +"select_account" = "Vali kasutajakonto"; +"attach_media" = "Manusta meediafail galeriist"; +"capture_media" = "Tee foto või video"; +"invite_user" = "Saada kutse Matrix'i kasutajale"; +"reset_to_default" = "Lähtesta vaikeseadeteks"; +"resend_message" = "Saada sõnum uuesti"; +"select_all" = "Vali kõik"; +"cancel_upload" = "Katkesta üleslaadimine"; +"cancel_download" = "Katkesta allalaadimine"; +"show_details" = "Näita üksikasju"; +"answer_call" = "Vasta kõnele"; +"reject_call" = "Ära võta kõnet vastu"; +"end_call" = "Lõpeta kõne"; +"ignore" = "Eira"; +"unignore" = "Lõpeta eiramine"; +// Events formatter +"notice_avatar_changed_too" = "(samuti sai avatar muudetud)"; +"notice_room_name_removed" = "%@ eemaldas jututoa nime"; +"notice_room_topic_removed" = "%@ eemaldas jututoa teema"; +"notice_event_redacted" = ""; +"notice_event_redacted_by" = " %@ poolt"; +"notice_event_redacted_reason" = " [põhjus: %@]"; +"notice_profile_change_redacted" = "%@ uuendas oma profiili %@"; +"notice_room_created" = "Kasutaja %@ lõi ja seadistas jututoa."; +"notice_room_join_rule" = "Liitumise tingimused on: %@"; +"notice_room_power_level_intro" = "Jututoa liikmete õigused on:"; +"notice_room_power_level_acting_requirement" = "Minimaalselt vajalikud õigused enne neid tegevusi on:"; +"notice_room_power_level_event_requirement" = "Sündmustega seotud minimaalselt vajalikud õigused on:"; +"notice_room_aliases" = "Jututoa aliased on: %@"; +"notice_room_related_groups" = "Selle jututoaga seotud grupid on: %@"; +"notice_encrypted_message" = "Krüptitud sõnum"; +"notice_encryption_enabled_ok" = "%@ lülitas sisse läbiva krüptimise."; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ lülitas sisse läbiva krüptimise (tundmatu algoritm %2$@)."; +"notice_image_attachment" = "manustatud pilt"; +"notice_audio_attachment" = "manustatud helifail"; +"notice_video_attachment" = "manustatud videofail"; +"notice_location_attachment" = "manustatud asukohafail"; +"notice_file_attachment" = "manustatud fail"; +"notice_invalid_attachment" = "vigane manus"; +"notice_unsupported_attachment" = "Manus ei ole toetatud: %@"; +"notice_feedback" = "Tagasiside (id: %@): %@"; +"notice_redaction" = "%@ muutis sündmust (id: %@)"; +"notice_error_unsupported_event" = "Sündmuse tüüp ei ole toetatud"; +"notice_error_unexpected_event" = "Ootamatu sündmus"; +"notice_error_unknown_event_type" = "Tundmatu sündmuse tüüp"; +"notice_room_history_visible_to_anyone" = "%@muutis jututoa tulevase ajaloo loetavaks kõigile."; +"notice_room_history_visible_to_members" = "%@ määras, et jututoa tulevane ajalugu on nähtav kõikidele selle liikmetele."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ määras, et jututoa tulevane ajalugu on nähtav kõikidele selle liikmetele nende kutsumise hetkest."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ määras, et jututoa tulevane ajalugu on nähtav kõikidele selle liikmetele nende liitumise hetkest."; +"notice_crypto_unable_to_decrypt" = "** Ei õnnestu dekrüptida: %@ **"; +"notice_crypto_error_unknown_inbound_session_id" = "Sõnumi saatja sessioon ei ole selle sõnumi jaoks saatnud dekrüptimisvõtmeid."; +"notice_sticker" = "kleeps"; +"notice_in_reply_to" = "Vastuseks kasutajale"; +// room display name +"room_displayname_empty_room" = "Tühi jututuba"; +"room_displayname_two_members" = "%@ ja %@"; +"room_displayname_more_than_two_members" = "%@ ja %@ muud"; +"device_details_delete_prompt_message" = "See tegevus vajab täiendavat autentimist.\nJätkamaks palun sisesta oma salasõna."; +// Encryption information +"room_event_encryption_info_title" = "Läbiva krüptimise teave\n\n"; +"room_event_encryption_info_event" = "Sündmuse teave\n"; +"room_event_encryption_info_event_user_id" = "Kasutajatunnus\n"; +"room_event_encryption_info_event_fingerprint_key" = "Väidetav Ed25519 allkirja sõrmejälje võti\n"; +"room_event_encryption_info_event_algorithm" = "Algoritm\n"; +"room_event_encryption_info_event_session_id" = "Sessiooni tunnus\n"; +"room_event_encryption_info_event_decryption_error" = "Dekrüptimise viga\n"; +"room_event_encryption_info_event_unencrypted" = "krüptimata"; +"room_event_encryption_info_event_none" = "ei midagi"; +"room_event_encryption_info_device" = "\nSaatja sessiooni teave\n"; +"room_event_encryption_info_device_unknown" = "tundmatu sessioon\n"; +"room_event_encryption_info_device_name" = "Avalik nimi\n"; +"room_event_encryption_info_device_id" = "Tunnus\n"; +"room_event_encryption_info_device_verification" = "Verifikatsioon\n"; +"room_event_encryption_info_device_fingerprint" = "Ed25519 sõrmejälg\n"; +"room_event_encryption_info_device_verified" = "Verifitseeritud"; +"room_event_encryption_info_device_not_verified" = "EI OLE verifitseeritud"; +"room_event_encryption_info_unverify" = "Eemalda verifitseerimine"; +"room_event_encryption_verify_title" = "Verifitseeri sessioon\n\n"; +"room_event_encryption_verify_ok" = "Verifitseeri"; +// Account +"account_save_changes" = "Salvesta muutused"; +"account_error_picture_change_failed" = "Pildi muutmine ei õnnestunud"; +"account_error_matrix_session_is_not_opened" = "Matrix'i sessioon pole avatud"; +"room_creation_name_placeholder" = "(näiteks HeadLõunasöögikohad)"; +"room_creation_alias_title" = "Jututoa alias:"; +"room_creation_alias_placeholder" = "(e.g. #midagi:domeen.ee)"; +"room_creation_alias_placeholder_with_homeserver" = "(näiteks #midagi%@)"; +"room_creation_participants_title" = "Osalejad:"; +"room_creation_participants_placeholder" = "(näiteks @kadri:koduserver1; @peeter:koduserver2...)"; +// Room +"room_please_select" = "Palun vali jututuba"; +"room_error_join_failed_title" = "Jututoaga liitumine ei õnnestunud"; +"room_error_join_failed_empty_room" = "Hetkel ei ole võimalik liituda tühja jututoaga."; +"room_error_timeline_event_not_found_title" = "Asukoha laadimine ajajoonel ei õnnestunud"; +"room_error_timeline_event_not_found" = "Rakendus üritas laadida teatud hetke selle jututoa ajajoonelt, kuid ei suutnud seda leida"; +"room_left" = "Sa lahkusid jututoast"; +"room_no_power_to_create_conference_call" = "Konverentsikõne alustamiseks selles jututoas on sul vaja õigusi"; +"room_no_conference_call_in_encrypted_rooms" = "Konverentsikõned ei ole krüptitud jututubades toetatud"; +// Reply to message +"message_reply_to_sender_sent_an_image" = "saatis pildi."; +"message_reply_to_sender_sent_a_video" = "saatis video."; +"message_reply_to_sender_sent_an_audio_file" = "saatis helifaili."; +"message_reply_to_sender_sent_a_file" = "saatis faili."; +"message_reply_to_message_to_reply_to_prefix" = "Vastuseks kasutajale"; +// Room members +"room_member_ignore_prompt" = "Kas sa oled kindel, et soovid peita kõik sõnumid selle kasutaja eest?"; +"e2e_import" = "Impordi"; +"e2e_passphrase_enter" = "Sisesta paroolifraas"; +// E2E export +"e2e_export_room_keys" = "Ekspordi jututoa võtmed"; +"e2e_export_prompt" = "Selle toiminguga on sul võimalik saabunud krüptitud sõnumite võtmed eksportida sinu kontrollitavasse kohalikku faili. Seetõttu on sul tulevikus võimalik importida need võtmed mõnda teise Matrix'i klienti ning seeläbi muuta saabunud krüptitud sõnumid ka seal loetavaks.\nKes iganes saab kätte selle võtmefaili, saab ka dekrüptida kõiki sinu krüptitud sõnumeid, seega palun hoia teda turvaliselt."; +"e2e_export" = "Ekspordi"; +"e2e_passphrase_confirm" = "Sisesta paroolifraas veel üks kord"; +"e2e_passphrase_empty" = "Paroolifraas ei tohi olla tühi"; +"e2e_passphrase_not_match" = "Paroolifraasid ei klapi omavahel"; +"e2e_passphrase_create" = "Loo paroolifraas"; +// Others +"user_id_title" = "Kasutajatunnus:"; +"offline" = "võrgust väljas"; +"unsent" = "Saatmata"; +"error" = "Viga"; +"private" = "Privaatne"; +"power_level" = "Õiguste tase"; +"network_error_not_reachable" = "Palun kontrolli oma võrguühendust"; +"user_id_placeholder" = "näiteks @kati:mingidomeen.com"; +"ssl_homeserver_url" = "Koduserveri aadress: %@"; +// Permissions +"camera_access_not_granted_for_call" = "Videokõned vajavad ligipääsu kaamerale, kuid %@'l pole selleks õigusi"; +"microphone_access_not_granted_for_call" = "Kõned vajavad ligipääsu mikrofonile, kuid %@'l pole selleks õigusi"; +"local_contacts_access_discovery_warning_title" = "Kasutajate leidmine"; +// Country picker +"country_picker_title" = "Vali riik"; +// Language picker +"language_picker_title" = "Vali keel"; +"language_picker_default_language" = "Vaikimisi (%@)"; +"notice_room_invite" = "%@ kutsus kasutajat %@"; +"notice_room_third_party_invite" = "%@ saatis kasutajale %@ kutse jututoaga liitumiseks"; +"notice_room_third_party_registered_invite" = "%@ võttis vastu kutse %@ nimel"; +"notice_room_third_party_revoked_invite" = "%@ võttis tagasi jututoaga liitumise kutse kasutajalt %@"; +"notice_room_join" = "%@ liitus"; +"notice_room_leave" = "%@ lahkus"; +"notice_room_reject" = "%@ lükkas tagasi kutse"; +"notice_room_kick" = "%@ müksas kasutajat %@"; +"notice_room_unban" = "%@ taastas %@ ligipääsu"; +"notice_room_ban" = "%@ keelas %@ ligipääsu"; +"notice_room_withdraw" = "%@ võttis tagasi kutse kasutajale %@"; +"notice_room_reason" = ". Põhjus: %@"; +"notice_avatar_url_changed" = "%@ muutis oma tunnuspilti"; +"notice_display_name_set" = "%@ määras oma kuvatavaks nimeks %@"; +"notice_display_name_changed_from" = "%@ muutis senise kuvatava nime %@ uueks nimeks %@"; +"notice_display_name_removed" = "%@ eemaldas oma kuvatava nime"; +"notice_topic_changed" = "%@ muutis uueks teemaks „%@“."; +"notice_room_name_changed" = "%@ muutis jututoa uueks nimeks %@."; +"notice_placed_voice_call" = "%@ alustas häälkõnet"; +"notice_placed_video_call" = "%@ alustas videokõnet"; +"notice_answered_video_call" = "%@ vastas kõnele"; +"notice_ended_video_call" = "%@ lõpetas kõne"; +"notice_conference_call_request" = "%@ saatis VoIP rühmakõne kutse"; +"notice_conference_call_started" = "VoIP rühmakõne algas"; +"notice_conference_call_finished" = "VoIP rühmakõne lõppes"; +// Notice Events with "You" +"notice_room_invite_by_you" = "Sina kutsusid kasutajat %@"; +"notice_room_invite_you" = "%@ kutsus sind"; +"notice_room_third_party_invite_by_you" = "Sina saatsid kasutajale %@ kutse jututoaga liitumiseks"; +"notice_room_third_party_registered_invite_by_you" = "Sina võtsid vastu kutse %@ nimel"; +"notice_room_third_party_revoked_invite_by_you" = "Sina võtsid tagasi jututoaga liitumise kutse kasutajalt %@"; +"notice_room_join_by_you" = "Sina liitusid"; +"notice_room_leave_by_you" = "Sina lahkusid"; +"notice_room_reject_by_you" = "Sa lükkasid kutse tagasi"; +"notice_room_kick_by_you" = "Sina müksasid %@ välja"; +"notice_room_unban_by_you" = "Sina taastasid %@ ligipääsu"; +"notice_room_ban_by_you" = "Sina keelasid %@ ligipääsu"; +"notice_room_withdraw_by_you" = "Sina võtsid tagasi %@ kutse"; +"notice_avatar_url_changed_by_you" = "Sa muutsid oma tunnuspilti"; +"notice_display_name_set_by_you" = "Sina määrasid oma kuvatavaks nimeks %@"; +"notice_display_name_changed_from_by_you" = "Sina muutsid senise kuvatava nime %@ uueks nimeks %@"; +"notice_display_name_removed_by_you" = "Sa eemaldasid oma kuvatava nime"; +"notice_topic_changed_by_you" = "Sa muutsid uueks teemaks „%@“."; +"notice_room_name_changed_by_you" = "Sa muutsid jututoa uueks nimeks %@."; +"notice_placed_voice_call_by_you" = "Sa alustasid häälkõnet"; +"notice_placed_video_call_by_you" = "Sa alustasid videokõnet"; +"notice_answered_video_call_by_you" = "Sa vastasid kõnele"; +"notice_ended_video_call_by_you" = "Sa lõpetasid kõne"; +"notice_conference_call_request_by_you" = "Sa algatasid VoIP rühmakõne"; +"notice_room_name_removed_by_you" = "Sa eemaldasid jututoa nime"; +"notice_room_topic_removed_by_you" = "Sa eemaldasid teema"; +"notice_event_redacted_by_you" = " sinu poolt"; +"notice_profile_change_redacted_by_you" = "Sa uuendasid oma profiili %@"; +"notice_room_created_by_you" = "Sa lõid ja seadistasid jututoa."; +"notice_encryption_enabled_ok_by_you" = "Sa lülitasid sisse läbiva krüptimise."; +"notice_encryption_enabled_unknown_algorithm_by_you" = "Sa lülitasid sisse läbiva krüptimise (kasutusel on tundmatu algoritm %@)."; +"notice_redaction_by_you" = "Sa muutsid sündmust: (id: %@)"; +"notice_room_history_visible_to_anyone_by_you" = "Sa muutsid jututoa tulevase ajaloo loetavaks kõigile."; +"notice_room_history_visible_to_members_by_you" = "Sina tegid jututoa tulevase ajaloo loetavaks kõikidele jututoa liikmetele."; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "Sina muutsid jututoa tulevase ajaloo loetavaks kõikidele jututoa liikmetele sellest hetkest, kui nad on kutse saanud."; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "Sina muutsid jututoa tulevase ajaloo loetavaks kõikidele jututoa liikmetele sellest hetkest, kui nad liitusid jututoaga."; +// button names +"ok" = "Sobib"; +"send" = "Saada"; +"copy_button_name" = "Kopeeri"; +"membership_leave" = "Lahkus"; +"ban" = "Keela ligipääs"; +"message_unsaved_changes" = "Osa muudatusi on salvestamata. Lahkudes need kaovad."; +// Login Screen +"login_error_already_logged_in" = "Sa oled juba sisse loginud"; +// contacts list screen +"invitation_message" = "Ma soovin sinuga vestelda Matrix'i võrgu vahendusel. Lisateavet leiad veebisaidist https://matrix.org/ ."; +// Settings screen +"settings_title_config" = "Seadistused"; +"notification_settings_always_notify" = "Teavita alati"; +"notification_settings_never_notify" = "Ära teavita iialgi"; +"notification_settings_word_to_match" = "vastendatav sõna"; +"notification_settings_highlight" = "Tõsta esile"; +"notification_settings_custom_sound" = "Kohandatud heli"; +"notification_settings_per_room_notifications" = "Jututoa-kohased teavitused"; +"notification_settings_per_sender_notifications" = "Saatjakohased teavitused"; +"notification_settings_sender_hint" = "@kasutaja:domeen.ee"; +"notification_settings_select_room" = "Vali jututuba"; +"notification_settings_other_alerts" = "Muud hoiatused"; +"notification_settings_contain_my_user_name" = "Teavita mind helimärguandega sõnumitest, mis sisaldavad minu kasutajanime"; +"notification_settings_contain_my_display_name" = "Teavita mind helimärguandega sõnumitest, mis sisaldavad minu kuvatavat nime"; +"notification_settings_just_sent_to_me" = "Teavita mind helimärguandega sõnumitest, mis on saadetud vaid mulle"; +"notification_settings_invite_to_a_new_room" = "Teavita mind, kui ma olen saanud kutse uude jututuppa"; +"notification_settings_people_join_leave_rooms" = "Teavita mind, kui teised kasutajad liituvad jututoaga või lahkuvad sealt"; +"notification_settings_receive_a_call" = "Teavita mind, kui mulle tuleb kõne"; +"notification_settings_suppress_from_bots" = "Ära luba teavitusi robototelt"; +"notification_settings_by_default" = "Vaikimisi..."; +"notification_settings_notify_all_other" = "Teavita mind kõikide muude sõnumite ja jututubade puhul"; +// gcm section +"settings_config_home_server" = "Koduserver: %@"; +"settings_config_identity_server" = "Isikutuvastusserver: %@"; +"settings_config_user_id" = "Kasutajatunnus: %@"; +// call string +"call_waiting" = "Ootan..."; +"call_connecting" = "Kõne on ühendamisel…"; +"call_ended" = "Kõne lõppes"; +"call_ring" = "Helistan..."; +"incoming_video_call" = "Saabuv videokõne"; +"incoming_voice_call" = "Saabuv häälkõne"; +"call_invite_expired" = "Kõnekutse aegus"; +// unrecognized SSL certificate +"ssl_trust" = "Usalda"; +"ssl_logout_account" = "Logi välja"; +"ssl_remain_offline" = "Eira"; +"ssl_fingerprint_hash" = "Sõrmejälg (%@):"; +"login_email_info" = "Lisades oma e-posti aadressi saad võimaldada teistel Matrix'i kasutajatel sind lihtsamini leida ning annad endale lisavõimaluse salasõna muutmiseks, kui seda tulevikus vaja peaks olema."; +"login_prompt_email_token" = "Palun sisesta oma e-posti aadressi registeerimiskirjas näidatud tunnusluba:"; +"login_error_unknown_token" = "Sisestatud tunnusluba ei ole õige"; +"login_error_login_email_not_yet" = "E-posti teel saadetud linki pole veel klõpsitud"; +"settings_enter_validation_token_for" = "Sisesta %@ tuvastamise tunnusluba:"; +"room_event_encryption_info_verify" = "Verifitseeri..."; +"account_link_email" = "Seotud e-posti aadress"; +"account_linked_emails" = "Seotud e-posti aadressid"; +"account_email_validation_title" = "Verifikatsioon on ootel"; +"account_email_validation_message" = "Palun vaata oma e-kirju ning klõpsi meie saadetud kirjas leiduvat linki. Kui see on tehtud, siis vajuta Jätka-nuppu."; +"account_email_validation_error" = "E-posti aadressi õigsust pole veel õnnestunud kontrollida. Palun vaata oma e-kirju ning klõpsi meie saadetud kirjas leiduvat linki. Kui see on tehtud, siis vajuta Jätka-nuppu"; +"account_msisdn_validation_title" = "Verifikatsioon on ootel"; +"account_msisdn_validation_message" = "Me oleme SMS'iga saatnud aktiveerimiskoodi. Palun sisesta see kood siia."; +"account_msisdn_validation_error" = "Telefoninumbri verifitseerimine ei õnnestunud."; +"account_error_display_name_change_failed" = "Kuvatava nime muutmine ei õnnestunud"; +"room_error_name_edition_not_authorized" = "Sinul pole õigusi selle jututoa nime muutmiseks"; +"room_error_topic_edition_not_authorized" = "Sinul pole õigusi selle jututoa teema muutmiseks"; +"room_error_cannot_load_timeline" = "Ajajoone laadimine ei õnnestunud"; +"e2e_import_prompt" = "Selle toiminguga saad importida krüptimisvõtmed, mis sa viimati olid teisest Matrix'i kliendist eksportinud. Seejärel on võimalik dekrüptida ka siin kõik need samad sõnumid, mida see teine klient suutis dekrüptida.\nSee ekspordifail on krüptitud paroolifraasiga. Faili dekrüptimiseks sisesta siia paroolifraas."; +"local_contacts_access_discovery_warning" = "Selleks, et leida Matrixi võrgu kasutajaid, võib %@ saata sinu aadressiraamatus leiduvad e-posti aadressid ja telefoninumbrid sinu valitud Matrixi isikutuvastusserverile. Kui server seda toetab, siis andmed muudetakse enne saatmist räsideks - täpsema teabe leiad oma isikutuvastusserveri privaatsuspoliitikast."; +"membership_ban" = "Suhtluskeeld"; +"notification_settings_per_word_notifications" = "Sõnadega seotud teavitused"; +"ssl_could_not_verify" = "Serveri õigsust ei olnud võimalik kontrollida."; +"room_event_encryption_info_event_identity_key" = "Curve25519 identiteedi võti\n"; +"room_event_encryption_info_device_blocked" = "Mustas nimekirjas"; +"room_event_encryption_info_block" = "Lisa musta nimekirja"; +"room_event_encryption_info_unblock" = "Eemalda mustast nimekirjast"; +"room_event_encryption_verify_message" = "Tegemaks kindlaks, et seda sessiooni võid usaldada, palun kohtu tema omanikuga mõnel muul viisil (näiteks isiklikult või telefonikõne vahendusel) ning küsi, kas võtmed, mida ta näeb oma kasutajaseadistustes kattuvad alljärgnevaga:\n\n\tSessioni nimi: %@\n\tSessioni tunnus: %@\n\tSessioni võti: %@\n\nKui andmed kattuvad, siis vajuta järgnevat verifitseerimise nuppu. Kui ei kattu, siis tõenäoliselt keegi võõras suudab seda teist sessiooni kontrollida ning sa ilmselt eelistaks lisada teda musta nimekirja.\n\nTulevikus see verifitseerimise toiming võib minna veelgi nutikamaks."; +"local_contacts_access_not_granted" = "Kasutajate leidmine sinu kohaliku aadressiraamatu alusel eeldab talle ligipääsu, kuid %@'l puuduvad selleks õigused"; +"notification_settings_global_info" = "Teavituste seadistused salvestatakse serverisse koos sinu konto muude andmetega ning neid jagatakse kõikide klientrakendustega, kes sellist võimalust toetavad (sh töölauateavitused).\n\nReegleid rakendatakse järjekorras ning esimene vastavus määrab ka tulemuse.\nSeega: sõnakohased teavitused on olulisemad, kui jututoa-kohased teavitused ning need omakorda olulisemad kui saatjakohased teavitused.\nMitme sarnase reegli puhul kehtib põhimõte, et esimene loendi alusel leitud vaste on määrav."; +"notification_settings_per_word_info" = "Sõnade otsing ei ole tõstutundlik ning võib kasutada asendusteks * metamärki. Näiteks:\n- midagi alusel otsitakse kõiki sõnu „midagi“ (eraldajaks kirjavahemärgi, tühikud või rea algus ja lõpp);\n- midagi* alusel otsitakse kõiki sõnu mille alguses on „midagi“;\n- *midagi* otsib mis iganes sõnu, kus leidub järjest 6 tähte „midagi“."; +"ssl_cert_not_trust" = "See võib tähendada, et keegi on suuteline pahatahtlikult sinu veebiliiklust pealtkuulama või sinu telefon ei usalda serveri kasutatavat sertifikaati."; +"ssl_cert_new_account_expl" = "Kui serveri haldaja on sind teavitanud, et nii võib juhtuda, siis kontrolli, et sertifikaadi sõrmejälg vastab sellele, mille haldaja sulle on andnud."; +"ssl_unexpected_existing_expl" = "Võrreldes selle sertifikaadiga, mida sinu nutiseade seni usaldas, on praegune sertifikaat muutunud. See on VÄGA EBATAVALINE. Me soovitame, et ÄRA NÕUSTU selle uue sertifikaadiga."; +"ssl_expected_existing_expl" = "Senise usaldusväärse sertifikaadi asemel kasutab server nüüd mitteusaldusväärset sertifikaati. See võib tähendada et haldaja on seda serveris muutnud. Et võrrelda viimase kehtiva sertifikaadi sõrmejälge, palun võta haldajaga ühendust."; +"ssl_only_accept" = "NÕUSTU sertifikaadiga vaid siis, kui serveri haldaja antud sõrmejälg klapib sellega, mida sa hetkel siin näed."; +// New +"notice_room_join_rule_invite" = "%@ määras, et jututuppa pääseb vaid kutsega."; +"notice_room_join_rule_invite_by_you" = "Sina määrasid, et jututuppa pääseb vaid kutsega."; +"notice_room_join_rule_public" = "%@ muutis jututoa avalikuks."; +"notice_room_join_rule_public_by_you" = "Sa muutsid jututoa avalikuks."; +"notice_room_name_removed_for_dm" = "%@ eemaldas jututoa nime"; +"notice_room_created_for_dm" = "%@ liitus."; +"notice_room_join_rule_invite_for_dm" = "%@ määras, et jututuppa pääseb vaid kutsega."; +"notice_room_join_rule_invite_by_you_for_dm" = "Sina määrasid, et jututuppa pääseb vaid kutsega."; +"notice_room_join_rule_public_for_dm" = "%@ muutis jututoa avalikuks."; +"notice_room_join_rule_public_by_you_for_dm" = "Sa muutsid jututoa avalikuks."; +"notice_room_power_level_intro_for_dm" = "Jututoa liikmete õigused on:"; +"notice_room_aliases_for_dm" = "Jututoa aliased on: %@"; +"notice_room_history_visible_to_members_for_dm" = "%@ määras, et jututoa tulevane ajalugu on nähtav kõikidele selle liikmetele."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ määras, et jututoa tulevane ajalugu on nähtav kõikidele selle liikmetele liitumiskutse saatmise hetkest."; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ määras, et jututoa tulevane ajalugu on nähtav kõikidele selle liikmetele nende liitumise hetkest."; +"room_left_for_dm" = "Sina lahkusid"; +"notice_room_third_party_invite_for_dm" = "%@ kutsus kasutajat %@"; +"notice_room_third_party_revoked_invite_for_dm" = "%@ võttis tagasi kasutaja %@ kutse"; +"notice_room_name_changed_for_dm" = "%@ muutis jututoa uueks nimeks %@."; +"notice_room_third_party_invite_by_you_for_dm" = "Sina kutsusid kasutajat %@"; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "Sina võtsid tagasi kasutaja %@ kutse"; +"notice_room_name_changed_by_you_for_dm" = "Sa muutsid jututoa uueks nimeks %@."; +"notice_room_name_removed_by_you_for_dm" = "Sa eemaldasid jututoa nime"; +"notice_room_created_by_you_for_dm" = "Sina liitusid."; +"notice_room_history_visible_to_members_by_you_for_dm" = "Sina tegid jututoa tulevase ajaloo loetavaks kõikidele jututoa liikmetele."; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "Sina määrasid, et jututoa tulevane ajalugu on nähtav kõikidele selle liikmetele liitumiskutse saatmise hetkest."; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "Sina määrasid, et jututoa tulevane ajalugu on nähtav kõikidele selle liikmetele nende liitumise hetkest."; +"call_more_actions_dialpad" = "Numbriklahvistik"; +"call_more_actions_transfer" = "Suuna kõne edasi"; +"call_more_actions_audio_use_device" = "Kasuta seadme kõlarit"; +"call_more_actions_audio_use_headset" = "Kasuta kõrvaklappe"; +"call_more_actions_change_audio_device" = "Muuda heliseadet"; +"call_more_actions_unhold" = "Jätka"; +"call_more_actions_hold" = "Pane ootele"; +"call_holded" = "Sina panid kõne ootele"; +"call_remote_holded" = "%@ pani kõne ootele"; +"notice_declined_video_call_by_you" = "Sina keeldusid kõnest"; +"notice_declined_video_call" = "%@ keeldus kõnest"; +"resume_call" = "Jätka"; +"call_consulting_with_user" = "Pean nõu kasutajaga %@"; +"call_transfer_to_user" = "Suunan kõne kasutajale %@"; +"call_video_with_user" = "Videokõne kasutajaga %@"; +"call_voice_with_user" = "Häälkõne kasutajaga %@"; +"call_ringing" = "Helistan…"; +"e2e_passphrase_too_short" = "Salafraas on liiga lühike (pikkus peaks olema vähemalt %d tähemärki)"; +"microphone_access_not_granted_for_voice_message" = "Häälsõnumite salvestamiseks on vajalik ligipääs mikrofonile, kuid %@'l pole selleks õigusi"; +"message_reply_to_sender_sent_a_voice_message" = "saatis häälsõnumi."; +"attachment_large_with_resolution" = "Suurena %@ (~%@)"; +"attachment_medium_with_resolution" = "Keskmisena %@ (~%@)"; +"attachment_small_with_resolution" = "Väiksena %@ (~%@)"; +"attachment_size_prompt_message" = "Seadistustest saad määrata, et see funktsionaalsus pole kasutusel."; +"attachment_size_prompt_title" = "Saatmiseks kinnita meedia suurus"; +"room_displayname_all_other_participants_left" = "%@ (lahkus(id))"; +"auth_reset_password_error_not_found" = "Pole leitav"; +"auth_reset_password_error_unauthorized" = "Tegevus pole lubatud"; +"auth_username_in_use" = "Selline kasutajanimi on juba olemas"; +"auth_invalid_user_name" = "Vigane kasutajanimi"; +"rename" = "Muuda nime"; +"room_displayname_all_other_members_left" = "%@ (lahkus(id))"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/eu.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/eu.lproj/MatrixKit.strings new file mode 100644 index 000000000..6a119cc14 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/eu.lproj/MatrixKit.strings @@ -0,0 +1,383 @@ +"notice_conference_call_started" = "VoIP konferentzia hasita"; +"notice_conference_call_finished" = "VoIP konferentzia amaituta"; +// Events formatter +"notice_avatar_changed_too" = "(abatarra ere aldatu da)"; +"notice_crypto_error_unknown_inbound_session_id" = "Igorlearen saioak ez dizkigu mezu honetarako gakoak bidali."; +"room_error_join_failed_empty_room" = "Ezin da oraingoz hutsik dagoen gela batetara berriro sartu."; +"notice_encrypted_message" = "Zifratutako mezua"; +"login_email_placeholder" = "E-mail helbidea"; +// room display name +"room_displayname_empty_room" = "Gela hutsa"; +"room_displayname_two_members" = "%@ eta %@"; +"room_displayname_more_than_two_members" = "%@ eta beste %u"; +// Settings +"settings" = "Ezarpenak"; +// button names +"ok" = "Ados"; +"cancel" = "Utzi"; +"save" = "Gorde"; +"leave" = "Atera"; +"send" = "Bidali"; +"copy_button_name" = "Kopiatu"; +"resend" = "Birbidali"; +"redact" = "Kendu"; +"share" = "Partekatu"; +"delete" = "Ezabatu"; +"room_no_power_to_create_conference_call" = "Gonbidatzeko baimena behar duzu gela honetan konferentzia bat hasteko"; +"room_no_conference_call_in_encrypted_rooms" = "Konferentzia deiak ez daude onartuta zifratutako geletan"; +"invite" = "Gonbidatu"; +"close" = "Itxi"; +"start_voice_call" = "Hasi ahots deia"; +"start_video_call" = "Hasi bideo deia"; +"submit" = "Bidali"; +"login_password_placeholder" = "Pasahitza"; +"login_home_server_title" = "Hasiera-zerbitzariaren URLa:"; +"login_identity_server_title" = "Identitate-zerbitzariaren URL-a:"; +"login_error_must_start_http" = "http[s]:// gisa hasi behar da URLa"; +"login_error_forbidden" = "Erabiltzaile-izen / pasahitz baliogabea"; +"login_error_unknown_token" = "Ez da ezagutzen zehaztutako sarbide katea"; +"login_error_bad_json" = "Gaizki osatutako JSON"; +"login_error_not_json" = "Ez zuen baliozko JSON-ik"; +"login_error_limit_exceeded" = "Eskaera gehiegi bidali dira"; +"login_error_user_in_use" = "Erabiltzaile-izen hau hartuta dago"; +"login_error_login_email_not_yet" = "Oraindik erabili ez den e-maileko esteka"; +"attachment_cancel_download" = "Utzi deskarga?"; +"attachment_cancel_upload" = "Utzi deskarga?"; +"call_ended" = "Deia amaitu da"; +"incoming_video_call" = "Bideo-deia jasotzen"; +"incoming_voice_call" = "Ahots-deia jasotzen"; +"continue" = "Jarraitu"; +// Time +"format_time_s" = "s"; +"format_time_m" = "m"; +"format_time_h" = "h"; +"format_time_d" = "e"; +"ban" = "Debekatu"; +"mention" = "Aipamena"; +"room_member_power_level_prompt" = "Ezin izango duzu aldaketa hau desegin kidea zure botere maila berera ekartzen ari zarelako.\nZiur zaude?"; +// unrecognized SSL certificate +"ssl_trust" = "Fidatu"; +"ssl_logout_account" = "Amaitu saioa"; +"ssl_remain_offline" = "Ezikusi"; +"ssl_could_not_verify" = "Ezin izan da urruneko zerbitzariaren identitatea egiaztatu."; +"ssl_cert_not_trust" = "Honek esan lezake inor zure trafikoa antzematen dabilela asmo txarrez, edo zure telefonoa ez dela fidatzen urruneko zerbitzariaren ziurtagiriaz."; +"ssl_cert_new_account_expl" = "Zerbitzariaren kudeatzaileak hau gerta daitekeela esaten badu, ziurtatu beheko hatz-marka beraiek emandako hatz-markarekin bat datorrela."; +"ssl_unexpected_existing_expl" = "Ziurtagiria aldatu da eta ez da zure telefonoak onartzen zuena. Hau OSO ARRAROA da. Ziurtagiri berri hau EZ ONARTZEA aholkatzen da."; +"ssl_expected_existing_expl" = "Ziurtagiria aldatu da, onartutako batetik onartu gabeko batera. Agian Zerbitzariak ziurtagiria berriztu du. Jarri kontaktuan zerbitzariaren kudeatzailearekin hatz-marka eskatzeko."; +"ssl_only_accept" = "SOILIK onartu ziurtagiria zerbitzariaren kudeatzaileak goikoarekin bat datorren hatz-marka bat argitaratu badu."; +// room details dialog screen +"room_details_title" = "Gelaren xehetasunak"; +"cancel_upload" = "Utzi igoera"; +"cancel_download" = "Utzi deskarga"; +"settings_title_notifications" = "Jakinarazpenak"; +"device_details_delete_prompt_message" = "Eragiketa honek autentifikazio gehigarria behar du.\nJarraitzeko, idatzi zure pasahitza."; +"device_details_delete_prompt_title" = "Autentifikazioa"; +// Language picker +"language_picker_title" = "Hautatu hizkuntza"; +"account_email_validation_title" = "Egiaztaketa egiteke"; +"account_email_validation_message" = "Irakurri zure e-maila eta egin klik dakarren estekan. Behin eginda, egin klik Jarraitu botoian."; +"account_email_validation_error" = "Ezin izan da e-mail helbidea egiaztatu. Irakurrri zure e-maila eta egin klik dakarren estekan. Behin eginda, egin klik Jarraitu botoian"; +// Country picker +"country_picker_title" = "Hautatu herrialde bat"; +"account_msisdn_validation_message" = "Aktibazio kodea duen SMS mezu bat bidali dizugu. Idatzi kode hori hemen azpian."; +"room_event_encryption_info_event_none" = "bat ere ez"; +// E2E export +"e2e_export_room_keys" = "Esportatu gelako gakoak"; +"e2e_export" = "Esportatu"; +"e2e_passphrase_enter" = "Idatzi pasaesaldia"; +"e2e_passphrase_confirm" = "Berretsi pasaesaldia"; +// E2E import +"e2e_import_room_keys" = "Inportatu gelako gakoak"; +"e2e_import" = "Inportatu"; +"room_event_encryption_info_device_verified" = "Egiaztatuta"; +"room_event_encryption_info_device_blocked" = "Blokeatuta"; +"room_event_encryption_verify_ok" = "Egiaztatu"; +"room_event_encryption_info_unverify" = "Kendu egiaztaketa"; +"room_event_encryption_info_block" = "Blokeatu"; +"room_event_encryption_info_unblock" = "Desblokeatu"; +"view" = "Ikusi"; +"back" = "Atzera"; +"retry" = "Saiatu berriro"; +"dismiss" = "Baztertu"; +"start_chat" = "Hasi txata"; +"room_event_encryption_info_event_unencrypted" = "zifratu gabe"; +"room_event_encryption_info_device_not_verified" = "EZ egiaztatuta"; +"room_event_encryption_info_verify" = "Egiaztatu…"; +"account_error_email_wrong_title" = "E-mail helbide baliogabea"; +"account_error_email_wrong_description" = "Honek ez du baliozko e-mail baten antzik"; +"room_error_join_failed_title" = "Huts egin du gelara elkartzean"; +"room_error_timeline_event_not_found_title" = "Huts egin du denbora-lerroko puntua kargatzean"; +"e2e_passphrase_empty" = "Pasaesaldia ezin da hutsik egon"; +"e2e_passphrase_not_match" = "Pasaesaldiak bat etorri behar dira"; +"error" = "Errorea"; +"create_room" = "Sortu gela"; +"membership_invite" = "Gonbidatuta"; +"kick" = "Kanporatu"; +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "Sortu kontua:"; +"login_server_url_placeholder" = "URL-a (adib. https://matrix.org)"; +"login_home_server_info" = "Zure hasiera-zerbitzariak gordetzen ditu zure elkarrizketa guztiak eta kontuaren datuak"; +"login_identity_server_info" = "Matrixek identitate zerbitzariak hornitzen ditu e-mailak eta abar zein Matrix ID-ri dagokien jakiteko, Orain https://matrix.org da dagoen bakarra."; +"login_user_id_placeholder" = "Matrix ID-a (adib. @urko:matrix.org edo urko)"; +"login_optional_field" = "aukerakoa"; +"login_display_name_placeholder" = "Pantaila-izena (adib. Urko Etxeberria)"; +"login_error_title" = "Saio hasierak huts egin du"; +"login_email_info" = "Zehaztu e-mail helbide bat beste Matrix erabiltzaileek zu errazago aurkitzeko, e-mail helbidea izateak ere etorkizunean pasahitza aldatzea ahalbidetuko dizu."; +"login_prompt_email_token" = "Sartu zure e-mail egiaztaketa tokena:"; +"login_error_no_login_flow" = "Huts egin du autentifikazio informazioa hasiera zerbitzari honetatik jasotzean"; +"login_leave_fallback" = "Utzi"; +"login_invalid_param" = "Parametro baliogabea"; +"register_error_title" = "Erregistratzeak huts egin du"; +// Action +"no" = "Ez"; +"yes" = "Bai"; +"abort" = "Abortatu"; +"discard" = "Baztertu"; +"sign_up" = "Erregistratu"; +"submit_code" = "Bidali kodea"; +"login_error_do_not_support_login_flows" = "Orain ez ditugu onartzen hasiera zerbitzari honek zehaztutako saio hasiera metodo guztiak, edo batere"; +"login_error_registration_is_not_supported" = "Oraindik ezin da aplikazioarekin erregistratu"; +"login_use_fallback" = "Erabili ordezko orria"; +"login_error_forgot_password_is_not_supported" = "Oraindik ezin da aplikazioarekin pasahitza berreskuratu"; +"set_default_power_level" = "Leheneratu botere maila"; +"set_moderator" = "Ezarri moderatzailea"; +"set_admin" = "Ezarri kudeatzailea"; +"select_account" = "Hautatu kontu bat"; +"attach_media" = "Erantsi media liburutegitik"; +"capture_media" = "Atera argazkia / bideoa"; +"invite_user" = "Gonbidatu matrix erabiltzailea"; +"reset_to_default" = "Leheneratu lehenetsitakora"; +"resend_message" = "Birbidali mezua"; +"select_all" = "Hautatu guztia"; +"show_details" = "Erakutsi xehetasunak"; +"answer_call" = "Erantzun deia"; +"reject_call" = "Ukatu deia"; +"end_call" = "Amaitu deia"; +"ignore" = "Ezikusi"; +"unignore" = "Berriro aintzat hartu"; +"notice_room_name_removed" = "%@ erabiltzaileak gelaren izena kendu du"; +"notice_room_topic_removed" = "%@ erabiltzaileak gelaren mintzagaia kendu du"; +"notice_event_redacted" = ""; +"notice_event_redacted_by" = " nork: %@"; +"notice_event_redacted_reason" = " [arrazoia: %@]"; +"notice_room_created" = "%@ erabiltzaileak gela sortu du"; +"notice_room_join_rule" = "Elkartzeko baldintza: %@"; +"notice_room_power_level_intro" = "Gelako kideen botere maila:"; +// Others +"user_id_title" = "Erabiltzaile ID-a:"; +"offline" = "deskonektatuta"; +"unsent" = "Bidali gabe"; +"not_supported_yet" = "Oraindik ez da onartzen"; +"default" = "lehenetsia"; +"private" = "Pribatua"; +"public" = "Publikoa"; +"power_level" = "Botere maila"; +"network_error_not_reachable" = "Egiaztatu zure sare konexioa"; +"user_id_placeholder" = "adib: @urko:hasierazerbitzaria"; +"ssl_homeserver_url" = "Hasiera-zerbitzariaren URL-a: %@"; +"language_picker_default_language" = "Lehenetsia (%@)"; +"notice_room_invite" = "%@ erabiltzaileak %@ gonbidatu du"; +"notice_room_third_party_invite" = "%@ erabiltzaileak gelara elkartzeko gonbidapen bat bidali dio %@ erabiltzaileari"; +"notice_room_third_party_registered_invite" = "%@ erabiltzaileak %@ gelarako gonbidapena onartu du"; +"notice_room_join" = "%@ elkartu da"; +"notice_room_leave" = "%@ atera da"; +"notice_room_reject" = "%@ erabiltzaileak gonbidapena baztertu du"; +"notice_room_kick" = "%@ erabiltzaileak %@ kanporatu du"; +"notice_room_unban" = "%@ erabiltzaileak debekua kendu dio %@ erabiltzaileari"; +"notice_room_ban" = "%@ erabiltzaileak %@ debekatu du"; +"notice_profile_change_redacted" = "%@ erabiltzaileak bere %@ profila eguneratu du"; +"notice_room_power_level_acting_requirement" = "Hemen aritu ahal izateko erabiltzaileak behar duen botere maila:"; +"notice_image_attachment" = "irudi-eranskina"; +"notice_audio_attachment" = "audio-eranskina"; +"notice_video_attachment" = "bideo-eranskina"; +"notice_location_attachment" = "kokaleku-eranskina"; +"notice_file_attachment" = "fitxategi-eranskina"; +"notice_invalid_attachment" = "eranskin baliogabea"; +"notice_unsupported_attachment" = "Onartu gabeko eranskina: %@"; +"notice_crypto_unable_to_decrypt" = "** Ezin izan da deszifratu: %@ **"; +"settings_enable_inapp_notifications" = "Gaitu aplikazio barneko jakinarazpenak"; +"notification_settings_room_rule_title" = "Gela: '%@'"; +// Devices +"device_details_title" = "Saioaren informazioa\n"; +"device_details_name" = "Izen publikoa\n"; +"device_details_identifier" = "ID-a\n"; +"device_details_last_seen" = "Azkenekoz ikusia\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_rename_prompt_message" = "Saio baten izen publikoa zurekin komunikatzen den jendeak ikusi dezake"; +// Encryption information +"room_event_encryption_info_title" = "Muturretik muturrerako zifratzearen informazioa\n\n"; +"room_event_encryption_info_event_user_id" = "Erabiltzailearen ID-a\n"; +"room_event_encryption_info_event_identity_key" = "Curve25519 identitate-gakoa\n"; +"room_event_encryption_info_event_algorithm" = "Algoritmoa\n"; +"room_event_encryption_info_event_session_id" = "Saioaren ID-a\n"; +"room_event_encryption_info_event_decryption_error" = "Deszifratze errorea\n"; +"room_event_encryption_info_device" = "\nIgorlearen saioaren informazioa\n"; +"room_event_encryption_info_device_unknown" = "saio ezezaguna\n"; +"room_event_encryption_info_device_name" = "Izen publikoa\n"; +"room_event_encryption_info_device_id" = "ID-a\n"; +"room_event_encryption_info_device_fingerprint" = "Ed25519 hatz-marka\n"; +// Account +"account_save_changes" = "Gorde aldaketak"; +"account_msisdn_validation_error" = "Ezin izan da telefono zenbakia egiaztatu."; +"account_msisdn_validation_title" = "Egiaztaketa egiteke"; +"login_mobile_device" = "Mugikorra"; +"login_tablet_device" = "Tableta"; +"login_desktop_device" = "Mahaigainekoa"; +"notification_settings_people_join_leave_rooms" = "Jakinarazi niri jendea gelera elkartu edo gelatik ateratzean"; +"notice_room_power_level_event_requirement" = "Gertaerekin lotutako gutxieneko botere maila:"; +"notice_room_aliases" = "Gelaren ezizenak: %@"; +"notice_encryption_enabled" = "%@ erabiltzaileak muturretik muturrera zifratzea gaitu du (%@ algoritmoa)"; +"notice_redaction" = "%@ erabiltzaileak gertaera bat kendu du (id: %@)"; +"notice_error_unsupported_event" = "Onartu gabeko gertaera"; +"notice_error_unexpected_event" = "Ustekabeko gertaera"; +"notice_error_unknown_event_type" = "Gertaera mota ezezaguna"; +"notice_room_history_visible_to_anyone" = "%@ erabiltzaileak etorkizuneko gelaren historiala ikusgai jarri du edonorentzat."; +"notice_room_history_visible_to_members" = "%@ erabiltzaileak etorkizuneko gelaren historiala ikusgai jarri du gelako kide guztientzat, gonbidapena egiten zaienetik."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ erabiltzaileak etorkizuneko gelaren historiala ikusgai jarri du gelako kide guztientzat, gonbidapena egiten zaienetik."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ erabiltzaileak etorkizuneko gelaren historiala ikusgai jarri du gelako kide guztientzat, elkartzen direnetik."; +"room_event_encryption_info_event" = "Gertaeraren informazioa\n"; +"room_event_encryption_info_device_verification" = "Egiaztaketa\n"; +"room_event_encryption_verify_title" = "Egiaztatu saioa\n\n"; +"account_link_email" = "Lotu e-maila"; +"account_linked_emails" = "Lotutako e-mailak"; +"account_error_display_name_change_failed" = "Huts egin du pantaila-izenaren aldaketak"; +"account_error_picture_change_failed" = "Huts egin du irudiaren aldaketak"; +"account_error_matrix_session_is_not_opened" = "Matrix saioa ez dago irekita"; +"account_error_msisdn_wrong_title" = "Telefono zenbaki baliogabea"; +"account_error_msisdn_wrong_description" = "Honek ez du baliozko telefono zenbaki baten antzik"; +// Room creation +"room_creation_name_title" = "Gelaren izena:"; +"room_creation_name_placeholder" = "(adib. lagunKuadrilla)"; +"room_creation_alias_title" = "Gelaren ezizena:"; +"room_creation_alias_placeholder" = "(adib. #gela:adibidea.org)"; +"room_creation_alias_placeholder_with_homeserver" = "(adib. #gela%@)"; +"room_creation_participants_title" = "Parte hartzaileak:"; +"room_creation_participants_placeholder" = "(adib. @miren:1zerbitzaria; @peio:2zerbitzaria…)"; +// Room +"room_please_select" = "Hautatu gela bat"; +"room_error_name_edition_not_authorized" = "Ez duzu gela honen izena aldatzeko baimenik"; +"room_error_topic_edition_not_authorized" = "Ez duzu gela honen mintzagaia aldatzeko baimenik"; +"notice_topic_changed" = "%@ erabiltzaileak mintzagaia honetara aldatu du: %@"; +"notice_room_name_changed" = "%@ erabiltzaileak gelaren izena honetara aldatu du: %@"; +"settings_enable_push_notifications" = "Gaitu jakinarazpenak"; +"settings_enter_validation_token_for" = "Sartu %@ balidazio tokena:"; +"room_event_encryption_info_event_fingerprint_key" = "Aldarrikatutako Ed25519 hatz-marka gakoa\n"; +"room_error_cannot_load_timeline" = "Huts egin du denbora lerroa kargatzean"; +"room_left" = "Gelatik atera zara"; +// Room members +"room_member_ignore_prompt" = "Ziur erabiltzaile honen mezu guztiak ezkutatu nahi dituzula?"; +// Attachment +"attachment_size_prompt" = "Nola bidali nahi duzu:"; +"attachment_original" = "Jatorrizko tamaina: %@"; +"attachment_small" = "Txikia: %@"; +"attachment_medium" = "Ertaina: %@"; +"attachment_large" = "Handia: %@"; +"attachment_multiselection_size_prompt" = "Irudiak nola bidali nahi dituzu:"; +"attachment_multiselection_original" = "Jatorrizko tamaina"; +"attachment_e2e_keys_file_prompt" = "Fitxategi honek Matrix bezero batetik esportatutako zifratze gakoak ditu.\nFitxategiaren edukia ikusi nahi duzu edo dauzkan gakoak inportatu?"; +"attachment_e2e_keys_import" = "Inportatu…"; +// Contacts +"contact_mx_users" = "Matrix erabiltzaileak"; +"contact_local_contacts" = "Kontaktu lokalak"; +// Search +"search_no_results" = "Emaitzarik ez"; +"search_searching" = "Bilaketa abian…"; +// Permissions +"camera_access_not_granted_for_call" = "Bideo deiek kamera atzitzeko baimena behar dute baina %@ aplikazioak ez du baimenik"; +"microphone_access_not_granted_for_call" = "Deiek mikrofonoa atzitzeko baimena behar dute baina %@ aplikazioak ez du baimenik"; +"local_contacts_access_discovery_warning_title" = "Erabiltzaileak aurkitzea"; +"local_contacts_access_discovery_warning" = "Matrix darabilten kontaktuak aukitzeko, %@ aplikazioak zure helbide-liburuko kontaktuen e-mail eta telefono zenbakiak igo ahal ditu zuk hautatutako Matrix identitate-zerbitzarira. AHal denean, datu pertsonalak hasheatu egingo dira bidali aurretik, egiaztatu zure identitate-zerbitzariaren pribatutasun politika xehetasun gehiagorako."; +"notice_room_withdraw" = "%@ erabiltzaileak %@ erabiltzailearen gonbidapena atzera bota du"; +"notice_room_reason" = ".Arrazoia: %@"; +"notice_avatar_url_changed" = "%@ erabiltzaileak abatarra aldatu du"; +"notice_display_name_set" = "%@ erabiltzaileak bere pantaila-izena aldatu du beste honetara: %@"; +"notice_display_name_changed_from" = "%@ erabiltzaileak bere pantaila-izena aldatu du, honetatik: %@ honetara: %@"; +"notice_display_name_removed" = "%@ erabiltzaileak bere pantaila-izena kendu du"; +"notice_placed_voice_call" = "%@ erabiltzaileak ahots deia hasi du"; +"notice_placed_video_call" = "%@ erabiltzaileak bideo deia hasi du"; +"notice_answered_video_call" = "%@ erabiltzaileak deia erantzun du"; +"notice_ended_video_call" = "%@ erabiltzaileak deia amaitu du"; +"notice_conference_call_request" = "%@ erabiltzaileak VoIP konferentzia bat eskatu du"; +"set_power_level" = "Ezarri botere-maila"; +// actions +"action_logout" = "Amaitu saioa"; +"login" = "Hasi saioa"; +"create_account" = "Sortu kontua"; +"membership_leave" = "Atera da"; +"membership_ban" = "Debekatua"; +"num_members_one" = "erabiltzaile %@"; +"num_members_other" = "%@ erabiltzaile"; +"unban" = "Kendu debekua"; +"message_unsaved_changes" = "Gorde gabeko aldaketak daude. Irtenez gero baztertuko dira."; +// Login Screen +"login_error_already_logged_in" = "Saioa hasita zegoen jada"; +// contacts list screen +"invitation_message" = "Zurekin hitz egin nahiko nuke matrix erabilita, zoaz http://matrix.org webgunera informazio gehiagorako."; +// Settings screen +"settings_title_config" = "Konfigurazioa"; +// Notification settings screen +"notification_settings_disable_all" = "Desgaitu jakinarazpen guztiak"; +"notification_settings_enable_notifications" = "Gaitu jakinarazpenak"; +"notification_settings_enable_notifications_warning" = "Jakinarazpen guztiak desgaituta daude gailu guztientzat."; +"notification_settings_per_word_notifications" = "Hitzen jakinarazpenak"; +"notification_settings_always_notify" = "Jakinarazi beti"; +"notification_settings_never_notify" = "Ez jakinarazi inoiz"; +"notification_settings_word_to_match" = "behatu beharreko hitza"; +"notification_settings_highlight" = "Nabarmendu"; +"notification_settings_custom_sound" = "Soinu pertsonalizatua"; +"notification_settings_per_room_notifications" = "Gelen jakinarazpenak"; +"notification_settings_per_sender_notifications" = "Igorleen jakinarazpenak"; +"notification_settings_sender_hint" = "@erabiltzailea:domeinua.eus"; +"notification_settings_select_room" = "Hautatu gela bat"; +"notification_settings_other_alerts" = "Beste alertak"; +"notification_settings_contain_my_user_name" = "Jakinarazi niri soinuarekin nire erabiltzaile izena duten mezuak daudenean"; +"notification_settings_contain_my_display_name" = "Jakinarazi niri soinuarekin nire pantaila-izena duten mezuak daudenean"; +"notification_settings_just_sent_to_me" = "Jakinarazi niri soinuarekin niri bakarrik bidalitako mezuak daudenean"; +"notification_settings_invite_to_a_new_room" = "Jakinarazi niri gela batera gonbidatzen nautenean"; +"notification_settings_receive_a_call" = "Jakinarazi niri dei bat jasotzen dudanean"; +"notification_settings_suppress_from_bots" = "Kendu boten jakinarazpenak"; +"notification_settings_by_default" = "Lehenetsita…"; +"notification_settings_notify_all_other" = "Jakinarazi beste mezu/gela guztietarako"; +// gcm section +"settings_config_home_server" = "Hasiera-zerbitzaria: %@"; +"settings_config_identity_server" = "Identitate zerbitzaria: %@"; +"settings_config_user_id" = "Erabiltzaile ID-a: %@"; +// call string +"call_waiting" = "Itxaroten…"; +"call_connecting" = "Deia konektatzen…"; +"call_ring" = "Deitzen…"; +"call_invite_expired" = "Dei-gonbidapena iraungi da"; +"ssl_fingerprint_hash" = "Hatz-marka (%@):"; +"notice_feedback" = "Informazio gertaera (id: %@): %@"; +"room_event_encryption_verify_message" = "Saio hau fidagarria dela egiaztatzeko, jarri kontaktuan jabearekin beste medio batzuk erabilita (adib. aurrez-aurre edo telefonoz deituz) eta galdetu bere erabiltzaile-ezarpenetan ikusten duten gakoa honekin bat datorren:\n\n\tSaioaren izena: %@\n\tSaioaren ID-a: %@\n\tSaioaren gakoa: %@\n\nBat badatoz, sakatu beheko egiaztatu botoia. Ez badatoz bat, beste norbait egon daiteke saioa atzematen eta ziur aski zerrenda beltzaren botoia zapaldu nahiko duzu.\n\nEtorkizunean egiaztaketa prozesu hau sofistikatuagoa izango da."; +"room_error_timeline_event_not_found" = "Aplikazioa gela honen denbora lerroko puntu zehatz bat kargatzen saiatu da baina ezin izan du aurkitu"; +"e2e_import_prompt" = "Prozesu honek aurretik beste Matrix bezero batetik esportatutako zifratze gakoak inportatzea ahalbidetzen du. Beste bezeroak dezifratu zitekeen mezuak deszifratu ahal izango duzu.\nEsportazio fitxategia pasaesaldi batez babestuta dago. Pasaesaldi hori hemen jarri behar duzu fitxategia deszifratzeko."; +"e2e_export_prompt" = "Prozesu honek zifratutako geletan jaso dituzun mezuen gakoak fitxategi lokal batera esportatzea ahalbidetzen dizu. Gero beste Matrix bezero batera inportatu ditzakezu, bezero horrek ere mezu hauek dezifratu ahal izateko.\nEsportatutako fitxategiak berau irakurri dezakeen edonori zuk ikusi ditzakezun mezuak deszifratzea ahalbidetuko dio, beraz kontuz non gordetzen duzun."; +"local_contacts_access_not_granted" = "Kontaktu lokaletatik erabiltzaileak aurkitzeko zure kontaktua atzitzeko baimena behar da baina %@ aplikazioak ez du baimenik"; +"notification_settings_global_info" = "Jakinarazpen ezarpenak zure erabiltzaile kontuan gordetzen dira eta onartutako bezeroen artean partekatzen dira (mahaigaineko jakinarazpenak barne).\n\nArauak ordenan aplikatzen dira; bat datorren lehen arauak zehazten du zer gertatzen den mezuarekin.\nBeraz: Hitzen jakinarazpenak gelen jakinarazpenak baino garrantzitsuagoak dira, eta hauek igorleen jakinarazpenak baino garrantzitsuagoak.\nMota bereko arauentzat, zerrendan bat datorren lehenak lehentasuna hartzen du."; +"notification_settings_per_word_info" = "Hitzak ez dituzte bereizten maiuskula eta minuskulak, eta * komodina izan dezakete. Beraz:\nmar bat dator mar katearekin hitz mugatzaileez inguratuta dagoenean (adib. puntuazioa, zuriunea, lerro hasiera edo bukaera).\nmar* bat dator mar-ez hasten den edozein hitzekin.\n*mar* bat dator edozein posiziotan mar duten hitzekin."; +"notice_room_related_groups" = "Gela honekin lotura duten taldeak hauek dira: %@"; +// Groups +"group_invite_section" = "Gonbidapenak"; +"group_section" = "Taldeak"; +"notice_sticker" = "eranskailua"; +"notice_in_reply_to" = "Honi erantzunez"; +"error_common_message" = "Errore bat gertatu da. Saiatu berriro geroago."; +// Reply to message +"message_reply_to_sender_sent_an_image" = "irudi bat bidali du."; +"message_reply_to_sender_sent_a_video" = "bideo bat bidali du."; +"message_reply_to_sender_sent_an_audio_file" = "audio fitxategi bat bidali du."; +"message_reply_to_sender_sent_a_file" = "fitxategi bat bidali du."; +"message_reply_to_message_to_reply_to_prefix" = "Honi erantzunez"; +"login_error_resource_limit_exceeded_title" = "Baliabide muga gaindituta"; +"login_error_resource_limit_exceeded_message_default" = "Hasiera zerbitzari honek bere baliabide mugetako bat gainditu du."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Hasiera zerbitzari honek bere hilabeteko erabiltzaile aktiboen muga gainditu du."; +"login_error_resource_limit_exceeded_message_contact" = "\n\nJarri kontaktuan zerbitzuaren administratzailearekin zerbitzu hau erabiltzen jarraitzeko."; +"login_error_resource_limit_exceeded_contact_button" = "Kontaktatu administratzailea"; +"e2e_passphrase_create" = "Sortu pasaesaldia"; +"account_error_push_not_allowed" = "Jakinarazpenak ez dira onartzen"; +"device_details_rename_prompt_title" = "Saioaren izena"; +"notice_room_third_party_revoked_invite" = "%@ erabiltzaileak %@ gelara elkartzeko gonbidapena indargabetu du"; +"notice_encryption_enabled_ok" = "%@ erabiltzaileak zifratzea gaitu du."; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ erabiltzaileak zifratzea gaitu du. (%2$@ algoritmo ezezaguna)."; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/fa.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/fa.lproj/MatrixKit.strings new file mode 100644 index 000000000..d2b523506 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/fa.lproj/MatrixKit.strings @@ -0,0 +1,549 @@ + + +"e2e_import_prompt" = "این فرآیند به شما امکان می دهد کلیدهای رمزگذاری را که قبلاً از کلاینت دیگری صادر کرده اید، وارد کنید. سپس می توانید هر پیامی را که کلاینت دیگر رمزگشایی می کند رمزگشایی کنید.\nپرونده با عبارت عبور محافظت می شود. برای رمزگشایی پرونده باید عبارت عبور را در اینجا وارد کنید."; +"room_member_power_level_prompt" = "شما نمی توانید این تغییر را لغو کنید زیرا در حال ارتقا سطح کاربر به همان سطح خودتان هستید.\nآیا مطمئن هستید؟"; +"notification_settings_per_word_info" = "کلمات به صورت غیر حساس(case insensitive)تطابق می‌کنند و ممکن است شامل یک wildcard * باشد. بنابراین:\nfoo با رشته foo که توسط جداکننده‌های کلمه احاطه شده تطابق می‌کند (به عنوان مثال علائم نگارشی و فضای خالی یا شروع و پایان خط).\nfoo* با هر کلمه ای که با foo شروع شود مطابقت دارد.\n*foo* با هر کلمه‌ای که حداقل سه حرف foo را داشته باشد تطابق می‌کند."; +"ssl_only_accept" = "فقط در صورتی که ادمین سرور اثر انگشت متناسب با مورد بالا را منتشر کرده باشد، گواهی را بپذیرید."; +"ssl_expected_existing_expl" = "این گواهی از یک گواهینامه مورد اعتماد قبلی به یک گواهینامه مورد اعتماد دیگر تبدیل شده است. سرور ممکن است گواهینامه خود را تمدید کرده باشد. برای اطمینان از اثر انگشت با ادمین سرور تماس بگیرید."; +"ssl_unexpected_existing_expl" = "این گواهینامه از گواهی مورد اعتماد تلفن شما تغییر کرده است. این بسیار غیر معمول است. توصیه می شود این گواهینامه جدید را قبول نکنید."; +"ssl_cert_new_account_expl" = "درصورتی که ادمین سرور بگوید مشکلی وجود ندارد، اطمینان حاصل کنید که اثر انگشت زیر با اثر انگشت ارائه شده توسط او مطابقت دارد."; +"ssl_cert_not_trust" = "این می تواند به این معنی باشد که شخصی ثالثی ترافیک شما را رهگیری می کند یا اینکه تلفن شما به گواهی ارائه شده توسط سرور اعتماد ندارد."; +"ssl_could_not_verify" = "هویت سرور تأیید نشد."; +"ssl_fingerprint_hash" = "اثر انگشت (%@):"; +"ssl_remain_offline" = "نادیده‌گیری"; +"ssl_logout_account" = "خروج"; + +// unrecognized SSL certificate +"ssl_trust" = "اعتماد کردن"; +"call_more_actions_dialpad" = "پد شماره گیری"; +"call_more_actions_transfer" = "انتقال"; +"call_more_actions_audio_use_device" = "بلندگوی دستگاه"; +"call_more_actions_audio_use_headset" = "استفاده از هدست صوتی"; +"call_more_actions_change_audio_device" = "تغییر خروجی صدا"; +"call_more_actions_unhold" = "از سرگیری"; +"call_more_actions_hold" = "قرار دادن در حالت انتظار"; +"call_holded" = "شما تماس را در حالت انتظار قرار داده‌اید"; +"call_remote_holded" = "%@ تماس را در حالت انتظار قرار داده است"; +"call_invite_expired" = "تماس منقضی شده است"; +"incoming_video_call" = "تماس ویدیویی ورودی"; +"incoming_voice_call" = "تماس صوتی ورودی"; +"call_ring" = "در حال تماس..."; +"call_ended" = "مکالمه تلفنی تمام شد"; +"call_connecting" = "در حال اتصال …"; + +// Settings keys + +// call string +"call_waiting" = "در انتظار..."; +"settings_config_user_id" = "شناسه کاربری: %@"; +"settings_config_identity_server" = "سرور هویت‌سنجی: %@"; + +// gcm section +"settings_config_home_server" = "سرور: %@"; +"notification_settings_notify_all_other" = "برای سایر پیام ها / اتاق ها اطلاع بده"; +"notification_settings_by_default" = "به صورت پیش فرض..."; +"notification_settings_suppress_from_bots" = "اعلان‌های ربات‌ها را سرکوب کن"; +"notification_settings_receive_a_call" = "هنگام دریافت تماس به من اطلاع بده"; +"notification_settings_people_join_leave_rooms" = "وقتی افراد به اتاق می‌پیوندند یا از اتاق خارج می شوند، به من اطلاع بده"; +"notification_settings_invite_to_a_new_room" = "وقتی به اتاق جدیدی دعوت شدم به من اطلاع بده"; +"notification_settings_just_sent_to_me" = "در مورد پیام‌هایی که فقط برای من ارسال شده است با صوت به من اطلاع بده"; +"notification_settings_contain_my_display_name" = "در مورد پیام‌هایی که حاوی نام من هستند با صدا به من اطلاع بده"; +"notification_settings_contain_my_user_name" = "در مورد پیام هایی که حاوی نام کاربری من است با صوت به من اطلاع بده"; +"notification_settings_other_alerts" = "هشدارهای دیگر"; +"notification_settings_select_room" = "اتاقی را انتخاب کنید"; +"notification_settings_sender_hint" = "@user:domain.com"; +"notification_settings_per_sender_notifications" = "اعلان‌های ارسال کننده محور"; +"notification_settings_per_room_notifications" = "اعلان‌های اتاق‌محور"; +"notification_settings_custom_sound" = "صدای سفارشی"; +"notification_settings_highlight" = "هایلایت"; +"notification_settings_word_to_match" = "کلمه مطابقت"; +"notification_settings_never_notify" = "هرگز اطلاع نده"; +"notification_settings_always_notify" = "همیشه اطلاع بده"; +"notification_settings_per_word_notifications" = "اعلان‌های کلمه‌محور"; +"notification_settings_global_info" = "تنظیمات اعلان در حساب کاربری شما ذخیره می شود و بین همه کلاینت‌هایی که از آنها پشتیبانی می کنند به اشتراک گذاشته می شود (از جمله اعلان های دسکتاپ).\n\nقوانین به ترتیب اعمال می شود. اولین قانونی که مطابقت دارد نتیجه پیام را مشخص می کند.\nبنابراین: اعلان‌های کلمه‌محور از اعلان‌های اتاق‌محور مهم‌تر و اعلان‌های اتاق‌محور از اعلان‌های ارسال‌کننده‌محور مهم‌تر هستند.\nبرای چندین قانون از یک نوع، اولین قانونی که در لیست مطابقت دارد در اولویت است."; +"notification_settings_enable_notifications_warning" = "همه اعلان‌ها در حال حاضر برای همه دستگاه‌ها غیرفعال هستند."; + +// room display name +"room_displayname_empty_room" = "اتاق خالی"; +"notice_in_reply_to" = "در پاسخ به"; +"notice_sticker" = "استیکر"; +"notice_crypto_error_unknown_inbound_session_id" = "نشست فرستنده کلیدهای این پیام را برای ما ارسال نکرده است."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ تاریخچه آینده‌ی اتاق را از همان زمانی که افراد به اتاق پیوستند برای آنان قابل مشاهده کرد."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ تاریخچه آینده‌ی اتاق را از همان زمانی که افراد دعوت شده اند برای آنان قابل مشاهده کرد."; +"login_email_info" = "یک ایمیل مشخص کنید تا سایر کاربران بتوانند شما را در ماتریکس با سهولت بیشتری پیدا کنند و به شما راهی برای تنظیم مجدد رمز عبور خود در آینده بدهد."; +"login_identity_server_info" = "ماتریکس سرورهای هویت‌سنجی را برای ردیابی اینکه کدام ایمیل‌ها و غیره متعلق به کدام شناسه‌های ماتریکس هستند فراهم می کند. در حال حاضر فقط https://matrix.org وجود دارد."; +"notice_video_attachment" = "پیوست ویدیویی"; +"notice_audio_attachment" = "پیوست صوتی"; +"notice_image_attachment" = "پیوست تصویر"; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ رمزگذاری سرتاسری را فعال کرد (الگوریتم ناشناخته %2$@)."; +"notice_encryption_enabled_ok" = "%@ رمزگذاری سرتاسری را فعال کرد."; +"notice_encrypted_message" = "پیام رمزگذاری شده"; +"notice_room_related_groups" = "گروه های مرتبط با این اتاق عبارتند از: %@"; +"notice_room_aliases_for_dm" = "نام های مستعار عبارتند از: %@"; +"notice_room_aliases" = "نام مستعار اتاق: %@"; +"notice_room_power_level_event_requirement" = "حداقل سطح قدرت مربوط به رویدادها عبارت است از:"; +"notice_room_power_level_acting_requirement" = "حداقل سطح قدرت که کاربر باید قبل از اقدام داشته باشد:"; +"notice_room_power_level_intro_for_dm" = "سطح قدرت اعضا عبارت است از:"; +"notice_room_power_level_intro" = "سطح قدرت اعضای اتاق عبارت است از:"; +"notice_room_join_rule_public_by_you_for_dm" = "شما اینجا را عمومی کردید."; +"notice_room_join_rule_public_by_you" = "شما اتاق را عمومی کردید."; +"notice_room_join_rule_public_for_dm" = "%@ اینجا را عمومی کرد."; +"notice_room_join_rule_public" = "%@ اتاق را عمومی کرد."; +"notice_room_join_rule_invite_by_you_for_dm" = "شما گفتگو را به حالت \"فقط با دعوت\" تنظیم کردید."; +"notice_room_join_rule_invite_by_you" = "شما اتاق را به حالت \"فقط با دعوت\" تنظیم کردید."; +"notice_room_join_rule_invite_for_dm" = "%@ گفتگو را به حالت \"فقط با دعوت\" تنظیم کرد."; +// New +"notice_room_join_rule_invite" = "%@ اتاق را به حالت \"فقط با دعوت\" تنظیم کرد."; +// Old +"notice_room_join_rule" = "قانون پیوستن: %@"; +"notice_room_created_for_dm" = "%@ پیوست."; +"notice_room_created" = "%@ اتاق را ایجاد و پیکربندی کرد."; +"notice_profile_change_redacted" = "%@ پروفایل خود را بروز کرد %@"; +"notice_event_redacted_reason" = " [علت: %@]"; +"notice_event_redacted_by" = " توسط %@"; +"notice_event_redacted" = "<%@ واکنش نشان داد>"; +"notice_room_topic_removed" = "%@ موضوع را حذف کرد"; +"notice_room_name_removed_for_dm" = "%@ نام را حذف کرد"; +"notice_room_name_removed" = "%@ نام اتاق را حذف کرد"; + +// Events formatter +"notice_avatar_changed_too" = "(آواتار هم تغییر کرد)"; +"unignore" = "عدم نادیده‌گیری"; +"ignore" = "چشم پوشی"; +"resume_call" = "از سرگیری"; +"end_call" = "پایان تماس"; +"reject_call" = "رد تماس"; +"answer_call" = "پاسخ دادن به تماس"; +"show_details" = "نمایش جزئیات"; +"cancel_download" = "لغو بارگیری"; +"cancel_upload" = "لغو بارگذاری"; +"select_all" = "انتخاب همه"; +"resend_message" = "ارسال مجدد پیام"; +"reset_to_default" = "تنظیم به حالت پیش فرض"; +"invite_user" = "دعوت کاربر ماتریکس"; +"capture_media" = "گرفتن عکس/فیلم"; +"attach_media" = "پیوست رسانه از کتابخانه"; +"select_account" = "یک حساب انتخاب کنید"; +"mention" = "اشاره"; +"start_video_call" = "شروع تماس ویدیویی"; +"start_voice_call" = "شروع تماس صوتی"; +"start_chat" = "شروع گفتگو"; +"set_admin" = "تنظیم مدیر"; +"set_moderator" = "تنظیم معاون"; +"set_default_power_level" = "تنظیم مجدد سطح قدرت"; +"set_power_level" = "تنظیم سطح قدرت"; +"submit_code" = "ارسال کد"; +"submit" = "ارسال"; +"sign_up" = "ثبت نام"; +"retry" = "تلاش مجدد"; +"dismiss" = "رد"; +"discard" = "رها کردن"; +"continue" = "ادامه"; +"close" = "بستن"; +"back" = "بازگشت"; +"abort" = "لغو"; +"yes" = "بله"; + +// Action +"no" = "خیر"; +"login_error_resource_limit_exceeded_contact_button" = "تماس با ادمین"; +"login_error_resource_limit_exceeded_message_contact" = "\n\nلطفاً برای ادامه استفاده از این سرویس با سرپرست سرویس خود تماس بگیرید."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "این سرور به محدودیت کاربر فعال ماهانه خود رسیده است."; +"login_error_resource_limit_exceeded_message_default" = "این سرور از یکی از محدودیت های منابع خود فراتر رفته است."; +"login_error_resource_limit_exceeded_title" = "از محدودیت منابع فراتر رفت"; +"login_desktop_device" = "دسکتاپ"; +"login_tablet_device" = "تبلت"; +"login_mobile_device" = "تلفن همراه"; +"login_error_forgot_password_is_not_supported" = "گذرواژه فراموش شده در حال حاضر پشتیبانی نمی شود"; +"register_error_title" = "ثبت نام انجام نشد"; +"login_invalid_param" = "پارامتر نامعتبر است"; +"login_leave_fallback" = "لغو"; +"login_use_fallback" = "استفاده از صفحه بازگشت"; +"login_error_login_email_not_yet" = "لینک ایمیل هنوز کلیک نشده است"; +"login_error_user_in_use" = "این نام کاربری قبلاً استفاده شده است"; +"login_error_limit_exceeded" = "درخواست‌های زیادی ارسال شده است"; +"login_error_not_json" = "حاوی JSON معتبر نبود"; +"login_error_bad_json" = "JSON بد شکل"; +"login_error_unknown_token" = "توکن دسترسی وارد شده معتبر نیست"; +"login_error_forbidden" = "نام کاربری / گذرواژه نامعتبر است"; +"login_error_registration_is_not_supported" = "ثبت نام در حال حاضر پشتیبانی نمی شود"; +"login_error_do_not_support_login_flows" = "در حال حاضر ما از روش ورود به سیستم تعریف شده توسط سرور پشتیبانی نمی کنیم"; +"login_error_no_login_flow" = "ما موفق به بازیابی اطلاعات احراز هویت از این سرور نشدیم"; +"login_error_title" = "ورود ناموفق بود"; +"login_prompt_email_token" = "لطفا کد اعتبارسنجی ایمیل خود را وارد کنید:"; +"login_email_placeholder" = "آدرس ایمیل"; +"login_display_name_placeholder" = "نام (به عنوان مثال محمد حسینی)"; +"login_optional_field" = "اختیاری"; +"login_password_placeholder" = "گذرواژه"; +"login_user_id_placeholder" = "شناسه ماترکیس (به عنوان مثال @bob:matrix.org یا Bob)"; +"login_identity_server_title" = "آدرس سرور هویت‌سنجی:"; +"login_home_server_info" = "سرور تمام مکالمات و داده‌های حساب شما را ذخیره می کند"; +"login_home_server_title" = "آدرس سرور:"; +"login_server_url_placeholder" = "URL (به عنوان مثال https://matrix.org)"; + +// Login Screen +"login_create_account" = "ایجاد حساب:"; +/* *********************** */ +/* iOS specific */ +/* *********************** */ + +"matrix" = "ماتریکس"; +"notification_settings_enable_notifications" = "فعال سازی اعلان‌ها"; + +// Notification settings screen +"notification_settings_disable_all" = "همه اعلان‌ها را غیرفعال کنید"; +"settings_title_notifications" = "اعلان‌ها"; + +// Settings screen +"settings_title_config" = "پیکربندی"; + +// contacts list screen +"invitation_message" = "من دوست دارم با شما با ماتریس گپ بزنم. لطفاً برای کسب اطلاعات بیشتر به وب سایت http://matrix.org مراجعه کنید."; + +// members list Screen + +// accounts list Screen + +// image size selection + +// invitation members list Screen + +// room creation dialog Screen + +// room info dialog Screen + +// room details dialog screen +"room_details_title" = "جزئیات اتاق"; +"login_error_must_start_http" = "آدرس باید با http[s]:// شروع شود"; + +// Login Screen +"login_error_already_logged_in" = "قبلاً وارد سیستم شده‌اید"; +"message_unsaved_changes" = "تغییرات ذخیره نشده‌ای وجود دارد. ترک کردن موجب از بین رفتن این تغییرات می‌شود."; +"unban" = "لغو تحریم"; +"ban" = "تحریم"; +"kick" = "اخراج"; +"invite" = "دعوت"; +"num_members_other" = "%@ کاربر"; +"num_members_one" = "%@ کاربر"; +"membership_ban" = "تحریم"; +"membership_leave" = "ترک کرد"; +"membership_invite" = "دعوت کرد"; +"create_account" = "ایجاد حساب"; +"login" = "ورود"; +"create_room" = "ایجاد اتاق"; + +// actions +"action_logout" = "خروج"; +"view" = "مشاهده"; +"delete" = "حذف"; +"share" = "اشتراک گذاری"; +"redact" = "حذف"; +"resend" = "ارسال مجدد"; +"copy_button_name" = "کپی"; +"send" = "ارسال"; +"leave" = "ترک"; +"save" = "ذخیره"; +"cancel" = "لغو"; + +// Room Screen + +// general errors + +// Home Screen + +// Last seen time + +// call events + +/* -*- + Automatic localization for en + + The following key/value pairs were extracted from the android i18n file: + /console/src/main/res/values/strings.xml. +*/ + + +// titles + +// button names +"ok" = "خب"; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "پیام‌های آینده را از زمان ملحق شدن افراد برای همه قابل مشاهده کردید."; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "شما تاریخچه آینده اتاق را از همان زمانی که به آن ملحق شده‌اند، برای همه اعضای اتاق قابل مشاهده کردید."; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "شما پیام‌های آینده را از زمان دعوت شدن برای همه قابل مشاهده کردید."; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "شما تاریخچه آینده اتاق را از همان زمانی که دعوت شده‌اند ، برای همه اعضای اتاق قابل مشاهده کردید."; +"notice_room_history_visible_to_members_by_you_for_dm" = "شما پیام‌های آینده را برای همه اعضای اتاق قابل مشاهده کردید."; +"notice_room_history_visible_to_anyone_by_you" = "شما تاریخچه آینده اتاق را برای همه قابل مشاهده کردید."; +"notice_room_history_visible_to_members_by_you" = "شما تاریخچه آینده اتاق را برای همه اعضای اتاق قابل مشاهده کردید."; +"notice_redaction_by_you" = "شما یک رویداد را مجدداً ویرایش کردید (شناسه: %@)"; +"notice_encryption_enabled_unknown_algorithm_by_you" = "رمزگذاری سرتاسری را فعال کردید (الگوریتم ناشناخته %@)."; +"notice_encryption_enabled_ok_by_you" = "رمزگذاری سرتاسری را فعال کردید."; +"notice_room_created_by_you" = "شما اتاق را ایجاد و پیکربندی کردید."; +"notice_profile_change_redacted_by_you" = "شما نمایه خود را به روز کردید %@"; +"notice_event_redacted_by_you" = " توسط شما"; +"notice_room_topic_removed_by_you" = "موضوع را حذف کردید"; +"notice_room_name_removed_by_you_for_dm" = "نام را حذف کردید"; +"notice_room_name_removed_by_you" = "نام اتاق را حذف کردید"; +"notice_conference_call_request_by_you" = "شما درخواست جلسه دادید"; +"notice_declined_video_call_by_you" = "شما تماس را رد کردید"; +"notice_ended_video_call_by_you" = "تماس را تمام کردید"; +"notice_answered_video_call_by_you" = "شما به تماس پاسخ دادید"; +"notice_placed_video_call_by_you" = "شما یک تماس ویدیویی برقرار کردید"; +"notice_placed_voice_call_by_you" = "شما یک تماس صوتی برقرار کردید"; +"notice_room_name_changed_by_you_for_dm" = "نام را به %@ تغییر دادید."; +"notice_room_name_changed_by_you" = "نام اتاق را به %@ تغییر دادید."; +"notice_topic_changed_by_you" = "شما موضوع را به \"%@\" تغییر دادید."; +"notice_display_name_removed_by_you" = "نام خود را حذف کردید"; +"notice_display_name_changed_from_by_you" = "نام خود را از %@ به %@ تغییر دادید"; +"notice_display_name_set_by_you" = "نام خود را به %@ تنظیم کردید"; +"notice_avatar_url_changed_by_you" = "آواتار خود را تغییر دادید"; +"notice_room_withdraw_by_you" = "شما دعوت %@ را پس گرفتید"; +"notice_room_ban_by_you" = "شما %@ را تحریم کردید"; +"notice_room_unban_by_you" = "شما %@ را از تحریم خارج کردید"; +"notice_room_kick_by_you" = "شما %@ را اخراج کردید"; +"notice_room_reject_by_you" = "شما دعوت را رد کردید"; +"notice_room_leave_by_you" = "خارج شدید"; +"notice_room_join_by_you" = "شما پیوستید"; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "شما دعوت %@ را لغو کردید"; +"notice_room_third_party_revoked_invite_by_you" = "شما دعوت از %@ برای پیوستن به اتاق را لغو کردید"; +"notice_room_third_party_registered_invite_by_you" = "شما دعوت %@ را پذیرفتید"; +"notice_room_third_party_invite_by_you_for_dm" = "شما %@ را دعوت کردید"; +"notice_room_third_party_invite_by_you" = "شما دعوت‌نامه‌ای به %@ برای پیوستن به اتاق ارسال کرده‌اید"; +"notice_room_invite_you" = "%@ شما را دعوت کرد"; + +// Notice Events with "You" +"notice_room_invite_by_you" = "شما %@ را دعوت کردید"; +"notice_conference_call_finished" = "جلسه به پایان رسید"; +"notice_conference_call_started" = "جلسه آغاز شد"; +"notice_conference_call_request" = "%@ درخواست جلسه مجازی کرد"; +"notice_declined_video_call" = "%@ تماس را رد کرد"; +"notice_ended_video_call" = "%@ به تماس پایان داد"; +"notice_answered_video_call" = "%@ به تماس پاسخ داد"; +"notice_placed_video_call" = "%@ تماس تصویری برقرار کرد"; +"notice_placed_voice_call" = "%@ تماس صوتی برقرار کرد"; +"notice_room_name_changed_for_dm" = "%@ نام را به %@ تغییر داد."; +"notice_room_name_changed" = "%@ نام اتاق را به %@ تغییر داد."; +"notice_topic_changed" = "%@ موضوع را به \"%@\" تغییر داد."; +"notice_display_name_removed" = "%@ نام خود را حذف کرد"; +"notice_display_name_changed_from" = "%@ نام خود را از %@ به %@ تغییر داد"; +"notice_display_name_set" = "%@ نام خود را به %@ تنظیم کرد"; +"notice_avatar_url_changed" = "%@ آواتار خود را تغییر داد"; +"notice_room_reason" = ". دلیل: %@"; +"notice_room_withdraw" = "%@ دعوت %@ را پس گرفت"; +"notice_room_ban" = "%@ %@ را تحریم کرد"; +"notice_room_unban" = "%@ %@ را از تحریم خارج کرد"; +"notice_room_kick" = "%@ %@ را اخراج کرد"; +"notice_room_reject" = "%@ دعوت را رد کرد"; +"notice_room_leave" = "%@ ترک کرد"; +"notice_room_join" = "%@ پیوست"; +"notice_room_third_party_revoked_invite_for_dm" = "%@ دعوت %@ را لغو کرد"; +"notice_room_third_party_revoked_invite" = "%@ دعوت از %@ برای پیوستن به اتاق را لغو کرد"; +"notice_room_third_party_registered_invite" = "%@ دعوت %@ را پذیرفت"; +"notice_room_third_party_invite_for_dm" = "%@ %@ را دعوت کرد"; +"notice_room_third_party_invite" = "%@ برای پیوستن به اتاق به %@ دعوتنامه ارسال کرد"; + +/* -*- + Automatic localization for en + + The following key/value pairs were extracted from the android i18n file: + /matrix-sdk/src/main/res/values/strings.xml. +*/ + +"notice_room_invite" = "%@ %@ را دعوت کرد"; +"language_picker_default_language" = "پیش فرض (%@)"; + +// Language picker +"language_picker_title" = "انتخاب زبان"; + +// Country picker +"country_picker_title" = "یک کشور را انتخاب کنید"; +"local_contacts_access_not_granted" = "کشف کاربران از دفترچه مخاطبین نیاز به دسترسی به مخاطبین شما دارد اما %@ اجازه استفاده از آنها را ندارد"; +"local_contacts_access_discovery_warning" = "برای کشف مخاطبی که در حال حاظر از ماتریکس استفاده می‌کند، %@ می تواند آدرس‌های ایمیل و شماره تلفن‌های موجود در دفترچه آدرس خود را به سرور هویت‌سنجی انتخابی شما ارسال کند. در صورت پشتیبانی، داده های شخصی قبل از ارسال هش می شوند - لطفا برای اطلاعات بیشتر سیاست حفظ حریم خصوصی سرور هویت‌سنجی خود را بررسی کنید."; +"local_contacts_access_discovery_warning_title" = "کشف کاربران"; +"microphone_access_not_granted_for_call" = "تماس ها نیاز به دسترسی به میکروفن دارند اما %@ اجازه استفاده از آن را ندارد"; + +// Permissions +"camera_access_not_granted_for_call" = "تماس های ویدئویی نیاز به دسترسی به دوربین دارند اما %@ اجازه استفاده از آن را ندارد"; +"ssl_homeserver_url" = "آدرس سرور: %@"; +"user_id_placeholder" = "مثال: @akbar:homeserver"; +"network_error_not_reachable" = "لطفاً اتصال شبکه خود را بررسی کنید"; +"power_level" = "سطح قدرت"; +"public" = "عمومی"; +"private" = "خصوصی"; +"not_supported_yet" = "در حال حاظر پشتیبانی نمی‌شود"; +"error_common_message" = "خطایی رخ داد لطفاً بعداً دوباره امتحان کنید."; +"error" = "خطا"; +"unsent" = "ارسال نشده"; + +// Others +"user_id_title" = "شناسه کاربری:"; +"e2e_passphrase_create" = "ایجاد عبارت عبور"; +"e2e_passphrase_not_match" = "عبارات عبور باید مطابقت داشته باشند"; +"e2e_passphrase_empty" = "عبارت عبور نباید خالی باشد"; +"e2e_passphrase_confirm" = "عبارت عبور را تأیید کنید"; +"e2e_export" = "ذخیره"; +"e2e_export_prompt" = "این فرآیند به شما امکان می دهد کلیدهای پیام هایی را که در اتاق های رمزگذاری شده دریافت کرده‌اید در یک فایل ذخیره کنید. سپس می توانید فایل را در آینده به یک کلاینت دیگر وارد کنید، بنابراین کلاینت همچنین می تواند این پیام ها را رمزگشایی کند.\nفایل ذخیره شده به هر کسی که می تواند آن را بخواند اجازه می دهد تا پیام‌های رمزگذاری شده‌ای را که می بینید رمزگشایی کند، بنابراین باید مراقب امنیت آن باشید."; + +// E2E export +"e2e_export_room_keys" = "کلیدهای اتاق را صادر کنید"; +"e2e_passphrase_enter" = "عبارت عبور را وارد کنید"; +"e2e_import" = "وارد كردن"; +"notice_room_created_by_you_for_dm" = "شما پیوستید."; +"default" = "پیش‌فرض"; +"offline" = "آفلاین"; + +// E2E import +"e2e_import_room_keys" = "ورود کلیدهای اتاق"; +"format_time_d" = "d"; +"format_time_h" = "h"; +"format_time_m" = "m"; + +// Time +"format_time_s" = "s"; +"search_searching" = "در حال جستجو ..."; + +// Search +"search_no_results" = "بدون نتیجه"; +"group_section" = "گروه‌ها"; + +// Groups +"group_invite_section" = "دعوت‌ها"; +"contact_local_contacts" = "مخاطبین محلی"; + +// Contacts +"contact_mx_users" = "کاربران ماتریکس"; +"attachment_e2e_keys_import" = "وارد كردن..."; +"attachment_e2e_keys_file_prompt" = "این پرونده شامل کلیدهای رمزگذاری کلاینت ماتریکس است.\nآیا می خواهید محتوای پرونده را مشاهده کنید یا کلیدهای موجود در آن را وارد کنید؟"; +"attachment_multiselection_original" = "اندازه واقعی"; +"attachment_multiselection_size_prompt" = "تصاویر به چه صورت ارسال شود:"; + +// Attachment +"attachment_size_prompt" = "می‌خواهید به چه صورت ارسال کنید:"; +"attachment_cancel_upload" = "بارگذاری لغو شود؟"; +"attachment_cancel_download" = "بارگیری لغو شود؟"; +"attachment_large" = "بزرگ: %@"; +"attachment_medium" = "متوسط: %@"; +"attachment_small" = "کوچک: %@"; +"attachment_original" = "اندازه واقعی: %@"; + +// Room members +"room_member_ignore_prompt" = "آیا مطمئن هستید که می خواهید همه پیام های این کاربر را پنهان کنید؟"; +"message_reply_to_message_to_reply_to_prefix" = "در پاسخ به"; +"message_reply_to_sender_sent_a_file" = "پرونده‌ای ارسال شد."; +"message_reply_to_sender_sent_an_audio_file" = "یک فایل صوتی ارسال شد."; + +// Reply to message +"message_reply_to_sender_sent_an_image" = "یک تصویر ارسال شد."; +"message_reply_to_sender_sent_a_video" = "یک ویدیو ارسال شد."; +"room_no_conference_call_in_encrypted_rooms" = "جلسات در اتاق های رمزگذاری شده پشتیبانی نمی شوند"; +"room_no_power_to_create_conference_call" = "برای شروع جلسه در این اتاق نیاز به دسترسی دعوت دارید"; +"room_left_for_dm" = "خارج شدید"; +"room_left" = "شما از اتاق خارج شدید"; +"room_error_timeline_event_not_found" = "برنامه سعی داشت نقطه خاصی را در پیام‌های این اتاق بارگیری کند اما نتوانست آن را پیدا کند"; +"room_error_timeline_event_not_found_title" = "خطا در بارگیری موقعیت پیام‌ها"; +"room_error_cannot_load_timeline" = "خطا در بارگیری پیام‌ها"; +"room_error_topic_edition_not_authorized" = "شما مجاز به ویرایش موضوع در اتاق نیستید"; +"room_error_name_edition_not_authorized" = "شما مجاز به ویرایش این نام اتاق نیستید"; +"room_error_join_failed_empty_room" = "در حال حاضر امکان عضویت مجدد در یک اتاق خالی وجود ندارد."; +"room_error_join_failed_title" = "پیوستن به اتاق با خطا مواجه شد"; + +// Room +"room_please_select" = "لطفا یک اتاق انتخاب کنید"; +"room_creation_participants_placeholder" = "(به عنوان مثال، @ali:homeserver1; @akbar:homeserver1; ...)"; +"room_creation_participants_title" = "شركت كنندگان:"; +"room_creation_alias_placeholder_with_homeserver" = "(به عنوان مثال، #foo%@)"; +"room_creation_alias_placeholder" = "(به عنوان مثال #foo:example.org)"; +"room_creation_alias_title" = "نام مستعار اتاق:"; +"room_creation_name_placeholder" = "به عنوان مثال(گروه ناهار)"; + +// Room creation +"room_creation_name_title" = "نام اتاق:"; +"account_error_push_not_allowed" = "اعلان مجاز نیست"; +"account_error_msisdn_wrong_description" = "به نظر نمی رسد این شماره تلفن معتبری باشد"; +"account_error_msisdn_wrong_title" = "شماره تلفن نامعتبر"; +"account_error_email_wrong_description" = "به نظر نمی رسد این یک آدرس ایمیل معتبر باشد"; +"account_error_email_wrong_title" = "آدرس ایمیل نامعتبر است"; +"account_error_matrix_session_is_not_opened" = "نشست ماتریس باز نیست"; +"account_error_picture_change_failed" = "تغییر تصویر انجام نشد"; +"account_error_display_name_change_failed" = "تغییر نام ناموفق بود"; +"account_msisdn_validation_error" = "تأیید شماره تلفن امکان پذیر نیست."; +"account_msisdn_validation_message" = "ما یک پیامک با کد فعال‌سازی ارسال کرده‌ایم. لطفاً این کد را در زیر وارد کنید."; +"account_msisdn_validation_title" = "در انتظار تأیید"; +"room_event_encryption_verify_message" = "برای تأیید اینکه این نشست قابل اعتماد است، لطفاً با استفاده از روشهای دیگر (مثلاً به صورت حضوری یا تماس تلفنی) با مالک آن تماس بگیرید و از آنها سوال کنید که آیا کلیدی که در تنظیمات کاربر خود برای این نشست می بینند با کلید زیر مطابقت دارد:\n\n نام نشست: %@\nشناسه نشست: %@\nکلید نشست: %@\n\nدر صورت مطابقت ، دکمه تأیید را در زیر فشار دهید. در صورت عدم تطابق، شخص ثالثی این نشست را رهگیری می کند و شما قاعدتا باید دکمه لیست سیاه را فشار دهید.\n\nدر آینده این روند تأیید پیچیده تر خواهد بود."; +"account_email_validation_error" = "تأیید آدرس ایمیل انجام نشد. لطفاً ایمیل خود را بررسی کرده و روی پیوند حاوی آن کلیک کنید. پس از انجام این کار ، روی ادامه کلیک کنید"; +"account_email_validation_message" = "لطفاً ایمیل خود را بررسی کرده و روی پیوند حاوی آن کلیک کنید. پس از انجام این کار ، روی ادامه کلیک کنید."; +"account_email_validation_title" = "در انتظار تایید"; +"account_linked_emails" = "ایمیل های متصل شده"; +"account_link_email" = "اتصال به ایمیل"; + +// Account +"account_save_changes" = "ذخیره تغییرات"; +"room_event_encryption_verify_ok" = "تأیید"; +"room_event_encryption_verify_title" = "تایید نشست\n\n"; +"room_event_encryption_info_unblock" = "خروج از لیست سیاه"; +"room_event_encryption_info_block" = "لیست سیاه"; +"room_event_encryption_info_unverify" = "تأیید نکردن"; +"room_event_encryption_info_verify" = "تأیید کنید ..."; +"room_event_encryption_info_device_blocked" = "در لیست سیاه قرار گرفت"; +"room_event_encryption_info_device_not_verified" = "تأیید نشده است"; +"room_event_encryption_info_device_verified" = "تأیید شده"; +"room_event_encryption_info_device_fingerprint" = "اثر انگشت Ed25519\n"; +"room_event_encryption_info_device_verification" = "تأیید هویت\n"; +"room_event_encryption_info_device_id" = "شناسه\n"; +"room_event_encryption_info_device_name" = "نام عمومی\n"; +"room_event_encryption_info_device_unknown" = "نشست ناشناخته\n"; +"room_event_encryption_info_device" = "\nاطلاعات نشست ارسال کننده\n"; +"room_event_encryption_info_event_none" = "هیچ یک"; +"room_event_encryption_info_event_unencrypted" = "رمزگذاری نشده"; +"room_event_encryption_info_event_decryption_error" = "خطای رمزگشایی\n"; +"room_event_encryption_info_event_session_id" = "شناسه جلسه\n"; +"room_event_encryption_info_event_algorithm" = "الگوریتم\n"; +"room_event_encryption_info_event_fingerprint_key" = "کلید اثر انگشت Ed25519 ادعا شده\n"; +"room_event_encryption_info_event_identity_key" = "کلید Curve25519\n"; +"room_event_encryption_info_event_user_id" = "شناسه کاربر\n"; +"room_event_encryption_info_event" = "اطلاعات رویداد\n"; + +// Encryption information +"room_event_encryption_info_title" = "اطلاعات رمزنگاری سرتاسری\n\n"; +"device_details_delete_prompt_message" = "این عملیات نیاز به احراز هویت مجدد دارد.\nبرای ادامه ، لطفاً گذرواژه خود را وارد کنید."; +"device_details_delete_prompt_title" = "احراز هویت"; +"device_details_rename_prompt_message" = "نام عمومی یک نشست برای افرادی که با آنها ارتباط برقرار می کنید قابل مشاهده است"; +"device_details_rename_prompt_title" = "نام نشست"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_last_seen" = "آخرین بازدید\n"; +"device_details_identifier" = "شناسه\n"; +"device_details_name" = "نام عمومی\n"; + +// Devices +"device_details_title" = "اطلاعات نشست\n"; +"notification_settings_room_rule_title" = "اتاق: %@"; +"settings_enter_validation_token_for" = "توکن فعال‌سازی را برای %@ وارد کنید:"; +"settings_enable_push_notifications" = "فعال‌سازی اعلان"; +"settings_enable_inapp_notifications" = "فعال‌سازی اعلان درون برنامه ای"; + +// Settings +"settings" = "تنظیمات"; +"room_displayname_more_than_two_members" = "%@ و %@ نفر دیگر"; +"room_displayname_two_members" = "%@ و %@"; +"notice_crypto_unable_to_decrypt" = "** رمزگشایی امکان پذیر نیست: %@ **"; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ پیام های آینده را از زمان پیوستن افراد قابل مشاهده کرد."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ پیام های آینده را از زمان دعوت شدن برای همه قابل مشاهده کرد."; +"notice_room_history_visible_to_members_for_dm" = "%@ پیام‌های آینده را برای همه اعضای اتاق قابل مشاهده کرد."; +"notice_room_history_visible_to_members" = "%@ تاریخچه آینده اتاق را برای همه اعضای اتاق قابل مشاهده کرد."; +"notice_room_history_visible_to_anyone" = "%@ تاریخچه آینده اتاق را برای همه قابل مشاهده کرد."; +"notice_error_unknown_event_type" = "نوع رویداد ناشناخته"; +"notice_error_unexpected_event" = "رویداد غیرمنتظره"; +"notice_error_unsupported_event" = "رویداد پشتیبانی نشده"; +"notice_redaction" = "%@ یک رویداد را تغییر داد (شناسه: %@)"; +"notice_feedback" = "بازخورد (شناسه: %@): %@"; +"notice_unsupported_attachment" = "پیوست پشتیبانی نشده: %@"; +"notice_invalid_attachment" = "پیوست نامعتبر"; +"notice_file_attachment" = "پیوست پرونده"; +"notice_location_attachment" = "پیوست مکان"; +"call_transfer_to_user" = "انتقال به %@"; +"call_consulting_with_user" = "تماس با %@"; +"call_video_with_user" = "تماس تصویری با %@"; +"call_voice_with_user" = "تماس صوتی با %@"; +"call_ringing" = "در حال زنگ خوردن…"; +"microphone_access_not_granted_for_voice_message" = "جهت ارسال پیام صوتی نیاز به دسترسی به میکروفون وجود دارد اما %@ دسترسی استفاده از آن را ندارد"; +"e2e_passphrase_too_short" = "کلمه عبور بیش از حد کوتاه است (حداقل می‌بایست %d کاراکتر باشد)"; +"message_reply_to_sender_sent_a_voice_message" = "یک پیام صوتی ارسال کنید."; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/fi.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/fi.lproj/MatrixKit.strings new file mode 100644 index 000000000..08a9690f9 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/fi.lproj/MatrixKit.strings @@ -0,0 +1,151 @@ +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "Luo tili:"; +"login_server_url_placeholder" = "URL (esim. https://matrix.org)"; +"login_home_server_title" = "Kotipalvelin:"; +"login_identity_server_title" = "Identiteettipalvelin:"; +"view" = "Näytä"; +"back" = "Takaisin"; +"continue" = "Jatka"; +"leave" = "Poistu"; +"invite" = "Kutsu"; +"retry" = "Yritä uudelleen"; +"cancel" = "Peruuta"; +"save" = "Tallenna"; +"login_password_placeholder" = "Salasana"; +"login_optional_field" = "valinnainen"; +"login_home_server_info" = "Kotipalvelin tallentaa kaikki keskustelu- ja tilitietosi"; +"login_display_name_placeholder" = "Näyttönimi (esim. Matti Meikäläinen)"; +"login_email_placeholder" = "Sähköpostiosoite"; +"login_error_title" = "Kirjautuminen epäonnistui"; +"login_error_registration_is_not_supported" = "Rekisteröinti ei ole tuettu tällä hetkellä"; +"login_error_forbidden" = "Virheellinen käyttäjätunnus tai salasana"; +"login_error_limit_exceeded" = "Liian monta pyyntöä on lähetetty"; +"login_leave_fallback" = "Peruuta"; +"register_error_title" = "Rekisteröinti epäonnistui"; +"login_mobile_device" = "Mobiili"; +"login_tablet_device" = "Tabletti"; +"login_desktop_device" = "Työpöytä"; +"close" = "Sulje"; +"select_account" = "Valitse tili"; +"invite_user" = "Kutsu Matrix-käyttäjä"; +"select_all" = "Valitse kaikki"; +"cancel_upload" = "Peruuta lähetys"; +"cancel_download" = "Peruuta lataus"; +"show_details" = "Näytä tiedot"; +"answer_call" = "Vastaa puheluun"; +"reject_call" = "Hylkää puhelu"; +"end_call" = "Lopeta puhelu"; +"notice_event_redacted_reason" = " [syy: %@]"; +"notice_encrypted_message" = "Salattu viesti"; +"notice_image_attachment" = "kuvaliite"; +"notice_audio_attachment" = "ääniliite"; +"notice_video_attachment" = "videoliite"; +"notice_location_attachment" = "sijaintiliite"; +"notice_file_attachment" = "tiedostoliite"; +"notice_invalid_attachment" = "virheellinen liite"; +// room display name +"room_displayname_empty_room" = "Tyhjä huone"; +"room_displayname_two_members" = "%@ ja %@"; +// Settings +"settings" = "Asetukset"; +"notification_settings_room_rule_title" = "Huone: '%@'"; +"device_details_name" = "Julkinen nimi\n"; +"device_details_last_seen" = "Viimeksi nähty\n"; +"device_details_rename_prompt_message" = "Istunnon julkinen nimi näkyy henkilöille, joiden kanssa keskustelet"; +"room_event_encryption_info_event_algorithm" = "Algoritmi\n"; +"room_event_encryption_info_event_session_id" = "Istunnon tunnus\n"; +"room_event_encryption_info_device_unknown" = "tuntematon istunto\n"; +"room_event_encryption_info_device_name" = "Julkinen nimi\n"; +// Account +"account_save_changes" = "Tallenna muutokset"; +"account_error_matrix_session_is_not_opened" = "Matrix-istunto ei ole avattuna"; +"account_error_email_wrong_title" = "Virheellinen sähköpostiosoite"; +"account_error_email_wrong_description" = "Tämä ei vaikuta kelvolliselta sähköpostiosoitteelta"; +"account_error_msisdn_wrong_title" = "Virheellinen puhelinnumero"; +"account_error_msisdn_wrong_description" = "Tämä ei vaikuta kelvolliselta puhelinnumerolta"; +// Room creation +"room_creation_name_title" = "Huoneen nimi:"; +// Room +"room_please_select" = "Valitse huone"; +"room_error_join_failed_title" = "Liittyminen huoneeseen epäonnistui"; +"room_left" = "Poistuit huoneesta"; +// Reply to message +"message_reply_to_sender_sent_an_image" = "lähetti kuvan."; +"message_reply_to_sender_sent_a_video" = "lähetti videon."; +"message_reply_to_sender_sent_an_audio_file" = "lähetti äänitiedoston."; +"message_reply_to_sender_sent_a_file" = "lähetti tiedoston."; +"attachment_original" = "Todellinen koko: %@"; +"attachment_cancel_download" = "Perutaanko lataus?"; +"attachment_cancel_upload" = "Perutaanko lähetys?"; +"attachment_multiselection_original" = "Todellinen koko"; +"attachment_e2e_keys_import" = "Tuo..."; +// Contacts +"contact_mx_users" = "Matrix-käyttäjät"; +"contact_local_contacts" = "Paikalliset yhteystiedot"; +"group_section" = "Ryhmät"; +// Search +"search_no_results" = "Ei tuloksia"; +"search_searching" = "Haku käynnissä..."; +"e2e_import" = "Tuo"; +"e2e_export" = "Vie"; +"error" = "Virhe"; +"default" = "oletus"; +"private" = "Yksityinen"; +"public" = "Julkinen"; +"network_error_not_reachable" = "Tarkista verkkoyhteytesi"; +// Country picker +"country_picker_title" = "Valitse maa"; +// Language picker +"language_picker_title" = "Valitse kieli"; +"language_picker_default_language" = "Oletus (%@)"; +"notice_answered_video_call" = "%@ vastasi puheluun"; +"notice_ended_video_call" = "%@ lopetti puhelun"; +// button names +"ok" = "OK"; +"send" = "Lähetä"; +"copy_button_name" = "Kopioi"; +"redact" = "Poista"; +"share" = "Jaa"; +"delete" = "Poista"; +// actions +"action_logout" = "Kirjaudu ulos"; +"create_room" = "Luo huone"; +"login" = "Kirjaudu sisään"; +"create_account" = "Luo tili"; +// Login Screen +"login_error_already_logged_in" = "Jo sisäänkirjautuneena"; +// room details dialog screen +"room_details_title" = "Huoneen tiedot"; +"settings_title_notifications" = "Ilmoitukset"; +// Notification settings screen +"notification_settings_disable_all" = "Poista käytöstä kaikki ilmoitukset"; +"notification_settings_enable_notifications" = "Käytä ilmoituksia"; +"notification_settings_always_notify" = "Ilmoita aina"; +"notification_settings_never_notify" = "Älä ilmoita koskaan"; +"notification_settings_select_room" = "Valitse huone"; +// gcm section +"settings_config_home_server" = "Kotipalvelin: %@"; +"settings_config_identity_server" = "Identiteettipalvelin: %@"; +// call string +"call_waiting" = "Odottaa..."; +"call_connecting" = "Puhelu yhdistyy..."; +"call_ended" = "Puhelu loppui"; +"call_ring" = "Soitetaan..."; +"incoming_video_call" = "Saapuva videopuhelu"; +"incoming_voice_call" = "Saapuva äänipuhelu"; +"call_invite_expired" = "Puhelukutsu vanhentui"; +"ssl_logout_account" = "Kirjaudu ulos"; +"ssl_fingerprint_hash" = "Sormenjälki (%@):"; +"room_event_encryption_info_verify" = "Vahvista..."; +"room_event_encryption_info_unverify" = "Poista vahvistus"; +"room_event_encryption_verify_title" = "Varmenna istunto\n\n"; +"room_event_encryption_verify_ok" = "Vahvista"; +"account_email_validation_error" = "Sähköpostin vahvistaminen epäonnistui. Tarkistathan sähköpostisi ja seuraa linkkiä, joka on lähettämässämme viestissä. Sen jälkeen, täppää jatka"; +"account_msisdn_validation_title" = "Vahvistus meneillään"; +"account_msisdn_validation_error" = "Puhelinnumeron vahvistus epäonnistui."; +"account_error_display_name_change_failed" = "Näyttönimen vaihtaminen epäonnistui"; +"account_error_picture_change_failed" = "Kuvan vaihtaminen epäonnistui"; +"ssl_could_not_verify" = "Etäpalvelimen identiteetin vahvistaminen epäonnistui."; +"login_user_id_placeholder" = "Matrix ID (esim. @matti:matrix.org tai pelkästään matti)"; +"login_identity_server_info" = "Matrix tarjoaa identiteettipalvelimen joka osaa kertaa mikä sähköpostiosoite tai puhelinnumero vastaa mitäkin Matrix ID:tä. Vain https://matrix.org on tällä hetkellä käytettävissä."; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/fr.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/fr.lproj/MatrixKit.strings new file mode 100644 index 000000000..758c3a3cb --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/fr.lproj/MatrixKit.strings @@ -0,0 +1,475 @@ +"view" = "Afficher"; +"back" = "Retour"; +"continue" = "Continuer"; +"leave" = "Quitter"; +"invite" = "Inviter"; +"retry" = "Réessayer"; +"cancel" = "Annuler"; +"save" = "Enregistrer"; +// room details dialog screen +"room_details_title" = "Détails du salon"; +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "Créer un compte :"; +"login_server_url_placeholder" = "URL (par ex. https://matrix.org)"; +"login_home_server_title" = "URL du serveur d’accueil :"; +"login_home_server_info" = "Votre serveur d’accueil stocke toutes vos discussions et les données de votre compte"; +"login_identity_server_title" = "URL du serveur d’identité :"; +"login_identity_server_info" = "Matrix fournit des serveurs d’identité pour lier les e-mail, etc aux identifiants Matrix. Seul https://matrix.org existe pour l’instant."; +"login_user_id_placeholder" = "Identifiant Matrix (par ex. @bob:matrix.org ou bob)"; +"login_password_placeholder" = "Mot de passe"; +"login_optional_field" = "facultatif"; +"login_display_name_placeholder" = "Nom d’affichage (par ex. Bob Obson)"; +"login_email_info" = "Définir une adresse e-mail permet aux autres utilisateurs de vous retrouver plus facilement sur Matrix et vous permettra de réinitialiser votre mot de passe."; +"login_email_placeholder" = "Adresse e-mail"; +"login_prompt_email_token" = "Veuillez saisir votre jeton de validation d’e-mail :"; +"login_error_title" = "Échec d’authentification"; +"login_error_no_login_flow" = "Échec de la récupération des informations d’authentification depuis ce serveur d’accueil"; +"login_error_do_not_support_login_flows" = "Aucun des parcours d’authentification définis par ce serveur d'accueil n’est pris en charge pour le moment"; +"login_error_registration_is_not_supported" = "L’inscription n'est pas prise en charge pour le moment"; +"login_error_forbidden" = "Nom d’utilisateur ou mot de passe invalide"; +"login_error_unknown_token" = "Le jeton d’accès spécifié n'est pas reconnu"; +"login_error_bad_json" = "JSON mal formaté"; +"login_error_not_json" = "Ne contenait pas de JSON valide"; +"login_error_limit_exceeded" = "Trop de requêtes ont été envoyées"; +"login_error_user_in_use" = "Ce nom d’utilisateur est déjà utilisé"; +"login_error_login_email_not_yet" = "Vous n’avez pas encore cliqué sur le lien dans l’e-mail"; +"login_use_fallback" = "Utiliser la page de secours"; +"login_leave_fallback" = "Annuler"; +"login_invalid_param" = "Paramètre invalide"; +"register_error_title" = "Échec lors de l’inscription"; +"login_error_forgot_password_is_not_supported" = "La réinitialisation du mot de passe n’est pas prise en charge pour le moment"; +// Action +"no" = "Non"; +"yes" = "Oui"; +"abort" = "Abandonner"; +"close" = "Fermer"; +"discard" = "Abandonner"; +"dismiss" = "Ignorer"; +"sign_up" = "S’inscrire"; +"submit" = "Valider"; +"submit_code" = "Envoyer le code"; +"set_default_power_level" = "Réinitialiser le rang"; +"set_moderator" = "Nommer modérateur"; +"set_admin" = "Nommer administrateur"; +"start_chat" = "Nouvelle conversation privée"; +"start_voice_call" = "Commencer un appel audio"; +"start_video_call" = "Commencer un appel vidéo"; +"mention" = "Mentionner"; +"select_account" = "Sélectionner un compte"; +"attach_media" = "Joindre un média de la médiathèque"; +"capture_media" = "Prendre une photo/vidéo"; +"invite_user" = "Inviter un utilisateur matrix"; +"reset_to_default" = "Réinitialiser aux valeurs par défaut"; +"resend_message" = "Renvoyer le message"; +"select_all" = "Tout sélectionner"; +"cancel_upload" = "Annuler l’envoi"; +"cancel_download" = "Annuler le téléchargement"; +"show_details" = "Afficher les détails"; +"answer_call" = "Répondre à l’appel"; +"reject_call" = "Rejeter l’appel"; +"end_call" = "Terminer l’appel"; +"ignore" = "Ignorer"; +"unignore" = "Ne plus ignorer"; +// Events formatter +"notice_avatar_changed_too" = "(l’avatar a aussi changé)"; +"notice_room_name_removed" = "%@ a supprimé le nom du salon"; +"notice_room_topic_removed" = "%@ a supprimé le sujet"; +"notice_event_redacted" = ""; +"notice_event_redacted_by" = " par %@"; +"notice_event_redacted_reason" = " [raison : %@]"; +"notice_profile_change_redacted" = "%@ ont mis à jour leur profil %@"; +"notice_room_created" = "%@ a créé et configuré le salon."; +"notice_room_join_rule" = "La règle pour rejoindre le salon est : %@"; +"notice_room_power_level_intro" = "Les rangs des membres du salon sont :"; +"notice_room_power_level_acting_requirement" = "Le rang minimum qu’un utilisateur doit avoir pour interagir est :"; +"notice_room_power_level_event_requirement" = "Le rang minimum lié aux événements est :"; +"notice_room_aliases" = "Les alias du salon sont : %@"; +"notice_room_related_groups" = "Les groupes associés à ce salon sont : %@"; +"notice_encrypted_message" = "Message chiffré"; +"notice_encryption_enabled" = "%@ a activé le chiffrement de bout en bout (algorithme %@)"; +"notice_image_attachment" = "image en pièce-jointe"; +"notice_audio_attachment" = "audio en pièce-jointe"; +"notice_video_attachment" = "vidéo en pièce-jointe"; +"notice_file_attachment" = "fichier en pièce-jointe"; +"notice_invalid_attachment" = "pièce-jointe non valide"; +"notice_unsupported_attachment" = "Pièce-jointe non prise en charge : %@"; +"notice_redaction" = "%@ a supprimé un événement (id : %@)"; +"notice_error_unsupported_event" = "Évènement non pris en charge"; +"notice_error_unexpected_event" = "Événement inattendu"; +"notice_error_unknown_event_type" = "Type d’événement inconnu"; +"notice_room_history_visible_to_anyone" = "%@ a rendu l’historique futur du salon visible à tout le monde."; +"notice_room_history_visible_to_members" = "%@ a rendu l’historique futur du salon visible à tous les membres du salon."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ a rendu l’historique futur du salon visible à tous les membres, à partir du moment où ils ont été invités."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ a rendu l’historique futur du salon visible à tous les membres, à partir de leur arrivée."; +"notice_crypto_unable_to_decrypt" = "** Déchiffrement impossible : %@ **"; +"notice_crypto_error_unknown_inbound_session_id" = "La session de l’expéditeur ne nous a pas envoyé les clés pour ce message."; +"notice_sticker" = "autocollant"; +// room display name +"room_displayname_empty_room" = "Salon vide"; +"room_displayname_two_members" = "%@ et %@"; +"room_displayname_more_than_two_members" = "%@ et %u autres"; +// Settings +"settings" = "Paramètres"; +"settings_enable_inapp_notifications" = "Activer les notifications dans l’application"; +"settings_enable_push_notifications" = "Activer les notifications push"; +"settings_enter_validation_token_for" = "Saisir le jeton de validation pour %@ :"; +"notification_settings_room_rule_title" = "Salon : « %@ »"; +// Devices +"device_details_title" = "Informations sur la session\n"; +"device_details_name" = "Nom public\n"; +"device_details_identifier" = "Identifiant\n"; +"device_details_last_seen" = "Vu pour la dernière fois\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_rename_prompt_message" = "Le nom public de la session est visible par les personnes avec qui vous communiquez"; +"device_details_delete_prompt_title" = "Authentification"; +"device_details_delete_prompt_message" = "Cette opération requiert une nouvelle authentification.\nPour poursuivre, saisissez votre mot de passe."; +// Encryption information +"room_event_encryption_info_title" = "Informations sur le chiffrement de bout en bout\n\n"; +"room_event_encryption_info_event" = "Informations sur l’événement\n"; +"room_event_encryption_info_event_user_id" = "Identifiant utilisateur\n"; +"room_event_encryption_info_event_identity_key" = "Clé d’identité Curve25519\n"; +"room_event_encryption_info_event_fingerprint_key" = "Clé d’empreinte Ed25519 déclarée\n"; +"room_event_encryption_info_event_algorithm" = "Algorithme\n"; +"room_event_encryption_info_event_session_id" = "Identifiant de session\n"; +"room_event_encryption_info_event_decryption_error" = "Erreur de déchiffrement\n"; +"room_event_encryption_info_event_unencrypted" = "non chiffré"; +"room_event_encryption_info_event_none" = "aucun"; +"room_event_encryption_info_device" = "\nInformations sur la session de l’expéditeur\n"; +"room_event_encryption_info_device_unknown" = "session inconnue\n"; +"room_event_encryption_info_device_name" = "Nom public\n"; +"room_event_encryption_info_device_id" = "Identifiant\n"; +"room_event_encryption_info_device_verification" = "Vérification\n"; +"room_event_encryption_info_device_fingerprint" = "Empreinte Ed25519\n"; +"room_event_encryption_info_device_verified" = "Vérifié"; +"room_event_encryption_info_device_not_verified" = "NON vérifié"; +"room_event_encryption_info_device_blocked" = "Sur liste noire"; +"room_event_encryption_info_verify" = "Vérifier…"; +"room_event_encryption_info_unverify" = "Annuler la vérification"; +"room_event_encryption_info_block" = "Ajouter à la liste noire"; +"room_event_encryption_info_unblock" = "Supprimer de la liste noire"; +"room_event_encryption_verify_title" = "Vérifier la session\n\n"; +"room_event_encryption_verify_message" = "Pour vérifier que cette session est fiable, contactez son propriétaire par un autre moyen (par ex. en personne ou au téléphone) et demandez-lui si la clé qu’il voit dans ses paramètres utilisateur pour cette session est identique à la clé ci-dessous :\n\n\tNom de la session : %@\n\tIdentifiant de la session : %@\n\tClé de la session : %@\n\nSi les clés sont identiques, cliquez sur le bouton vérifier ci-dessous. Sinon, quelqu’un est probablement en train d’intercepter cette session et vous devriez plutôt l’ajouter à la liste noire.\n\nÀ l'avenir, ce processus de vérification sera plus élaboré."; +"room_event_encryption_verify_ok" = "Vérifier"; +// Account +"account_save_changes" = "Enregistrer les modifications"; +"account_link_email" = "Lier un e-mail"; +"account_linked_emails" = "E-mail liés"; +"account_email_validation_title" = "Vérification en attente"; +"account_email_validation_message" = "Vérifiez vos e-mails et cliquez sur le lien fourni. Ensuite, cliquez sur continuer."; +"account_email_validation_error" = "Impossible de vérifier l'adresse e-mail. Vérifiez vos e-mails et cliquez sur le lien fourni. Ensuite, cliquez sur continuer"; +"account_msisdn_validation_title" = "Vérification en attente"; +"account_msisdn_validation_message" = "Nous vous avons envoyé un SMS avec un code d’activation. Veuillez le saisir ci-dessous."; +"account_msisdn_validation_error" = "Impossible de vérifier le numéro de téléphone."; +"account_error_display_name_change_failed" = "Échec de modification du nom d’affichage"; +"account_error_picture_change_failed" = "Échec de modification de l’image"; +"account_error_matrix_session_is_not_opened" = "La session Matrix n’est pas ouverte"; +"account_error_email_wrong_title" = "Adresse e-mail non valide"; +"account_error_email_wrong_description" = "L’adresse e-mail ne semble pas valide"; +"account_error_msisdn_wrong_title" = "Numéro de téléphone non valide"; +"account_error_msisdn_wrong_description" = "Le numéro de téléphone ne semble pas valide"; +// Room creation +"room_creation_name_title" = "Nom du salon :"; +"room_creation_name_placeholder" = "(par ex. groupeDej)"; +"room_creation_alias_title" = "Alias du salon :"; +"room_creation_alias_placeholder" = "(par ex. #foo:exemple.org)"; +"room_creation_alias_placeholder_with_homeserver" = "(par ex. #foo%@)"; +"room_creation_participants_title" = "Membres :"; +"room_creation_participants_placeholder" = "(par ex. @bob:serveurdaccueil1 ; @john:serveurdaccueil2...)"; +// Room +"room_please_select" = "Sélectionnez un salon"; +"room_error_join_failed_title" = "Échec de l’inscription au salon"; +"room_error_join_failed_empty_room" = "Il est impossible pour le moment de rejoindre un salon vide."; +"room_error_name_edition_not_authorized" = "Vous n’êtes pas autorisé à modifier le nom du salon"; +"room_error_topic_edition_not_authorized" = "Vous n’êtes pas autorisé à modifier le sujet du salon"; +"room_error_cannot_load_timeline" = "Échec du chargement du fil de discussion"; +"room_error_timeline_event_not_found_title" = "Échec du chargement de la position dans le fil de discussion"; +"room_error_timeline_event_not_found" = "L’application a tenté de charger un instant précis dans l’historique du salon, mais ne l’a pas trouvée"; +"room_left" = "Vous avez quitté le salon"; +"room_no_power_to_create_conference_call" = "Des permissions sont requises pour inviter ou démarrer une téléconférence dans ce salon"; +"room_no_conference_call_in_encrypted_rooms" = "Les téléconférences ne sont pas prises en charges dans les salons chiffrés"; +// Room members +"room_member_ignore_prompt" = "Voulez-vous vraiment masquer tous les messages de cet utilisateur ?"; +"room_member_power_level_prompt" = "Vous ne pourrez pas annuler cette modification car vous promouvez cet utilisateur au même rang que le vôtre.\nEn êtes-vous sûr ?"; +// Attachment +"attachment_size_prompt" = "Voulez-vous envoyer au format :"; +"attachment_original" = "Taille réelle (%@)"; +"attachment_small" = "Petit (~%@)"; +"attachment_medium" = "Moyen (~%@)"; +"attachment_large" = "Grand (~%@)"; +"attachment_cancel_download" = "Annuler le téléchargement ?"; +"attachment_cancel_upload" = "Annuler l’envoi ?"; +"attachment_multiselection_size_prompt" = "Voulez-vous envoyer l’image au format :"; +"attachment_multiselection_original" = "Taille réelle"; +"attachment_e2e_keys_file_prompt" = "Ce fichier contient des clés de chiffrement exportées d’un client Matrix.\nVoulez-vous voir le contenu du fichier ou importer les clés qu'il contient ?"; +"attachment_e2e_keys_import" = "Importer…"; +// Contacts +"contact_mx_users" = "Utilisateurs Matrix"; +"contact_local_contacts" = "Contacts locaux"; +// Groups +"group_invite_section" = "Invitations"; +"group_section" = "Groupes"; +// Search +"search_no_results" = "Aucun résultat"; +"search_searching" = "Recherche en cours…"; +// Time +"format_time_s" = "s"; +"format_time_m" = "m"; +"format_time_h" = "h"; +"format_time_d" = "j"; +// E2E import +"e2e_import_room_keys" = "Importer les clés du salon"; +"e2e_import_prompt" = "Ce processus permet d’importer les clés de chiffrement que vous avez précédemment exportées d’un autre client Matrix. Vous pourrez ensuite déchiffrer tous les messages que l’autre client pouvait déchiffrer.\nLe fichier exporté est protégé par une phrase secrète. Entrez la phrase secrète ci-dessous pour déchiffrer le fichier."; +"e2e_import" = "Importer"; +"e2e_passphrase_enter" = "Entrer la phrase secrète"; +// E2E export +"e2e_export_room_keys" = "Exporter les clés de salon"; +"e2e_export_prompt" = "Ce processus permet d’exporter vers un fichier local les clés des messages que vous avez reçu sur les salons chiffrés. Vous pourrez ensuite importer ce fichier dans un autre client Matrix, pour qu’il puisse déchiffrer aussi ces messages.\nLe fichier exporté permettra à tous ceux qui y ont accès de déchiffrer tous les messages chiffrés que vous pouvez voir, donc vous devriez le conserver dans un endroit sûr."; +"e2e_export" = "Exporter"; +"e2e_passphrase_confirm" = "Confirmer la phrase secrète"; +"e2e_passphrase_empty" = "La phrase secrète ne peut pas être vide"; +"e2e_passphrase_not_match" = "Les phrases secrètes doivent être identiques"; +// Others +"user_id_title" = "Identifiant utilisateur :"; +"offline" = "hors ligne"; +"unsent" = "Non envoyé"; +"error" = "Erreur"; +"not_supported_yet" = "Pas encore pris en charge"; +"default" = "par défaut"; +"private" = "Privé"; +"public" = "Public"; +"power_level" = "Rang"; +"network_error_not_reachable" = "Vérifiez votre connexion au réseau"; +"user_id_placeholder" = "ex : @bob:serveurdaccueil"; +"ssl_homeserver_url" = "URL du serveur d’accueil : %@"; +// Permissions +"camera_access_not_granted_for_call" = "Pour passer un appel vidéo l’accès à l’appareil photo est indispensable mais %@ n’a pas les permissions nécessaires"; +"microphone_access_not_granted_for_call" = "Pour passer un appel l’accès au microphone est indispensable mais %@ n’a pas les permissions nécessaires"; +"local_contacts_access_not_granted" = "Pour découvrir des utilisateurs à partir des contacts locaux, l’accès aux contacts est indispensable mais %@ n’a pas les permissions nécessaires"; +"local_contacts_access_discovery_warning_title" = "Découverte des utilisateurs"; +"local_contacts_access_discovery_warning" = "Pour découvrir des contacts utilisant déjà Matrix, %@ peut envoyer les adresses e-mail et les numéros de téléphone de votre répertoire au serveur d’identité que vous avez choisi. S’il le prend en charge, vos données personnelles sont hachées avant d’être envoyées − vérifiez la politique de confidentialité de votre serveur d’identité pour plus de détails."; +// Country picker +"country_picker_title" = "Choisissez un pays"; +"notice_room_invite" = "%@ a invité %@"; +"notice_room_third_party_invite" = "%@ a invité %@ à rejoindre ce salon"; +"notice_room_third_party_registered_invite" = "%@ a accepté l’invitation à %@"; +"notice_room_join" = "%@ est arrivé"; +"notice_room_leave" = "%@ est parti"; +"notice_room_reject" = "%@ a rejeté l’invitation"; +"notice_room_kick" = "%@ a expulsé %@"; +"notice_room_unban" = "%@ a révoqué le bannissement de %@"; +"notice_room_ban" = "%@ a banni %@"; +"notice_room_withdraw" = "%@ a annulé l’invitation de %@"; +"notice_room_reason" = ". Raison : %@"; +"notice_avatar_url_changed" = "%@ a changé d’avatar"; +"notice_display_name_set" = "%@ a modifié son nom en %@"; +"notice_display_name_changed_from" = "%@ a modifié son nom de %@ à %@"; +"notice_display_name_removed" = "%@ a supprimé son nom d’affichage"; +"notice_topic_changed" = "%@ a modifié le sujet en : « %@ »."; +"notice_room_name_changed" = "%@ a modifié le nom du salon en %@."; +"notice_placed_voice_call" = "%@ a passé un appel audio"; +"notice_placed_video_call" = "%@ a passé un appel vidéo"; +"notice_answered_video_call" = "%@ a répondu à l’appel"; +"notice_ended_video_call" = "%@ a terminé l’appel"; +"notice_conference_call_request" = "%@ a débuté une téléconférence en VoIP"; +"notice_conference_call_started" = "Téléconférence en VoIP démarrée"; +"notice_conference_call_finished" = "Téléconférence en VoIP terminée"; +// button names +"ok" = "OK"; +"send" = "Envoyer"; +"copy_button_name" = "Copier"; +"resend" = "Renvoyer"; +"redact" = "Effacer"; +"share" = "Partager"; +"set_power_level" = "Définir le rang"; +"delete" = "Supprimer"; +// actions +"action_logout" = "Se déconnecter"; +"create_room" = "Créer un salon"; +"login" = "Connexion"; +"create_account" = "Créer un compte"; +"membership_invite" = "Invité"; +"membership_leave" = "Parti"; +"membership_ban" = "Banni"; +"num_members_one" = "%@ utilisateur"; +"num_members_other" = "%@ utilisateurs"; +"kick" = "Expulser"; +"ban" = "Bannir"; +"unban" = "Révoquer le bannissement"; +"message_unsaved_changes" = "Il y a des modifications non enregistrées. Quitter les annulera."; +// Login Screen +"login_error_already_logged_in" = "Déjà connecté"; +"login_error_must_start_http" = "L’URL doit débuter par http[s]://"; +// contacts list screen +"invitation_message" = "Je souhaiterais discuter avec vous sur Matrix. Veuillez visiter le site web http://matrix.org pour plus d’informations."; +// Settings screen +"settings_title_config" = "Configuration"; +"settings_title_notifications" = "Notifications"; +// Notification settings screen +"notification_settings_disable_all" = "Désactiver toutes les notifications"; +"notification_settings_enable_notifications" = "Activer les notifications"; +"notification_settings_enable_notifications_warning" = "Toutes les notifications sont actuellement désactivées pour tous les appareils."; +"notification_settings_global_info" = "Les paramètres de notification sont sauvegardés sur le compte utilisateur et partagés entre tous les clients qui les prennent en charge (y compris les notifications de bureau).\n\nLes règles s’appliquent dans l'ordre ; la première règle applicable définit le résultat.\nEn conséquence : les notification par mot-clé sont plus importantes que les notifications par salon, qui sont plus importantes que les notifications par expéditeur.\nEntre plusieurs règles du même type, la première dans la liste est prioritaire."; +"notification_settings_per_word_notifications" = "Notifications par mot-clé"; +"notification_settings_per_word_info" = "Les mots sont détectés sans tenir compte de la casse et peuvent contenir un joker *. Par conséquent :\nfoo détecte la chaîne foo entourée de délimiteurs de mots (par ex. ponctuation, espace et début ou fin de ligne).\nfoo* détecte tous les mots commençant par foo.\n*foo* détecte tous les mots qui contiennent les trois lettres foo."; +"notification_settings_always_notify" = "Toujours notifier"; +"notification_settings_never_notify" = "Ne jamais notifier"; +"notification_settings_word_to_match" = "mots à détecter"; +"notification_settings_highlight" = "Mettre en valeur"; +"notification_settings_custom_sound" = "Son personnalisé"; +"notification_settings_per_room_notifications" = "Notifications par salon"; +"notification_settings_per_sender_notifications" = "Notifications par expéditeur"; +"notification_settings_sender_hint" = "@utilisateur:domaine.com"; +"notification_settings_select_room" = "Choisir un salon"; +"notification_settings_other_alerts" = "Autres alertes"; +"notification_settings_contain_my_user_name" = "Me notifier par un son lorsqu’un message contient mon nom d’utilisateur"; +"notification_settings_contain_my_display_name" = "Me notifier par un son lorsqu’un message contient mon nom d’affichage"; +"notification_settings_just_sent_to_me" = "Me notifier par un son lorsqu’un message privé m’est envoyé"; +"notification_settings_invite_to_a_new_room" = "Me notifier lorsque je suis invité dans un salon"; +"notification_settings_people_join_leave_rooms" = "Me notifier lorsque des personnes rejoignent et quittent les salons"; +"notification_settings_receive_a_call" = "Me notifier lorsque je reçois un appel"; +"notification_settings_suppress_from_bots" = "Supprimer les notifications des robots"; +"notification_settings_by_default" = "Par défaut…"; +"notification_settings_notify_all_other" = "Notifier pour tous les autres messages ou salons"; +// gcm section +"settings_config_home_server" = "Serveur d’accueil : %@"; +"settings_config_identity_server" = "Serveur d’identité : %@"; +"settings_config_user_id" = "Identifiant utilisateur : %@"; +// call string +"call_waiting" = "En attente…"; +"call_connecting" = "Connexion…"; +"call_ended" = "Appel terminé"; +"call_ring" = "Appel…"; +"incoming_video_call" = "Appel vidéo entrant"; +"incoming_voice_call" = "Appel audio entrant"; +"call_invite_expired" = "La demande d’appel a expiré"; +// unrecognized SSL certificate +"ssl_trust" = "Faire confiance"; +"ssl_logout_account" = "Se déconnecter"; +"ssl_remain_offline" = "Ignorer"; +"ssl_fingerprint_hash" = "Empreinte (%@) :"; +"ssl_could_not_verify" = "Impossible de vérifier l’identité du serveur distant."; +"ssl_cert_not_trust" = "Cela pourrait signifier que quelqu’un de malveillant intercepte votre trafic, ou que votre téléphone ne fait pas confiance au certificat fourni par le serveur distant."; +"ssl_cert_new_account_expl" = "Si l’administrateur affirme que ce comportement est normal, assurez-vous que l’empreinte ci-dessous est identique à celle qu’il fournit."; +"ssl_unexpected_existing_expl" = "Le certificat a changé depuis qu’il a été approuvé par votre téléphone. Ce comportement est INATTENDU. Il est recommandé de ne PAS ACCEPTER ce nouveau certificat."; +"ssl_expected_existing_expl" = "Le certificat était fiable et a été remplacé par un certificat qui ne l’est pas. Le serveur a peut-être renouvelé son certificat. Contactez l’administrateur du serveur pour lui demander l’empreinte de son certificat."; +"ssl_only_accept" = "Accepter le certificat UNIQUEMENT si l’administrateur du serveur a publié une empreinte correspondant à celle ci-dessus."; +"notice_feedback" = "Événement en retour (identifiant : %@) : %@"; +"notice_location_attachment" = "position en pièce-jointe"; +// Language picker +"language_picker_title" = "Choisissez une langue"; +"language_picker_default_language" = "Défaut (%@)"; +"login_mobile_device" = "Téléphone"; +"login_tablet_device" = "Tablette"; +"login_desktop_device" = "Ordinateur"; +"notice_in_reply_to" = "En réponse à"; +"error_common_message" = "Une erreur est survenue. Veuillez réessayer ultérieurement."; +// Reply to message +"message_reply_to_sender_sent_an_image" = "a envoyé une image."; +"message_reply_to_sender_sent_a_video" = "a envoyé une vidéo."; +"message_reply_to_sender_sent_an_audio_file" = "a envoyé un fichier audio."; +"message_reply_to_sender_sent_a_file" = "a envoyé un fichier."; +"message_reply_to_message_to_reply_to_prefix" = "En réponse à"; +"login_error_resource_limit_exceeded_title" = "Limite de ressources dépassée"; +"login_error_resource_limit_exceeded_message_default" = "Ce serveur d’accueil a dépassé une de ses limites de ressources."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Ce serveur d’accueil a atteint sa limite mensuelle d’utilisateurs actifs."; +"login_error_resource_limit_exceeded_message_contact" = "\n\nVeuillez contacter l’administrateur de votre service pour continuer à l’utiliser."; +"login_error_resource_limit_exceeded_contact_button" = "Contacter l’administrateur"; +"e2e_passphrase_create" = "Créer la phrase secrète"; +"account_error_push_not_allowed" = "Les notifications ne sont pas autorisées"; +"notice_room_third_party_revoked_invite" = "%@ a retiré l’invitation de %@ à rejoindre ce salon"; +"device_details_rename_prompt_title" = "Nom de la session"; +"notice_encryption_enabled_ok" = "%@ a activé le chiffrement de bout en bout."; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ a activé le chiffrement de bout en bout (algorithme %2$@ inconnu)."; +// Notice Events with "You" +"notice_room_invite_by_you" = "Vous avez invité %@"; +"notice_room_invite_you" = "%@ vous a invité"; +"notice_room_third_party_invite_by_you" = "Vous avez envoyé une invitation à %@ pour rejoindre le salon"; +"notice_room_third_party_registered_invite_by_you" = "Vous avez accepté l’invitation pour %@"; +"notice_room_third_party_revoked_invite_by_you" = "Vous avez révoqué l’invitation pour que %@ rejoigne le salon"; +"notice_room_join_by_you" = "Vous avez rejoint le salon"; +"notice_room_leave_by_you" = "Vous êtes parti"; +"notice_room_reject_by_you" = "Vous avez rejeté l’invitation"; +"notice_room_kick_by_you" = "Vous avez expulsé %@"; +"notice_room_unban_by_you" = "Vous avez révoqué le bannissement de %@"; +"notice_room_ban_by_you" = "Vous avez banni %@"; +"notice_room_withdraw_by_you" = "Vous avez annulé l’invitation de %@"; +"notice_avatar_url_changed_by_you" = "Vous avez changé votre avatar"; +"notice_display_name_set_by_you" = "Vous avez défini votre nom d’affichage en %@"; +"notice_display_name_changed_from_by_you" = "Vous avez changé votre nom d’affichage de %@ vers %@"; +"notice_display_name_removed_by_you" = "Vous avez supprimé votre nom d’affichage"; +"notice_topic_changed_by_you" = "Vous avez changé le sujet en « %@ »."; +"notice_room_name_changed_by_you" = "Vous avez changé le nom du salon en %@."; +"notice_placed_voice_call_by_you" = "Vous avez passé un appel audio"; +"notice_placed_video_call_by_you" = "Vous avez passé un appel vidéo"; +"notice_answered_video_call_by_you" = "Vous avez répondu à l’appel"; +"notice_ended_video_call_by_you" = "Vous avez terminé l’appel"; +"notice_conference_call_request_by_you" = "Vous avez demandé une conférence VoIP"; +"notice_room_name_removed_by_you" = "Vous avez supprimé le nom du salon"; +"notice_room_topic_removed_by_you" = "Vous avez supprimé le sujet"; +"notice_event_redacted_by_you" = " par vous"; +"notice_profile_change_redacted_by_you" = "Vous avez mis à jour votre profil %@"; +"notice_room_created_by_you" = "Vous avez créé et configuré le salon."; +"notice_encryption_enabled_ok_by_you" = "Vous avez activé le chiffrement de bout en bout."; +"notice_encryption_enabled_unknown_algorithm_by_you" = "Vous avez activé le chiffrement de bout en bout (algorithme non reconnu %@)."; +"notice_redaction_by_you" = "Vous avez supprimé un évènement (id : %@)"; +"notice_room_history_visible_to_anyone_by_you" = "Vous avez rendu l’historique futur du salon visible par tout le monde."; +"notice_room_history_visible_to_members_by_you" = "Vous avez rendu l’historique futur du salon visible par tous les membres du salon."; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "Vous avez rendu l’historique futur du salon visible par tous les membres, à partir du moment où ils ont été invités."; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "Vous avez rendu l’historique futur du salon visible par tous les membres, à partir de leur arrivée."; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "Vous avez rendu les messages ultérieurs visibles à tous le monde, à partir de leur arrivée."; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "Vous avez rendu les messages ultérieurs visibles à tous le monde, à partir du moment où ils sont invités."; +"notice_room_history_visible_to_members_by_you_for_dm" = "Vous avez rendu les messages ultérieurs visibles à tous les membres du salon."; +"notice_room_created_by_you_for_dm" = "Vous êtes arrivé."; +"notice_room_name_removed_by_you_for_dm" = "Vous avez supprimé le nom"; +"notice_room_name_changed_by_you_for_dm" = "Vous avez changé le nom en %@."; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "Vous avez supprimé l’invitation de %@"; +"notice_room_third_party_invite_by_you_for_dm" = "Vous avez invité %@"; +"notice_room_name_changed_for_dm" = "%@ a changé le nom en %@."; +"notice_room_third_party_revoked_invite_for_dm" = "%@ a supprimé l’invitation de %@"; +"notice_room_third_party_invite_for_dm" = "%@ a invité %@"; +"room_left_for_dm" = "Vous êtes parti"; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ a rendu les messages ultérieurs visibles à tout le monde, à partir de leur arrivée."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ a rendu les messages ultérieurs visibles à tout le monde, à partir du moment où ils sont invités."; +"notice_room_history_visible_to_members_for_dm" = "%@ a rendu les messages ultérieurs visibles à tous les membres du salon."; +"notice_room_aliases_for_dm" = "Les alias sont : %@"; +"notice_room_power_level_intro_for_dm" = "Les rangs des membres sont :"; +"notice_room_join_rule_public_by_you_for_dm" = "Vous avez rendu le salon public."; +"notice_room_join_rule_public_by_you" = "Vous avez rendu le salon public."; +"notice_room_join_rule_public_for_dm" = "%@ a rendu le salon public."; +"notice_room_join_rule_public" = "%@ a rendu le salon public."; +"notice_room_join_rule_invite_by_you_for_dm" = "Vous avez rendu le salon joignable sur invitation exclusivement."; +"notice_room_join_rule_invite_by_you" = "Vous avez rendu le salon joignable sur invitation exclusivement."; +"notice_room_join_rule_invite_for_dm" = "%@ a rendu le salon joignable sur invitation exclusivement."; +// New +"notice_room_join_rule_invite" = "%@ a rendu le salon joignable sur invitation exclusivement."; +"notice_room_created_for_dm" = "%@ est arrivé."; +"notice_room_name_removed_for_dm" = "%@ a supprimé le nom"; +"call_more_actions_dialpad" = "Pavé de numérotation"; +"call_more_actions_transfer" = "Transférer"; +"call_more_actions_audio_use_device" = "Haut parleur de l’appareil"; +"call_more_actions_audio_use_headset" = "Utiliser les écouteurs"; +"call_more_actions_change_audio_device" = "Changer de périphérique audio"; +"call_more_actions_unhold" = "Reprendre"; +"call_more_actions_hold" = "Mettre en attente"; +"call_holded" = "Vous avez mis l’appel en attente"; +"call_remote_holded" = "%@ a mis l’appel en attente"; +"notice_declined_video_call_by_you" = "Vous avez refusé l’appel"; +"notice_declined_video_call" = "%@ a refusé l’appel"; +"resume_call" = "Reprendre"; +"call_transfer_to_user" = "Transfert à %@"; +"call_consulting_with_user" = "Consultation de %@"; +"call_video_with_user" = "Appel vidéo avec %@"; +"call_voice_with_user" = "Appel audio avec %@"; +"call_ringing" = "Sonnerie…"; +"e2e_passphrase_too_short" = "La phrase secrète est trop courte, elle doit compter au moins %d caractères"; +"microphone_access_not_granted_for_voice_message" = "Pour les messages vocaux, l’accès au microphone est indispensable mais %@ n’a pas les permissions nécessaires"; +"message_reply_to_sender_sent_a_voice_message" = "envoyer un message vocal."; +"attachment_large_with_resolution" = "Grand %@ (~%@)"; +"attachment_medium_with_resolution" = "Moyen %@ (~%@)"; +"attachment_small_with_resolution" = "Petit %@ (~%@)"; +"attachment_size_prompt_message" = "Vous pouvez désactiver ceci dans les paramètres."; +"attachment_size_prompt_title" = "Préciser la taille pour l’envoi"; +"auth_username_in_use" = ""; +"auth_invalid_user_name" = "Nom d’utilisateur invalide"; +"rename" = "Renommer"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/hu.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/hu.lproj/MatrixKit.strings new file mode 100644 index 000000000..1bf93bad0 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/hu.lproj/MatrixKit.strings @@ -0,0 +1,479 @@ +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "Fiók létrehozása:"; +"login_server_url_placeholder" = "URL (például https://matrix.org)"; +"login_home_server_title" = "Matrix szerver URL:"; +"view" = "Megtekintés"; +"back" = "Vissza"; +"continue" = "Folytatás"; +"leave" = "Elhagyás"; +"invite" = "Meghívás"; +"login_home_server_info" = "A matrix szerver tárolja minden beszélgetésedet és felhasználói fiók adatodat"; +"login_identity_server_title" = "Azonosítási szerver URL:"; +"login_identity_server_info" = "Matrix biztosít egy azonosító szerver ami nyilvántartja, hogy melyik matrix azonosítóhoz milyen e-mail cím, stb. tartozik. Jelenleg csak a https://matrix.org az egyetlen ilyen szerver."; +"login_user_id_placeholder" = "Matrix azonosító (pl.: @bob:matrix.org vagy csak bob)"; +"login_password_placeholder" = "Jelszó"; +"login_optional_field" = "opcionális"; +"login_display_name_placeholder" = "Megjelenített név (pl. Bob Obson)"; +"login_email_info" = "Az e-mail cím megadásával más felhasználók könnyebben találhatnak meg a Matrixon és lehetőséget ad a jelszó alaphelyzetbe állítására."; +"login_email_placeholder" = "E-mail cím"; +"login_prompt_email_token" = "Kérlek add meg az e-mail érvényesítő kódot:"; +"login_error_title" = "A bejelentkezés sikertelen"; +"login_error_no_login_flow" = "Nem sikerült letölteni az azonosítási információkat erről a saját szerverről"; +"login_error_do_not_support_login_flows" = "Jelenleg nem támogatunk egyetlen bejelentkezési sémát sem azok közül amit a saját szerver ismer"; +"login_error_registration_is_not_supported" = "A regisztráció jelenleg nem támogatott"; +"login_error_forbidden" = "Érvénytelen felhasználói név/jelszó"; +"login_error_unknown_token" = "A megadott hozzáférési kód nem ismert"; +"login_error_bad_json" = "Hibás JSON"; +"login_error_not_json" = "Nem tartalmaz helyes JSON adatot"; +"login_error_limit_exceeded" = "Túl sok kérés lett elküldve"; +"login_error_user_in_use" = "Ez a felhasználói név már használatban van"; +"login_error_login_email_not_yet" = "Az e-mailben küldött hivatkozásra még nem kattintottál rá"; +"login_use_fallback" = "Alternatív oldal használata"; +"login_leave_fallback" = "Mégse"; +"login_invalid_param" = "Érvénytelen paraméter"; +"register_error_title" = "A regisztráció sikertelen"; +"login_error_forgot_password_is_not_supported" = "Az „elfelejtett jelszó” jelenleg nem támogatott"; +"login_mobile_device" = "Mobil"; +"login_tablet_device" = "Tablet"; +"login_desktop_device" = "Asztali"; +// Action +"no" = "Nem"; +"yes" = "Igen"; +"abort" = "Megszakítás"; +"close" = "Bezár"; +"discard" = "Elvetés"; +"dismiss" = "Elutasítás"; +"retry" = "Újra"; +"sign_up" = "Regisztráció"; +"submit" = "Elküldés"; +"submit_code" = "Kód küldése"; +"set_default_power_level" = "Hozzáférési szint visszaállítása"; +"set_moderator" = "Beállítás moderátornak"; +"set_admin" = "Beállítás adminisztrátornak"; +"start_chat" = "Csevegés kezdése"; +"start_voice_call" = "Hanghívás kezdése"; +"start_video_call" = "Videóhívás kezdése"; +"mention" = "Megemlítés"; +"select_account" = "Felhasználói fiók kiválasztása"; +"attach_media" = "Média fájl csatolása a könyvtárból"; +"capture_media" = "Fénykép/videó készítése"; +"invite_user" = "Matrix felhasználó meghívása"; +"reset_to_default" = "Alapértelmezés visszaállítása"; +"resend_message" = "Üzenet újraküldése"; +"select_all" = "Mind kijelölése"; +"cancel_upload" = "Feltöltés megszakítása"; +"cancel_download" = "Letöltés megszakítása"; +"show_details" = "Részletek megmutatása"; +"answer_call" = "Hívás fogadása"; +"reject_call" = "Hívás elutasítása"; +"end_call" = "Hívás befejezése"; +"ignore" = "Figyelmen kívül hagyás"; +"unignore" = "Figyelembe vétel"; +// Events formatter +"notice_avatar_changed_too" = "(a felhasználó képe is megváltozott )"; +"notice_room_name_removed" = "%@ törölte a szoba nevét"; +"notice_room_topic_removed" = "%@ törölte a témát"; +"notice_event_redacted" = ""; +"notice_event_redacted_by" = " %@ által"; +"notice_event_redacted_reason" = " [indok: %@]"; +"notice_profile_change_redacted" = "%@ frissítette az adatait %@"; +"notice_room_created" = "%@ szobát készített és beállította."; +"notice_room_join_rule" = "A csatlakozási szabály: %@"; +"notice_room_power_level_intro" = "A szoba tagjainak a hozzáférési szintje:"; +"notice_room_power_level_acting_requirement" = "Mielőtt a felhasználó ezt tehetné legalább az alábbi hozzáférési szinttel kell rendelkeznie:"; +"notice_room_power_level_event_requirement" = "Az eseményekhez kapcsolódó minimális hozzáférési szintek:"; +"notice_room_aliases" = "A szoba becenevei: %@"; +"notice_room_related_groups" = "A szobához kapcsolódó csoportok: %@"; +"notice_encrypted_message" = "Titkosított üzenet"; +"notice_encryption_enabled" = "%@ bekapcsolta a végponttól végpontig titkosítást (algoritmus: %@)"; +"notice_image_attachment" = "képmelléklet"; +"notice_audio_attachment" = "hangmelléklet"; +"notice_video_attachment" = "videómelléklet"; +"notice_location_attachment" = "helyadat-melléklet"; +"notice_file_attachment" = "fájlmelléklet"; +"notice_invalid_attachment" = "érvénytelen melléklet"; +"notice_unsupported_attachment" = "Nem támogatott melléklet: %@"; +"notice_feedback" = "Visszajelzés esemény (azon.: %@): %@"; +"notice_redaction" = "%@ kitakart egy eseményt (azon.: %@)"; +"notice_error_unsupported_event" = "Nem támogatott esemény"; +"notice_error_unexpected_event" = "Nem várt esemény"; +"notice_error_unknown_event_type" = "Ismeretlen eseménytípus"; +"notice_room_history_visible_to_anyone" = "%@ a szoba jövőbeni üzeneteit mindenki számára láthatóvá tette."; +"notice_room_history_visible_to_members" = "%@ a szoba jövőbeni üzeneteit a szobában lévő minden felhasználó számára láthatóvá tette."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ a szoba jövőbeni üzeneteit a szobában lévő félhasználók számára a meghívásuk pillanatától tette láthatóvá."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ a szoba jövőbeni üzeneteit a szobában lévő felhasználók számára a csatlakozásuktól tette láthatóvá."; +"notice_crypto_unable_to_decrypt" = "** Nem sikerül visszafejteni: %@ **"; +"notice_crypto_error_unknown_inbound_session_id" = "A küldő munkamenete nem küldte el a kulcsot ehhez az üzenethez."; +"notice_sticker" = "Matrica"; +"notice_in_reply_to" = "Válaszolva erre"; +// Settings +"settings" = "Beállítások"; +"settings_enable_inapp_notifications" = "Alkalmazáson belüli értesítések engedélyezése"; +"settings_enable_push_notifications" = "Leküldéses értesítések engedélyezése"; +"settings_enter_validation_token_for" = "Érvényesítő kód megadása (%@):"; +"notification_settings_room_rule_title" = "Szoba: „%@”"; +// Devices +"device_details_title" = "Munkamenet információk\n"; +"device_details_name" = "Nyilvános név\n"; +"device_details_identifier" = "Azon.\n"; +"device_details_last_seen" = "Utoljára ekkor láttuk:\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_rename_prompt_message" = "A munkamenet nyilvános neve megjelenik azoknál az embereknél, akikkel beszélgetsz"; +"device_details_delete_prompt_title" = "Hitelesítés"; +"device_details_delete_prompt_message" = "A művelethez további hitelesítés szükséges.\nA továbblépéshez add meg a jelszavadat."; +// Encryption information +"room_event_encryption_info_title" = "Végpontok közötti titkosítási információk\n\n"; +"room_event_encryption_info_event" = "Esemény információ\n"; +"room_event_encryption_info_event_user_id" = "Felhasználó azonosító\n"; +"room_event_encryption_info_event_identity_key" = "Curve25519 azonosítási kulcs\n"; +"room_event_encryption_info_event_fingerprint_key" = "Claimed Ed25519 ujjlenyomat kulcs\n"; +"room_event_encryption_info_event_algorithm" = "Algoritmus\n"; +"room_event_encryption_info_event_session_id" = "Munkamenet-azonosító\n"; +"room_event_encryption_info_event_decryption_error" = "Visszafejtési hiba\n"; +"room_event_encryption_info_event_unencrypted" = "titkosítatlan"; +"room_event_encryption_info_event_none" = "nincs"; +"room_event_encryption_info_device" = "\nKüldő munkamenetének információi\n"; +"room_event_encryption_info_device_unknown" = "ismeretlen munkamenet\n"; +"room_event_encryption_info_device_name" = "Nyilvános név\n"; +"room_event_encryption_info_device_id" = "Azon.\n"; +"room_event_encryption_info_device_verification" = "Ellenőrzés\n"; +"room_event_encryption_info_device_fingerprint" = "Ed25519 ujjlenyomat\n"; +"room_event_encryption_info_device_verified" = "Ellenőrizve"; +"room_event_encryption_info_device_not_verified" = "NINCS ellenőrizve"; +"room_event_encryption_info_device_blocked" = "Tiltólistán"; +"room_event_encryption_info_verify" = "Ellenőriz..."; +"room_event_encryption_info_unverify" = "Ellenőrzés visszavonása"; +"room_event_encryption_info_block" = "Tiltólistára tétel"; +"room_event_encryption_info_unblock" = "Törlés a tiltólistáról"; +"room_event_encryption_verify_title" = "Munkamenet ellenőrzése\n\n"; +"room_event_encryption_verify_ok" = "Ellenőrzés"; +// Account +"account_save_changes" = "Változások mentése"; +"account_link_email" = "E-mail cím összekötése"; +"account_linked_emails" = "Hozzárendelt e-mail címek"; +"account_email_validation_title" = "Ellenőrzés folyamatban"; +"account_msisdn_validation_title" = "Ellenőrzés folyamatban"; +"account_msisdn_validation_error" = "A telefonszám ellenőrzése sikertelen."; +"account_error_display_name_change_failed" = "Megjelenítési név megváltoztatása sikertelen"; +"account_error_picture_change_failed" = "A kép megváltoztatása sikertelen"; +"account_error_matrix_session_is_not_opened" = "A Matrix munkamenet nincs megnyitva"; +"account_error_email_wrong_title" = "Érvénytelen e-mail cím"; +"account_error_email_wrong_description" = "Ez nem tűnik érvényes e-mail címnek"; +"account_error_msisdn_wrong_title" = "Érvénytelen telefonszám"; +"account_error_msisdn_wrong_description" = "Nem tűnik érvényes telefonszámnak"; +// Room creation +"room_creation_name_title" = "Szoba neve:"; +"room_creation_name_placeholder" = "(pl.: ebédCsoport)"; +"room_creation_alias_title" = "Szoba beceneve:"; +"room_creation_alias_placeholder" = "(pl.: #foo:example.org)"; +"room_creation_alias_placeholder_with_homeserver" = "(pl.: #foo%@)"; +"room_creation_participants_title" = "Résztvevők:"; +"room_creation_participants_placeholder" = "(pl.: @bob:homeserver1; @john:homeserver2...)"; +// Room +"room_please_select" = "Kérlek válassz szobát"; +"room_error_join_failed_title" = "A szobához való csatlakozás nem sikerült"; +"room_error_join_failed_empty_room" = "Üres szobába jelenleg nem lehet belépni."; +"room_error_name_edition_not_authorized" = "A szoba nevének megváltoztatásához nincs jogosultságod"; +"room_event_encryption_verify_message" = "Ennek a munkamenet hitelességének a vizsgálatához vedd fel a kapcsolatot a tulajdonossal egy másik csatornán (pl. személyes találkozó vagy telefonhívás) és kérdezd meg, hogy amit ő lát a személyes beállításoknál mint a munkamenethez tartozó kulcs, az megegyezik az alábbi kulccsal:\n\n\tMunkamenet neve: %@\n\tMunkamenet azonosító: %@\n\tMunkamenet kulcs: %@\n\nHa egyezik akkor nyomd meg az ellenőrizve gombot alább. Ha nem egyezik, akkor valaki jogosulatlanul akarja használni a munkamenetet; talán a legjobb, ha a tiltólista gombot nyomod meg inkább.\n\nA jövőben ezen az ellenőrzési módon javítani fogunk."; +"account_email_validation_message" = "Ellenőrizd a leveleidet, és kattints a levélben lévő hivatkozásra. Ha az megvan, akkor kattints itt a tovább gombra."; +"account_email_validation_error" = "Az e-mail címet nem sikerült ellenőrizni. Ellenőrizd a leveleidet, és kattints a levélben lévő hivatkozásra. Ha az megvan, akkor kattints itt a tovább gombra"; +"account_msisdn_validation_message" = "SMS-t küldtünk az aktiváló kóddal. Add meg az aktiváló kódot alább."; +"room_error_topic_edition_not_authorized" = "Nem vagy jogosult a szoba témájának szerkesztésére"; +"room_error_cannot_load_timeline" = "Az idővonalat nem sikerült betölteni"; +"room_error_timeline_event_not_found_title" = "Az idővonali pozíciót nem sikerült letölteni"; +"room_error_timeline_event_not_found" = "Az alkalmazás megpróbált az idővonalról betölteni egy időpillanatot de nem találja"; +"room_left" = "Elhagytad a szobát"; +"room_no_power_to_create_conference_call" = "Ebben a szobában nincs jogosultságod meghívni valakit konferenciát indítani"; +"room_no_conference_call_in_encrypted_rooms" = "Titkosított szobákban a konferenciahívások nem támogatottak"; +// Reply to message +"message_reply_to_sender_sent_an_image" = "kép elküldve."; +"message_reply_to_sender_sent_a_video" = "videó elküldve."; +"message_reply_to_sender_sent_an_audio_file" = "hangfájl elküldve."; +"message_reply_to_sender_sent_a_file" = "fájl elküldve."; +"message_reply_to_message_to_reply_to_prefix" = "Válaszolva erre"; +// Room members +"room_member_ignore_prompt" = "Biztos, hogy eltakarod ennek a felhasználónak az összes üzenetét?"; +"room_member_power_level_prompt" = "Valószínűleg nem fogod tudni visszavonni ezt a műveletet, mivel ugyanarra a szintre emeled a felhasználót mint amin te magad vagy.\nBiztos vagy benne?"; +// Attachment +"attachment_size_prompt" = "Hogy szeretnéd elküldeni:"; +"attachment_original" = "Jelenlegi méret (%@)"; +"attachment_small" = "Kicsi (~%@)"; +"attachment_medium" = "Közepes (~%@)"; +"attachment_large" = "Nagy (~%@)"; +"attachment_cancel_download" = "Megszakítod a letöltést?"; +"attachment_cancel_upload" = "Megszakítod a feltöltést?"; +"attachment_multiselection_size_prompt" = "Hogy szeretnéd elküldeni a képet:"; +"attachment_multiselection_original" = "Jelenlegi méret"; +"attachment_e2e_keys_file_prompt" = "Ez a fájl a Matrix kliensből kimentett titkosító kulcsokat tartalmaz.\nSzeretnéd megjeleníteni a fájl tartalmát vagy betöltöd a kulcsokat amiket tartalmaz?"; +"attachment_e2e_keys_import" = "Betöltés..."; +// Contacts +"contact_mx_users" = "Matrix felhasználók"; +"contact_local_contacts" = "Helyi névjegyek"; +// Groups +"group_invite_section" = "Meghívók"; +"group_section" = "Csoportok"; +// Search +"search_no_results" = "Nincs találat"; +"search_searching" = "Keresés folyamatban..."; +// Time +"format_time_s" = "mp"; +"format_time_m" = "p"; +"format_time_h" = "ó"; +"format_time_d" = "n"; +// E2E import +"e2e_import_room_keys" = "Szoba kulcsok betöltése"; +"e2e_import_prompt" = "Ez a folyamat betölti azokat a titkosítási kulcsokat amiket előzőleg egy másik Matrix kliensből mentettél ki. Ez után minden olyan üzenetet vissza tudsz fejteni amit a másik eszköz vissza tud.\nA kulcsokat tartalmazó fájl jelszóval védett. Add meg itt a jelszót a fájl visszafejtéséhez."; +"e2e_import" = "Betöltés"; +"e2e_passphrase_enter" = "Jelszó megadása"; +// E2E export +"e2e_export_room_keys" = "Szoba kulcsok kimentése"; +"e2e_export_prompt" = "Ezzel a folyamattal kimentheted azokat a kulcsokat amiket a titkosított szobákban az üzenetek visszafejtésére használtál. Így később ezt a fájlt egy másik Matrix kliensbe betöltve a kliens vissza tudja fejteni ezeket az üzeneteket.\nAki el tudja olvasni a kimentett kulcsokat tartalmazó fájlt vissza fogja tudni fejteni az üzeneteket amiket látsz, ezért tartsd a fájlt biztonságosan."; +"e2e_export" = "Kiment"; +"e2e_passphrase_confirm" = "Jelszó megerősítése"; +"e2e_passphrase_empty" = "A jelszó nem lehet üres"; +"e2e_passphrase_not_match" = "A jelszavaknak egyezniük kell"; +// Others +"user_id_title" = "Felhasználói azonosító:"; +"offline" = "kapcsolat nélkül"; +"unsent" = "Elküldetlen"; +"error" = "Hiba"; +"error_common_message" = "Hiba történt. Kérlek próbáld meg később."; +"not_supported_yet" = "Jelenleg nem támogatott"; +"default" = "alapértelmezett"; +"private" = "Privát"; +"public" = "Nyilvános"; +"power_level" = "Hozzáférési szint"; +"network_error_not_reachable" = "Ellenőrizd a hálózati hozzáférésed"; +"user_id_placeholder" = "pl.: @bob:matrixszerver"; +"ssl_homeserver_url" = "Matrix szerver URL: %@"; +// Permissions +"camera_access_not_granted_for_call" = "Videó hívásokhoz engedélyezni kell a hozzáférést a kamerához de %@ nem rendelkezik ilyen engedéllyel"; +"microphone_access_not_granted_for_call" = "Hívásokhoz engedélyezni kell a hozzáférést a mikrofonhoz, de %@ nem rendelkezik ilyen engedéllyel"; +"local_contacts_access_not_granted" = "Címjegyzékben található felhasználók feltérképezéséhez engedéllyel kell rendelkezni a címjegyzékhez, de %@ nem rendelkezik ilyen engedéllyel"; +"local_contacts_access_discovery_warning_title" = "Felhasználók keresése"; +"local_contacts_access_discovery_warning" = "Az olyan ismerősök felderítéséhez akik már használják a Matrixot, %@ el tudja küldeni a címjegyzékben található e-mail címeket és telefonszámokat az általad választott Matrix azonosítási szervernek. Ahol lehetséges a személyes adatok hash-elve lesznek - kérlek ellenőrizd az azonosítási szervered adatvédelmi szabályait."; +// Country picker +"country_picker_title" = "Válassz országot"; +// Language picker +"language_picker_title" = "Válassz nyelvet"; +"language_picker_default_language" = "Alapértelmezett (%@)"; +"notice_room_invite" = "%@ meghívta %@ felhasználót"; +"notice_room_third_party_invite" = "%@ meghívót küldött %@ felhasználónak, hogy lépjen be a szobába"; +"notice_room_third_party_registered_invite" = "%@ elfogadta a meghívást ide: %@"; +"notice_room_join" = "%@ csatlakozott"; +"notice_room_leave" = "%@ távozott"; +"notice_room_reject" = "%@ elutasította a meghívást"; +"notice_room_kick" = "%@ kirúgta: %@"; +"notice_room_unban" = "%@ visszaengedte: %@"; +"notice_room_ban" = "%@ kitiltotta: %@"; +"notice_room_withdraw" = "%@ visszavonta %@ meghívóját"; +"notice_room_reason" = ". Ok: %@"; +"notice_avatar_url_changed" = "%@ megváltoztatta a profilképét"; +"notice_display_name_set" = "%@ a becenevét %@ névre állította be"; +"notice_display_name_changed_from" = "%@ megváltoztatta a becenevét %@ névről %@ névre"; +"notice_display_name_removed" = "%@ törölte a becenevét"; +"notice_topic_changed" = "%@ megváltoztatta a témát erre: „%@”."; +"notice_room_name_changed" = "%@ megváltoztatta a szoba nevét erre: %@."; +"notice_placed_voice_call" = "%@ hanghívást kezdeményezett"; +"notice_placed_video_call" = "%@ videóhívást kezdeményezett"; +"notice_answered_video_call" = "%@ fogadta a hívást"; +"notice_ended_video_call" = "%@ befejezte a hívást"; +"notice_conference_call_request" = "%@ VoIP konferenciát kezdeményezett"; +"notice_conference_call_started" = "VoIP konferencia indult"; +"notice_conference_call_finished" = "VoIP konferencia befejeződött"; +// button names +"ok" = "Rendben"; +"cancel" = "Mégse"; +"save" = "Ment"; +"send" = "Küld"; +"copy_button_name" = "Másol"; +"resend" = "Újraküld"; +"redact" = "Töröl"; +"share" = "Megosztás"; +"set_power_level" = "Hozzáférési szint beállítása"; +"delete" = "Töröl"; +// actions +"action_logout" = "Kilép"; +"create_room" = "Szoba készítése"; +"login" = "Belép"; +"create_account" = "Felhasználói fiók készítés"; +"membership_invite" = "Meghívva"; +"membership_leave" = "Elhagyva"; +"membership_ban" = "Kitiltva"; +"num_members_one" = "%@ felhasználó"; +"num_members_other" = "%@ felhasználó"; +"kick" = "Elküld"; +"ban" = "Kitilt"; +"unban" = "Visszaenged"; +"message_unsaved_changes" = "Mentetlen beállítások vannak. Ha kilépsz a beállítások elvesznek."; +// Login Screen +"login_error_already_logged_in" = "Már bejelentkeztél"; +"login_error_must_start_http" = "Az URL-nek http[s]:// -sel kell kezdődnie"; +// room details dialog screen +"room_details_title" = "Szoba adatai"; +// contacts list screen +"invitation_message" = "Szeretnék veled beszélgetni a Matrix-szal. További információkért látogasd meg a http://matrix.org weboldalt."; +// Settings screen +"settings_title_config" = "Beállítások"; +"settings_title_notifications" = "Értesítések"; +// Notification settings screen +"notification_settings_disable_all" = "Minden értesítés kikapcsolása"; +"notification_settings_enable_notifications" = "Értesítések engedélyezése"; +"notification_settings_enable_notifications_warning" = "Minden értesítés tiltva van minden eszközhöz."; +"notification_settings_global_info" = "Az értesítések beállításai a felhasználói fiókhoz van elmentve és minden eszköz használhatja amelyik támogatja (beleértve az asztali értesítéseket is).\n\nA szabályok sorrendje számít; az első szabály ami illeszkedik határozza meg a viselkedést az üzenethez.\nÍgy a kulcsszó szintű értesítések fontosabbak mint a szoba szintűek amik fontosabbak mint a küldő szintű értesítések.\nHa több szabály van ugyanolyan kategóriából a listában az első szabály ami illeszkedik lesz a meghatározó."; +"notification_settings_per_word_notifications" = "Kulcsszó alapú értesítések"; +"notification_settings_per_word_info" = "A szavaknál a kis-, és nagybetű nincs megkülönböztetve és tartalmazhat „wildcard” karaktert (*). Például:\nfoo illeszkedik minden szövegre ahol a foo szó elválasztó karakterrel van körülvéve (pl.: írásjel, szóköz, sor eleje, sor vége).\nfoo* illeszkedik minden szövegre ami foo-val kezdődik.\n*foo* illeszkedik minden szövegre ami a három betűt (foo) tartalmazza."; +"notification_settings_always_notify" = "Mindig értesít"; +"notification_settings_never_notify" = "Soha ne értesítsen"; +"notification_settings_word_to_match" = "szó amire illeszkedjen"; +"notification_settings_highlight" = "Kiemel"; +"notification_settings_custom_sound" = "Egyedi hang"; +"notification_settings_per_room_notifications" = "Szoba szintű értesítések"; +"notification_settings_per_sender_notifications" = "Küldő szintű értesítések"; +"notification_settings_sender_hint" = "@felhasznalo:domain.com"; +"notification_settings_select_room" = "Válassz szobát"; +"notification_settings_other_alerts" = "További figyelmeztetések"; +"notification_settings_contain_my_user_name" = "Hangos értesítés ha az üzenet tartalmazza a nevemet"; +"notification_settings_contain_my_display_name" = "Hangos értesítés ha az üzenet a becenevemet tartalmazza"; +"notification_settings_just_sent_to_me" = "Hangos értesítés ha az üzenetet csak nekem küldték"; +"notification_settings_invite_to_a_new_room" = "Értesítés ha meghívnak egy új szobába"; +"notification_settings_people_join_leave_rooms" = "Értesítés, ha valaki belép vagy elhagy szobát"; +"notification_settings_receive_a_call" = "Értesítés, ha hívást kapok"; +"notification_settings_suppress_from_bots" = "Robotoktól való értesítések tiltása"; +"notification_settings_by_default" = "Alapértelmezetten..."; +"notification_settings_notify_all_other" = "Értesítés minden egyéb üzenethez/szobához"; +// gcm section +"settings_config_home_server" = "Matrix szerver: %@"; +"settings_config_identity_server" = "Azonosítási szerver: %@"; +"settings_config_user_id" = "Felhasználói azonosító: %@"; +// call string +"call_waiting" = "Vár..."; +"call_connecting" = "Kapcsolás…"; +"call_ended" = "Hívás vége"; +"call_ring" = "Hívás..."; +"incoming_video_call" = "Érkező videó hívás"; +"incoming_voice_call" = "Érkező hang hívás"; +"call_invite_expired" = "Hívás meghívás lejárt"; +// unrecognized SSL certificate +"ssl_trust" = "Megbízhatóság"; +"ssl_logout_account" = "Kilép"; +"ssl_remain_offline" = "Figyelmen kívül hagy"; +"ssl_fingerprint_hash" = "Ujjlenyomat (%@):"; +"ssl_could_not_verify" = "A távoli szerver nem azonosítható."; +"ssl_cert_not_trust" = "Ez azt jelentheti, hogy valaki lehallgatja a forgalmat vagy a telefon nem tekinti megbízhatónak a szerver tanúsítványát."; +"ssl_cert_new_account_expl" = "Ha a szolgáltatás adminisztrátorának információi alapján ez várható, ellenőrizd az ujjlenyomatot azzal amit az adminisztrátor közölt."; +"ssl_unexpected_existing_expl" = "A tanúsítvány amit eddig a telefon elfogadott megváltozott. Ez nagyon GYANÚS. Ajánlott az új tanúsítvány ELUTASÍTÁSA."; +"ssl_expected_existing_expl" = "Az eddig elfogadott tanúsítvány egy nem elfogadottra módosult. A szerver lehet, hogy megújította a tanúsítványát. Vedd fel a kapcsolatot a szerver adminisztrátorával az új ujjlenyomat ellenőrzéséhez."; +"ssl_only_accept" = "CSAK akkor fogadd el a tanúsítványt ha a szerver adminisztrátora közzétette az ujjlenyomatot és az megegyezik az alábbival."; +"login_error_resource_limit_exceeded_title" = "Erőforrás korlát túllépés"; +"login_error_resource_limit_exceeded_message_default" = "Ez a Matrix szerver túllépte az egyik erőforrás-korlátját."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Ez a Matrix szerver elérte a havi aktív felhasználói korlátját."; +"login_error_resource_limit_exceeded_message_contact" = "\n\nKérlek vedd fel a kapcsolatot a szolgáltatás adminisztrátorával, hogy tovább használhasd."; +"login_error_resource_limit_exceeded_contact_button" = "Adminisztrátor kapcsolatfelvétel"; +// room display name +"room_displayname_empty_room" = "Üres szoba"; +"room_displayname_two_members" = "%@ és %@"; +"room_displayname_more_than_two_members" = "%@ és %@ mások"; +"e2e_passphrase_create" = "Jelmondat készítés"; +"account_error_push_not_allowed" = "Értesítések nincsenek engedélyezve"; +"notice_room_third_party_revoked_invite" = "%@ visszavonta a a meghívót ehhez a szobához: %@"; +"device_details_rename_prompt_title" = "Munkamenet neve"; +"notice_encryption_enabled_ok" = "%@ bekapcsolta a végpontok közötti titkosítást."; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ bekapcsolta a végpontok közötti titkosítást (ismeretlen algoritmus: %2$@)."; +// Notice Events with "You" +"notice_room_invite_by_you" = "Meghívtad őt: %@"; +"notice_room_invite_you" = "%@ meghívott"; +"notice_room_third_party_invite_by_you" = "Meghívót küldtél neki: %@, hogy be tudjon lépni a szobába"; +"notice_room_third_party_registered_invite_by_you" = "A meghívót ide: %@ elfogadtad"; +"notice_room_third_party_revoked_invite_by_you" = "Visszavontad a meghívót ehhez a szobához: %@"; +"notice_room_join_by_you" = "Beléptél"; +"notice_room_leave_by_you" = "Távoztál"; +"notice_room_reject_by_you" = "A meghívót elutasítottad"; +"notice_room_kick_by_you" = "Kirúgtad őt: %@"; +"notice_room_unban_by_you" = "Visszaengedted őt: %@"; +"notice_room_ban_by_you" = "Kitiltottad őt: %@"; +"notice_room_withdraw_by_you" = "%@ meghívóját visszavontad"; +"notice_avatar_url_changed_by_you" = "A profilképedet megváltoztattad"; +"notice_display_name_set_by_you" = "A megjelenő nevedet megváltoztattad erre: %@"; +"notice_display_name_changed_from_by_you" = "A megjelenő nevedet megváltoztattad erről: %@ erre: %@"; +"notice_display_name_removed_by_you" = "A megjelenő nevedet törölted"; +"notice_topic_changed_by_you" = "A témát megváltoztattad erre: „%@”."; +"notice_room_name_changed_by_you" = "A szoba nevét megváltoztattad erre: %@."; +"notice_placed_voice_call_by_you" = "Hanghívást kezdeményeztél"; +"notice_placed_video_call_by_you" = "Videóhívást kezdeményeztél"; +"notice_answered_video_call_by_you" = "Fogadtad a hívást"; +"notice_ended_video_call_by_you" = "Befejezted a hívást"; +"notice_conference_call_request_by_you" = "VoIP konferenciát kezdeményeztél"; +"notice_room_name_removed_by_you" = "A szoba nevét törölted"; +"notice_room_topic_removed_by_you" = "A szoba témáját törölted"; +"notice_event_redacted_by_you" = " nálad"; +"notice_profile_change_redacted_by_you" = "A profilodat megváltoztattad: %@"; +"notice_room_created_by_you" = "A szobát létrehoztad és beállítottad."; +"notice_encryption_enabled_ok_by_you" = "A végpontok közötti titkosítást bekapcsoltad."; +"notice_encryption_enabled_unknown_algorithm_by_you" = "A végpontok közötti titkosítást bekapcsoltad (az algoritmus ismeretlen: %@)."; +"notice_redaction_by_you" = "Kitakartál egy eseményt (azon.: %@)"; +"notice_room_history_visible_to_anyone_by_you" = "Láthatóvá tetted a szoba jövőbeni üzeneteit mindenki számára."; +"notice_room_history_visible_to_members_by_you" = "Láthatóvá tetted a szoba jövőbeni üzeneteit a szobában tartózkodók számára."; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "A szoba jövőbeni üzeneteit a szobában lévő felhasználók számára a meghívásuk pillanatától láthatóvá tetted."; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "A szoba jövőbeni üzeneteit a szobában lévő felhasználók számára a szobába való belépésük pillanatától láthatóvá tetted."; +"notice_room_name_removed_for_dm" = "%@ törölte a nevet"; +"notice_room_created_for_dm" = "%@ csatlakozott."; +// New +"notice_room_join_rule_invite" = "%@meghívásossá tette a szobát."; +"notice_room_join_rule_invite_for_dm" = "%@meghívásossá tette."; +"notice_room_join_rule_invite_by_you" = "A szobát meghívásossá tetted."; +"notice_room_join_rule_invite_by_you_for_dm" = "Meghívásossá tetted."; +"notice_room_join_rule_public" = "%@ nyilvánossá tette a szobát."; +"notice_room_join_rule_public_for_dm" = "%@ nyilvánossá tette."; +"notice_room_join_rule_public_by_you" = "A szobát nyilvánossá tetted."; +"notice_room_join_rule_public_by_you_for_dm" = "Ezt nyilvánossá tetted."; +"notice_room_power_level_intro_for_dm" = "A tagok a hozzáférési szintje:"; +"notice_room_aliases_for_dm" = "A becenevek: %@"; +"notice_room_history_visible_to_members_for_dm" = "%@ a jövőbeni üzeneteket láthatóvá tette a szobában lévő minden felhasználó számára."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ a jövőbeni üzeneteket láthatóvá tette mindenki számára a meghívásuk pillanatától."; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ a jövőbeni üzeneteket láthatóvá tette mindenki számára a belépésük pillanatától."; +"room_left_for_dm" = "Távoztál"; +"notice_room_third_party_invite_for_dm" = "%@ meghívta %@ felhasználót"; +"notice_room_third_party_revoked_invite_for_dm" = "%@ visszavonta az ő meghívóját: %@"; +"notice_room_name_changed_for_dm" = "%@ megváltoztatta a nevet erre: %@."; +"notice_room_third_party_invite_by_you_for_dm" = "Meghívtad őt: %@"; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "Visszavontad az ő meghívóját: %@"; +"notice_room_name_changed_by_you_for_dm" = "Megváltoztattad a nevet erre: %@."; +"notice_room_name_removed_by_you_for_dm" = "A nevet törölted"; +"notice_room_created_by_you_for_dm" = "Beléptél."; +"notice_room_history_visible_to_members_by_you_for_dm" = "A jövőbeni üzeneteket láthatóvá tetted a szobában lévő minden felhasználó számára."; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "A jövőbeni üzeneteket láthatóvá tetted mindenki számára a meghívásuk pillanatától."; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "A jövőbeni üzeneteket láthatóvá tetted mindenki számára a belépésük pillanatától."; +"call_more_actions_dialpad" = "Tárcsázó számlap"; +"call_more_actions_transfer" = "Átadás"; +"call_more_actions_audio_use_device" = "Eszköz hangszóró"; +"call_more_actions_audio_use_headset" = "Fejhallgató használata"; +"call_more_actions_change_audio_device" = "Hang eszköz megváltoztatása"; +"call_more_actions_unhold" = "Folytatás"; +"call_more_actions_hold" = "Várakoztat"; +"call_holded" = "Felfüggesztette a hívást"; +"call_remote_holded" = "%@ felfüggesztette a hívást"; +"notice_declined_video_call_by_you" = "Elutasította a hívást"; +"notice_declined_video_call" = "%@ elutasította a hívást"; +"resume_call" = "Folytatás"; +"call_transfer_to_user" = "Hívásátirányítás ide: %@"; +"call_consulting_with_user" = "Konzultáció vele: %@"; +"call_video_with_user" = "Videóhívás vele: %@"; +"call_voice_with_user" = "Hanghívás vele: %@"; +"call_ringing" = "Hívás…"; +"e2e_passphrase_too_short" = "A jelmondat túl rövid (legalább %d karakter hosszúnak kell lennie)"; +"microphone_access_not_granted_for_voice_message" = "Ha hangüzenetekhez a mikrofonhoz szükséges a hozzáférés, de %@ nem rendelkezik a használatához szükséges engedéllyel"; +"message_reply_to_sender_sent_a_voice_message" = "hang üzenet elküldve."; +"attachment_large_with_resolution" = "Nagy %@ (~%@)"; +"attachment_medium_with_resolution" = "Közepes %@ (~%@)"; +"attachment_small_with_resolution" = "Kicsi %@ (~%@)"; +"attachment_size_prompt_message" = "Ezt a beállításokban kikapcsolhatod."; +"attachment_size_prompt_title" = "Méret megerősítése küldéshez"; +"room_displayname_all_other_participants_left" = "%@ (Bal)"; +"auth_reset_password_error_not_found" = "Nem található"; +"auth_reset_password_error_unauthorized" = "Nem engedélyezett"; +"auth_username_in_use" = "A felhasználónév foglalt"; +"auth_invalid_user_name" = "Érvénytelen felhasználónév"; +"rename" = "Átnevez"; +"room_displayname_all_other_members_left" = "%@ (Bal)"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/id.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/id.lproj/MatrixKit.strings new file mode 100644 index 000000000..3bee04d44 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/id.lproj/MatrixKit.strings @@ -0,0 +1,557 @@ + + +"auth_username_in_use" = "Nama pengguna telah dipakai"; +"invite" = "Undang"; +"rename" = "Ubah Nama"; +"view" = "Tampilkan"; +"cancel" = "Batalkan"; +"leave" = "Tinggalkan"; +"save" = "Simpan"; +"notice_room_power_level_intro_for_dm" = "Level kekuatan anggota pesan langsung adalah:"; +"notice_room_power_level_intro" = "Level kekuatan anggota ruangan adalah:"; +"notice_room_join_rule_public_by_you_for_dm" = "Anda membuat pesan langsung ini publik."; +"notice_room_join_rule_public_by_you" = "Anda membuat ruangan ini publik."; +"notice_room_join_rule_public_for_dm" = "%@ membuat pesan langsung ini publik."; +"notice_room_join_rule_public" = "%@ membuat ruangan ini publik."; +"notice_room_join_rule_invite_by_you_for_dm" = "Anda membuat pesan langsung ini undangan saja."; +"notice_room_join_rule_invite_by_you" = "Anda membuat ruangan ini undangan saja."; +"notice_room_join_rule_invite_for_dm" = "%@ membuat pesan langsung ini undangan saja."; +// New +"notice_room_join_rule_invite" = "%@ membuat ruangan ini undangan saja."; +// Old +"notice_room_join_rule" = "Peraturan bergabung adalah: %@"; +"notice_room_created_for_dm" = "%@ bergabung."; +"notice_room_created" = "%@ membuat dan mengatur ruangan ini."; +"notice_profile_change_redacted" = "%@ memperbarui profilnya %@"; +"notice_event_redacted_reason" = " [alasan: %@]"; +"notice_event_redacted_by" = " dari %@"; +"notice_event_redacted" = ""; +"notice_room_topic_removed" = "%@ menghapus topik ruangan"; +"notice_room_name_removed_for_dm" = "%@ menghapus nama pesan langsung"; +"notice_room_name_removed" = "%@ menghapus nama ruangan"; + +// Events formatter +"notice_avatar_changed_too" = "(avatar juga diganti)"; +"unignore" = "Hapus Pengabaian"; +"ignore" = "Abaikan"; +"resume_call" = "Lanjutkan"; +"end_call" = "Akhiri Panggilan"; +"reject_call" = "Tolak Panggilan"; +"answer_call" = "Jawab Panggilan"; +"show_details" = "Tampilkan Detail"; +"cancel_upload" = "Batal Mengunggah"; +"cancel_download" = "Batal Mengunduh"; +"select_all" = "Pilih Semua"; +"resend_message" = "Kirim ulang pesan"; +"reset_to_default" = "Atur ulang ke bawaan"; +"invite_user" = "Undang pengguna Matrix"; +"capture_media" = "Ambil Foto/Video"; +"attach_media" = "Lampirkan Media dari Library"; +"select_account" = "Pilih sebuah akun"; +"mention" = "Sebutan"; +"start_video_call" = "Mulai Panggilan Video"; +"start_voice_call" = "Mulai Panggilan Suara"; +"start_chat" = "Mulai Mengobrol"; +"set_moderator" = "Tetapkan Moderator"; +"set_admin" = "Tetapkan Admin"; +"set_power_level" = "Atur Level Kekuatan"; +"set_default_power_level" = "Atur Ulang Level Kekuatan"; +"submit_code" = "Kirim kode"; +"submit" = "Kirim"; +"sign_up" = "Daftar"; +"retry" = "Coba Lagi"; +"dismiss" = "Lupakan"; +"discard" = "Buang"; +"continue" = "Lanjutkan"; +"close" = "Tutup"; +"back" = "Kembali"; +"abort" = "Batalkan"; +"yes" = "Ya"; + +// Action +"no" = "Tidak"; +"login_error_resource_limit_exceeded_contact_button" = "Hubungi Administrator"; +"login_error_resource_limit_exceeded_message_contact" = "\n\nSilakan hubungi service homeserver Anda untuk melanjutkan menggunakan perangkat ini."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Homeserver ini telah mencapai batas Pengguna Aktif Bulanan."; +"login_error_resource_limit_exceeded_message_default" = "Homeserver ini telah melebihi batas sumbernya."; +"login_error_resource_limit_exceeded_title" = "Melebihi Batas Sumber"; +"login_desktop_device" = "Desktop"; +"login_tablet_device" = "Tablet"; +"login_mobile_device" = "Mobile"; +"login_error_forgot_password_is_not_supported" = "Lupa kata sandi saat ini belum didukung"; +"register_error_title" = "Pendaftaran Gagal"; +"login_invalid_param" = "Parameter tidak valid"; +"login_leave_fallback" = "Batalkan"; +"login_use_fallback" = "Gunakan halaman fallback"; +"login_error_login_email_not_yet" = "Tautan email yang belum diklik"; +"login_error_user_in_use" = "Nama pengguna ini sudah dipakai"; +"login_error_limit_exceeded" = "Terlalu banyak permintaan yang dikirim"; +"login_error_not_json" = "Tidak mengandung JSON yang valid"; +"login_error_unknown_token" = "Token akses yang ditentukan tidak diketahui"; +"login_error_bad_json" = "JSON cacat"; +"login_error_forbidden" = "Nama pengguna/kata sandi tidak valid"; +"login_error_registration_is_not_supported" = "Pendaftaran saat ini tidak didukung"; +"login_error_do_not_support_login_flows" = "Saat ini kami tidak mendukung salah satu atau semua alur masuk yang ditentukan oleh homeserver ini"; +"login_error_no_login_flow" = "Kami gagal untuk menerima informasi otentikasi dari homeserver ini"; +"login_error_title" = "Login Gagal"; +"login_prompt_email_token" = "Harap masukkan token validasi email Anda:"; +"login_email_placeholder" = "Alamat email"; +"login_email_info" = "Menentukan alamat email memungkinkan pengguna lain untuk menemukan Anda di Matrix dengan lebih mudah, dan akan memberi Anda cara untuk menyetel ulang sandi di masa mendatang."; +"login_display_name_placeholder" = "Nama tampilan (mis. Bob Obson)"; +"login_optional_field" = "opsional"; +"login_password_placeholder" = "Kata sandi"; +"login_user_id_placeholder" = "ID Matrix (mis. @bob:matrix.org atau bob)"; +"login_identity_server_info" = "Matrix menyediakan server identitas untuk melacak email mana, dll., milik ID Matrix mana. Hanya https://matrix.org yang saat ini ada."; +"login_identity_server_title" = "URL server identitas:"; +"login_home_server_info" = "Homeserver Anda menyimpan semua pesan Anda dan data akun"; +"login_home_server_title" = "URL Homeserver:"; +"login_server_url_placeholder" = "URL (mis. https://matrix.org)"; + +// Login Screen +"login_create_account" = "Buat akun:"; +/* *********************** */ +/* iOS specific */ +/* *********************** */ + +"matrix" = "Matrix"; +"auth_reset_password_error_not_found" = "Tidak ditemukan"; +"auth_reset_password_error_unauthorized" = "Tidak diotorisasi"; +"auth_invalid_user_name" = "Nama pengguna tidak valid"; +"ssl_only_accept" = "HANYA terima sertifikat jika administrator server telah mempublikasikan sidik jari yang cocok dengan sidik jari di atas."; +"ssl_expected_existing_expl" = "Sertifikat ini telah berubah dari yang sebelumnya tepercaya menjadi yang tidak tepercaya. Servernya mungkin telah memperbarui sertifikatnya. Hubungi administrator server untuk sidik jari yang diharapkan."; +"ssl_unexpected_existing_expl" = "Sertifikat ini telah berubah dari yang dipercaya oleh ponsel Anda. Ini SANGAT TIDAK BIASA. Anda disarankan untuk TIDAK MENERIMA sertifikat baru ini."; +"ssl_cert_new_account_expl" = "Jika administrator server mengatakan bahwa ini diharapkan, pastikan bahwa sidik jari di bawah ini cocok dengan sidik jari yang disediakannya."; +"ssl_cert_not_trust" = "Ini bisa berarti bahwa seseorang mencegat lalu lintas Anda, atau bahwa ponsel Anda tidak mempercayai sertifikat yang disediakan oleh server jarak jauh."; +"ssl_could_not_verify" = "Tidak dapat memverifikasi identitas server jarak jauh."; +"ssl_fingerprint_hash" = "Sidik Jari (%@):"; +"ssl_remain_offline" = "Abaikan"; +"ssl_logout_account" = "Keluar"; + +// unrecognized SSL certificate +"ssl_trust" = "Percayai"; +"call_transfer_to_user" = "Pindahkan ke %@"; +"call_consulting_with_user" = "Mengkonsultasi dengan %@"; +"call_video_with_user" = "Panggilan video dengan %@"; +"call_voice_with_user" = "Panggilan suara dengan %@"; +"call_more_actions_dialpad" = "Tombol penyetel"; +"call_more_actions_transfer" = "Pindahkan"; +"call_more_actions_audio_use_device" = "Speaker Perangkat"; +"call_more_actions_change_audio_device" = "Ubah Perangkat Audio"; +"call_more_actions_unhold" = "Lanjutkan"; +"call_more_actions_hold" = "Jeda"; +"call_holded" = "Anda menjeda panggilan ini"; +"call_remote_holded" = "%@ menjeda panggilan ini"; +"call_invite_expired" = "Undangan Panggilan Kedaluwarsa"; +"incoming_voice_call" = "Masuk Panggilan Suara"; +"incoming_video_call" = "Masuk Panggilan Video"; +"call_ended" = "Panggilan diakhiri"; +"call_ringing" = "Berdering…"; + +// Settings keys + +// call string +"call_connecting" = "Menghubungkan…"; +"settings_config_user_id" = "ID Pengguna: %@"; +"settings_config_identity_server" = "Server identitas: %@"; + +// gcm section +"settings_config_home_server" = "Homeserver: %@"; +"notification_settings_notify_all_other" = "Beritahu untuk semua pesan/ruangan lainnya"; +"notification_settings_by_default" = "Secara default..."; +"notification_settings_suppress_from_bots" = "Jangan beritahu saya tentang notifikasi dari bot"; +"notification_settings_receive_a_call" = "Beritahu saya ketika saya menerima panggilan"; +"notification_settings_people_join_leave_rooms" = "Beritahu saya ketika ada orang bergabung atau meninggalkan ruangan"; +"notification_settings_invite_to_a_new_room" = "Beritahu saya ketika saya diundang ke ruangan baru"; +"notification_settings_just_sent_to_me" = "Beritahu saya dengan suara tentang pesan yang baru saja dikirim ke saya"; +"notification_settings_contain_my_display_name" = "Beritahu saya dengan suara tentang pesan yang berisi nama tampilan saya"; +"notification_settings_contain_my_user_name" = "Beritahu saya dengan suara tentang pesan yang berisi nama pengguna saya"; +"notification_settings_other_alerts" = "Pemberitahuan Lainnya"; +"notification_settings_select_room" = "Pilih sebuah ruangan"; +"notification_settings_sender_hint" = "@pengguna:domain.com"; +"notification_settings_per_sender_notifications" = "Notifikasi per pengirim"; +"notification_settings_per_room_notifications" = "Notifikasi per ruangan"; +"notification_settings_custom_sound" = "Suara kustom"; +"notification_settings_highlight" = "Highlight"; +"notification_settings_word_to_match" = "kata untuk dicocokkan"; +"notification_settings_never_notify" = "Jangan diberitahu"; +"notification_settings_always_notify" = "Selalu diberitahu"; +"notification_settings_per_word_info" = "Kata-kata tidak cocok dengan huruf besar-kecil, dan mungkin menyertakan karakter pengganti *. Jadi:\nfoo cocok dengan string foo yang dikelilingi oleh pembatas kata (misalnya tanda baca dan spasi atau awal/akhir baris).\nfoo* cocok dengan kata apa pun yang dimulai foo.\n*foo* cocok dengan kata apa pun yang menyertakan 3 huruf foo."; +"notification_settings_per_word_notifications" = "Notifikasi per kata"; +"notification_settings_global_info" = "Pengaturan notifikasi disimpan ke akun pengguna Anda dan dibagikan di antara semua client yang mendukungnya (termasuk pemberitahuan desktop).\n\nAturan diterapkan secara berurutan; aturan pertama yang cocok menentukan hasil untuk pesan.\nJadi: Notifikasi per kata lebih penting daripada notifikasi per ruangan yang lebih penting daripada notifikasi per pengirim.\nUntuk beberapa aturan dengan jenis yang sama, yang pertama dalam daftar yang cocok akan diprioritaskan."; +"notification_settings_enable_notifications_warning" = "Semua notifikasi saat ini dinonaktifkan untuk semua perangkat."; +"notification_settings_enable_notifications" = "Aktifkan notifikasi"; + +// Notification settings screen +"notification_settings_disable_all" = "Nonaktifkan semua notifikasi"; +"settings_title_notifications" = "Notifikasi"; + +// Settings screen +"settings_title_config" = "Konfigurasi"; + +// members list Screen + +// accounts list Screen + +// image size selection + +// invitation members list Screen + +// room creation dialog Screen + +// room info dialog Screen + +// room details dialog screen +"room_details_title" = "Detail Ruangan"; +"login_error_must_start_http" = "URL harus dimulai dengan http[s]://"; + +// Login Screen +"login_error_already_logged_in" = "Sudah masuk"; +"message_unsaved_changes" = "Ada perubahan yang belum disimpan. Meninggalkannya akan membuang mereka."; +"unban" = "Hilangkan Cekalan"; +"ban" = "Cekal"; +"kick" = "Keluarkan"; +"num_members_other" = "%@ pengguna"; +"num_members_one" = "%@ pengguna"; +"membership_ban" = "Dicekal"; +"membership_leave" = "Keluar"; +"membership_invite" = "Diundang"; +"create_account" = "Buat Akun"; +"create_room" = "Buat Ruangan"; +"login" = "Masuk"; + +// actions +"action_logout" = "Keluar"; +"delete" = "Hapus"; +"share" = "Bagikan"; +"redact" = "Hapus"; +"resend" = "Kirim Ulang"; +"copy_button_name" = "Salin"; +"send" = "Kirim"; + +// Room Screen + +// general errors + +// Home Screen + +// Last seen time + +// call events + +/* -*- + Automatic localization for en + + The following key/value pairs were extracted from the android i18n file: + /console/src/main/res/values/strings.xml. +*/ + + +// titles + +// button names +"ok" = "OK"; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "Anda membuat sejarah pesan di masa mendatang dapat dilihat oleh semuanya, sejak mereka bergabung."; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "Anda membuat sejarah ruangan di masa mendatang dapat dilihat oleh semua anggota ruang, sejak mereka bergabung."; +"notice_encryption_enabled_unknown_algorithm_by_you" = "Anda mengaktifkan enkripsi ujung-ke-ujung (algoritma %@ tidak dikenal)."; +"notice_room_third_party_revoked_invite" = "%@ menghilangkan undangannya %@ untuk bergabung ke ruangan ini"; +"notice_room_third_party_revoked_invite_by_you" = "Anda menghilangkan undangannya %@ untuk bergabung ke ruangan ini"; +"account_email_validation_error" = "Tidak dapat memverifikasi alamat email. Silakan cek email Anda dan tekan tautannya yang ada. Setelah selesai, tekan lanjut"; + +// contacts list screen +"invitation_message" = "Saya ingin berkomunikasi dengan Anda dengan Matrix. Silakan kunjungi ke websitenya di https://matrix.org untuk informasi selanjutnya."; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "Anda membuat sejarah pesan di masa mendatang dapat dilihat oleh semuanya, sejak mereka diundang."; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "Anda membuat sejarah ruangan di masa mendatang dapat dilihat oleh semua anggota ruangan, sejak mereka diundang."; +"notice_room_history_visible_to_members_by_you_for_dm" = "Anda membuat pesan di masa depan dapat dilihat oleh semua anggota ruangan."; +"notice_room_history_visible_to_members_by_you" = "Anda membuat sejarah ruangan di masa mendatang dapat dilihat oleh semua anggota ruangan."; +"notice_room_history_visible_to_anyone_by_you" = "Anda membuat sejarah ruangan di masa mendatang dapat dilihat oleh siapa saja."; +"notice_redaction_by_you" = "Anda menghapus sebuah peristiwa (id: %@)"; +"notice_encryption_enabled_ok_by_you" = "Anda mengaktifkan enkripsi ujung-ke-ujung."; +"notice_room_created_by_you_for_dm" = "Anda bergabung."; +"notice_room_created_by_you" = "Anda membuat dan mengatur ruangan ini."; +"notice_profile_change_redacted_by_you" = "Anda memperbarui profil Anda %@"; +"notice_event_redacted_by_you" = " oleh Anda"; +"notice_room_topic_removed_by_you" = "Anda menghilangkan topik ruangan ini"; +"notice_room_name_removed_by_you_for_dm" = "Anda menghilangkan nama ruangan ini"; +"notice_room_name_removed_by_you" = "Anda menghilangkan nama ruangan ini"; +"notice_conference_call_request_by_you" = "Anda telah meminta konferensi VoIP"; +"notice_declined_video_call_by_you" = "Anda menolak anggilannya"; +"notice_ended_video_call_by_you" = "Anda mengakhiri pangilannya"; +"notice_answered_video_call_by_you" = "Anda menjawab panggilannya"; +"notice_placed_video_call_by_you" = "Anda melakukan panggilan video"; +"notice_placed_voice_call_by_you" = "Anda melakukan panggilan suara"; +"notice_room_name_changed_by_you_for_dm" = "Anda mengubah nama ruangan ini ke%@."; +"notice_room_name_changed_by_you" = "Anda mengubah nama ruangan ini ke %@."; +"notice_topic_changed_by_you" = "Anda mengubah topik ruangan nini ke \"%@\"."; +"notice_display_name_removed_by_you" = "Anda menghilangkan nama tampilan Anda"; +"notice_display_name_changed_from_by_you" = "Anda mengubah nama tampilan Anda dari %@ ke %@"; +"notice_display_name_set_by_you" = "Anda mengubah nama tampilan Anda ke %@"; +"notice_avatar_url_changed_by_you" = "Anda mengubah avatar Anda"; +"notice_room_withdraw_by_you" = "Anda menghilangkan undangannya %@"; +"notice_room_ban_by_you" = "Anda mencekal %@"; +"notice_room_unban_by_you" = "Anda menghilangkan cekalan %@"; +"notice_room_kick_by_you" = "Anda mengeluarkan %@"; +"notice_room_reject_by_you" = "Anda menolak undangannya"; +"notice_room_leave_by_you" = "Anda keluar"; +"notice_room_join_by_you" = "Anda bergabung"; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "Anda menghilangkan undangannya %@"; +"notice_room_third_party_registered_invite_by_you" = "Anda menerima undangan untuk %@"; +"notice_room_third_party_invite_by_you_for_dm" = "Anda mengundang %@"; +"notice_room_third_party_invite_by_you" = "Anda mengirim sebuah undangan ke @% untuk bergabung ke ruangan ini"; +"notice_room_invite_you" = "%@ mengundang Anda"; + +// Notice Events with "You" +"notice_room_invite_by_you" = "Anda mengundang %@"; +"notice_conference_call_finished" = "Konferensi VoIP diakhiri"; +"notice_conference_call_started" = "Konferensi VoIP dimulai"; +"notice_conference_call_request" = "%@ telah meminta konferensi VoIP"; +"notice_declined_video_call" = "%@ menolak panggilannya"; +"notice_ended_video_call" = "%@ mengakhiri panggilannya"; +"notice_answered_video_call" = "%@ menjawab panggilannya"; +"notice_placed_video_call" = "%@ melakukan panggilan video"; +"notice_placed_voice_call" = "%@ melakukan panggilan suara"; +"notice_room_name_changed_for_dm" = "%@ mengubah nama ruangan ini ke %@."; +"notice_room_name_changed" = "%@ mengubah nama ruangan ini ke %@."; +"notice_topic_changed" = "%@ mengubah topik ruangan ini ke \"%@\"."; +"notice_display_name_removed" = "%@ menghilangkan nama tampilannya"; +"notice_display_name_changed_from" = "%@ mengubah nama tampilannya dari %@ ke %@"; +"notice_display_name_set" = "%@ mengubah nama tampilannya ke %@"; +"notice_avatar_url_changed" = "%@ mengubah avatarnya"; +"notice_room_reason" = ". Alasan: %@"; +"notice_room_withdraw" = "%@ menghilangkan undangannya %@"; +"notice_room_ban" = "%@ mencekal %@"; +"notice_room_unban" = "%@ menghilangkan cekalan %@"; +"notice_room_kick" = "%@ mengeluarkan %@"; +"notice_room_reject" = "%@ menolak undangannya"; +"notice_room_leave" = "%@ keluar"; +"notice_room_join" = "%@ bergabung"; +"notice_room_third_party_revoked_invite_for_dm" = "%@ menghilangkan undangannya %@"; +"notice_room_third_party_registered_invite" = "%@ menerima undangan untuk %@"; +"notice_room_third_party_invite_for_dm" = "%@ mengundang %@"; +"notice_room_third_party_invite" = "%@ mengirim sebuah undangan ke %@ untuk bergabung ke ruangan ini"; + +/* -*- + Automatic localization for en + + The following key/value pairs were extracted from the android i18n file: + /matrix-sdk/src/main/res/values/strings.xml. +*/ + +"notice_room_invite" = "%@ mengundang %@"; +"language_picker_default_language" = "Bawaan (%@)"; + +// Language picker +"language_picker_title" = "Pilih sebuah bahasa"; + +// Country picker +"country_picker_title" = "Pilih sebuah negara"; +"microphone_access_not_granted_for_voice_message" = "Pesan suara membutuhkan akses ke Mikrofon tetapi %@ tidak memiliki izin untuk menggunakannya"; +"local_contacts_access_discovery_warning" = "Untuk menemukan kontak Anda yang sudah menggunakan Matrix, %@ dapat mengirim alamat email dan nomor telepon di kontak Anda ke server identitas Matrix yang Anda pilih. Di mana saja yang didukung, data personal akan di-hash sebelum dikirim - mohon cek kebijakan privasi identitas server Anda untuk detail lainnya."; +"local_contacts_access_discovery_warning_title" = "Penemuan pengguna"; +"local_contacts_access_not_granted" = "Penemuan pengguna dari kontak lokal membutuhkan akses ke kontak Anda tetapi %@ tidak memiliki izin untuk menggunakannya"; +"microphone_access_not_granted_for_call" = "Panggilan membutuhkan akses ke Mikrofon tetapi %@ tidak memiliki izin untuk menggunakannya"; + +// Permissions +"camera_access_not_granted_for_call" = "Panggilan video membutuhkan akses ke Kamera tetapi %@ tidak memiliki izin untuk menggunakannya"; +"ssl_homeserver_url" = "URL Homeserver: %@"; +"user_id_placeholder" = "misal: @bob:homeserver"; +"network_error_not_reachable" = "Mohon cek koneksi jaringan Anda"; +"power_level" = "Level Kekuatan"; +"public" = "Publik"; +"private" = "Privat"; +"default" = "bawaan"; +"not_supported_yet" = "Belum didukung"; +"error_common_message" = "Sebuah kesalahan terjadi. Coba lagi nanti."; +"error" = "Gagal"; +"unsent" = "Belum Terkirim"; +"offline" = "offline"; + +// Others +"user_id_title" = "ID Pangguna:"; +"e2e_passphrase_create" = "Buat frasa sandi"; +"e2e_passphrase_not_match" = "Frasa sandi harus cocok"; +"e2e_passphrase_too_short" = "Frasa sandi terlalu pendek (Harus minimal %d karakter panjangnya)"; +"e2e_passphrase_empty" = "Frasa sandi tidak boleh kosong"; +"e2e_passphrase_confirm" = "Konfirmasi frasa sandi"; +"e2e_export" = "Ekspor"; +"e2e_export_prompt" = "Proses ini memungkinkan Anda untuk mengekspor kunci untuk pesan yang Anda telah terima di ruangan terenkripsi ke file lokal. Anda nanti akan dapat mengimpor filenya ke client Matrix lainnya di masa mendatang, supaya client itu juga bisa mendekripsi pesan yang terenkripsi.\nFile yang diekspor akan memungkinan siapa saja yang dapat membaca untuk mendekripsikan pesan terenkripsi apa saja yang Anda bisa lihat, jadi Anda harus berhati-hati untuk menyimpannya secara aman."; + +// E2E export +"e2e_export_room_keys" = "Ekspor kunci ruangan"; +"e2e_passphrase_enter" = "Masukkan frasa sandi"; +"e2e_import" = "Impor"; +"e2e_import_prompt" = "Proses ini memungkinkan Anda untuk mengimpor kunci enkripsi yang Anda punya sebelumnya yang diekspor dari client Matrix lain. Anda nanti akan dapat mendekripsi pesan apa saja yang client lain dapat mendekripsinya.\nFile yang diekspor dilindungi dengan frasa sandi. Anda seharusnya masukkan frasa sandinya di sini, untuk mendekripsi filenya."; + +// E2E import +"e2e_import_room_keys" = "Impor kunci ruangan"; +"format_time_d" = "h"; +"format_time_h" = "j"; +"format_time_m" = "m"; + +// Time +"format_time_s" = "d"; +"search_searching" = "Pencarian sedang dilakukan..."; + +// Search +"search_no_results" = "Tidak Ada Hasil"; +"group_section" = "Grup"; + +// Groups +"group_invite_section" = "Undangan"; +"contact_local_contacts" = "Kontak Local"; + +// Contacts +"contact_mx_users" = "Pengguna Matrix"; +"attachment_e2e_keys_import" = "Impor..."; +"attachment_e2e_keys_file_prompt" = "File ini berisi kunci enkripsi yang diimpor dari client Matrix lain.\nApakah Anda ingin menampilkan konten file atau impor kunci yang berisi?"; +"attachment_multiselection_original" = "Ukuran Sebenarnya"; +"attachment_multiselection_size_prompt" = "Apakah Anda ingin mengirim gambarnya sebagai:"; +"attachment_cancel_upload" = "Batalkan unggahannya?"; +"attachment_cancel_download" = "Batalkan unduhannya?"; +"attachment_large_with_resolution" = "Besar %@ (~%@)"; +"attachment_medium_with_resolution" = "Sedang %@ (~%@)"; +"attachment_small_with_resolution" = "Kecil %@ (~%@)"; +"attachment_large" = "Besar (~%@)"; +"attachment_medium" = "Sedang (~%@)"; +"attachment_small" = "Kecil (~%@)"; +"attachment_original" = "Ukuran Sebenarnya (%@)"; +"attachment_size_prompt_message" = "Anda dapat menonaktifkannya di pengaturan."; +"attachment_size_prompt_title" = "Konfirmasi ukuran untuk dikirim"; + +// Attachment +"attachment_size_prompt" = "Apakah Anda ingin mengirimnya sebagai:"; +"room_member_power_level_prompt" = "Anda tidak akan lagi membatalkan perubahan ini ketika Anda mempromosikan penggunanya untuk memiliki level kekuatan yang sama dengan Anda sendiri.\nApakah Anda yakin?"; + +// Room members +"room_member_ignore_prompt" = "Apakah Anda yakin untuk menyembunyikan semua pesan dari pengguna ini?"; +"message_reply_to_message_to_reply_to_prefix" = "Membalas ke"; +"message_reply_to_sender_sent_a_file" = "mengirim sebuah file."; +"message_reply_to_sender_sent_a_voice_message" = "mengirim sebuah pesan suara."; +"message_reply_to_sender_sent_an_audio_file" = "mengirim sebuah file audio."; +"message_reply_to_sender_sent_a_video" = "mengirim sebuah video."; + +// Reply to message +"message_reply_to_sender_sent_an_image" = "mengirim sebuah gambar."; +"room_no_conference_call_in_encrypted_rooms" = "Panggilan konferensi tidak didukung di ruangan terenkripsi"; +"room_no_power_to_create_conference_call" = "Anda membutuhkan izin untuk mengundang untuk memulai konferensi di ruangan ini"; +"room_left_for_dm" = "Anda keluar"; +"room_left" = "Anda meninggalkan ruangan ini"; +"room_error_timeline_event_not_found" = "Aplikasi ini sedang mencoba untuk memuat titik tertenu di linimasa ruangan ini tetapi tidak dapat menemukannya"; +"room_error_timeline_event_not_found_title" = "Gagal untuk memuat posisi linimasa"; +"room_error_cannot_load_timeline" = "Gagal untuk memuat linimasa"; +"room_error_topic_edition_not_authorized" = "Anda tidak diizinkan untuk mengubah topik ruangan ini"; +"room_error_name_edition_not_authorized" = "Anda tidak diizinkan untuk mengubah nama ruangan ini"; +"room_error_join_failed_empty_room" = "Saat ini tidak mungkin untuk bergabung ke ruangan yang kosong."; +"room_error_join_failed_title" = "Gagal untuk bergabung ke ruangan"; + +// Room +"room_please_select" = "Silakan pilih sebuah ruangan"; +"room_creation_participants_placeholder" = "(mis. @bob:homeserver1; @john:homeserver2...)"; +"room_creation_participants_title" = "Anggota:"; +"room_creation_alias_placeholder_with_homeserver" = "(mis. #foo%@)"; +"room_creation_alias_placeholder" = "(mis. #foo:example.org)"; +"room_creation_alias_title" = "Alias ruangan:"; +"room_creation_name_placeholder" = "(mis. grupMakanSiang)"; + +// Room creation +"room_creation_name_title" = "Nama ruangan:"; +"account_error_push_not_allowed" = "Notifikasi tidak diizinkan"; +"account_error_msisdn_wrong_description" = "Ini sepertinya bukan nomor telepon yang valid"; +"account_error_msisdn_wrong_title" = "Nomor Telepon Tidak Valid"; +"account_error_email_wrong_description" = "Ini sepertinya bukan alamat email yang valid"; +"account_error_email_wrong_title" = "Alamat Email Tidak Valid"; +"account_error_matrix_session_is_not_opened" = "Sesi Matrix tidak dibuka"; +"account_error_picture_change_failed" = "Penggantian gambar gagal"; +"account_error_display_name_change_failed" = "Penggantian nama tampilan gagal"; +"account_msisdn_validation_error" = "Tidak dapat memverifikasi nomor telepon."; +"account_msisdn_validation_message" = "Kami telah mengirim sebuah SMS dengan kode aktivasi. Silakan masukkan kodenya di bawah."; +"account_msisdn_validation_title" = "Menunggu Verifikasi"; +"account_email_validation_message" = "Silakan cek email Anda dan tekan tautannya yang ada. Setelah selesai, tekan lanjut."; +"account_email_validation_title" = "Menunggu Verifikasi"; +"account_linked_emails" = "Email yang tertaut"; +"account_link_email" = "Tautkan Email"; + +// Account +"account_save_changes" = "Simpan perubahan"; +"room_event_encryption_verify_ok" = "Verifikasi"; +"room_event_encryption_verify_message" = "Untuk memverifikasi bahwa sesi ini dapat dipercaya, harap hubungi pemiliknya menggunakan cara lain (misalnya secara langsung atau melalui panggilan telepon) dan tanyakan apakah kunci yang mereka lihat di Pengaturan Pengguna untuk sesi ini cocok dengan kunci di bawah ini:\n\n\tNama sesi: %@\n\tID sesi: %@\n\tKunci sesi: %@\n\nJika cocok, tekan tombol verifikasi di bawah. Jika tidak, maka orang lain mencegat sesi ini dan Anda mungkin ingin menekan tombol daftar hitam sebagai gantinya.\n\nDi masa yang mendatang proses verifikasi ini akan semakin canggih."; +"room_event_encryption_verify_title" = "Verifikasi sesi\n\n"; +"room_event_encryption_info_unblock" = "Hilangkan dari daftar hitam"; +"room_event_encryption_info_block" = "Tambahkan ke daftar hitam"; +"room_event_encryption_info_unverify" = "Hilangkan verifikasi"; +"room_event_encryption_info_verify" = "Verifikasi..."; +"room_event_encryption_info_device_blocked" = "Di dalam daftar hitam"; +"room_event_encryption_info_device_not_verified" = "TIDAK terverifikasi"; +"room_event_encryption_info_device_verified" = "Terverifikasi"; +"room_event_encryption_info_device_fingerprint" = "Sidik jari Ed25519\n"; +"room_event_encryption_info_device_verification" = "Verifikasi\n"; +"room_event_encryption_info_device_id" = "ID\n"; +"room_event_encryption_info_device_name" = "Nama Publik\n"; +"room_event_encryption_info_device_unknown" = "sesi tidak dikenal\n"; +"room_event_encryption_info_device" = "\nInformasi sesi pengirim\n"; +"room_event_encryption_info_event_none" = "tidak ada"; +"room_event_encryption_info_event_unencrypted" = "tidak terenkripsi"; +"room_event_encryption_info_event_decryption_error" = "Kesalahan saat mendekripsi\n"; +"room_event_encryption_info_event_session_id" = "ID Sesi\n"; +"room_event_encryption_info_event_algorithm" = "Algoritma\n"; +"room_event_encryption_info_event_fingerprint_key" = "Mendapatkan kunci sidik jari Ed25519\n"; +"room_event_encryption_info_event_identity_key" = "Kunci identitas Curve25519\n"; +"room_event_encryption_info_event_user_id" = "ID Pengguna\n"; +"room_event_encryption_info_event" = "Informasi peristiwa\n"; + +// Encryption information +"room_event_encryption_info_title" = "Informasi enkripsi ujung-ke-ujung\n\n"; +"device_details_delete_prompt_message" = "Operasi ini membutuhkan otentikasi tambahan.\nUntuk melanjutkan, silakan masukkan kata sandi Anda."; +"device_details_delete_prompt_title" = "Otentikasi"; +"device_details_rename_prompt_message" = "Nama publik sesi dapat dilihat oleh orang yang berkomunikasi dengan Anda"; +"device_details_rename_prompt_title" = "Nama Sesi"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_last_seen" = "Terakhir dilihat\n"; +"device_details_identifier" = "ID\n"; +"device_details_name" = "Nama Publik\n"; + +// Devices +"device_details_title" = "Informasi sesi\n"; +"notification_settings_room_rule_title" = "Ruangan: '%@'"; +"settings_enter_validation_token_for" = "Masukkan token validasi untuk %@:"; +"settings_enable_push_notifications" = "Aktifkan notifikasi push"; +"settings_enable_inapp_notifications" = "Aktifkan notifikasi di dalam aplikasi"; + +// Settings +"settings" = "Pengaturan"; +"room_displayname_all_other_members_left" = "%@ (Keluar)"; +"room_displayname_more_than_two_members" = "%@ dan %@ lainnya"; +"room_displayname_two_members" = "%@ dan %@"; + +// room display name +"room_displayname_empty_room" = "Ruangan kosong"; +"notice_in_reply_to" = "Membalas ke"; +"notice_sticker" = "stiker"; +"notice_crypto_error_unknown_inbound_session_id" = "Sesi pengirim belum mengirim kami kunci untuk pesan ini."; +"notice_crypto_unable_to_decrypt" = "** Tidak dapat mendekripsi: %@ **"; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ membuat pesan di masa mendatang dapat dilihat oleh semuanya, sejak mereka bergabung."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ membuat sejarah pesan di masa mendatang dapat dilihat oleh semuanya, sejak mereka diundang."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ membuat sejarah ruangan di masa mendatang dapat dilihat oleh semua anggota ruangan, sejak mereka bergabung."; +"notice_room_history_visible_to_anyone" = "%@ membuat sejarah ruangan di masa mendatang dapat dilihat oleh siapa saja."; +"notice_room_history_visible_to_members" = "%@ membuat sejarah ruangan di masa mendatang dapat dilihat oleh semua anggota ruangan."; +"notice_room_history_visible_to_members_for_dm" = "%@ membuat semua pesan di masa mendatang dapat dilihat oleh semua anggota ruangan."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ membuat sejarah ruangan di masa mendatang dapat dilihat oleh semua anggota ruang, sejak mereka diundang."; +"notice_error_unknown_event_type" = "Tipe peristiwa yang tidak dikenal"; +"notice_error_unexpected_event" = "Peristiwa yang tidak terduga"; +"notice_error_unsupported_event" = "Peristiwa yang tidak didukung"; +"notice_redaction" = "%@ menghapus sebuah peristiwa (id: %@)"; +"notice_feedback" = "Peristiwa umpan balik (id: %@): %@"; +"notice_unsupported_attachment" = "Lampiran yang tidak didukung: %@"; +"notice_invalid_attachment" = "lampiran tidak valid"; +"notice_file_attachment" = "lampiran file"; +"notice_location_attachment" = "lampiran lokasi"; +"notice_video_attachment" = "lampiran video"; +"notice_audio_attachment" = "lampiran audio"; +"notice_image_attachment" = "lampiran gambar"; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ mengaktifkan enkripsi ujung-ke-ujung (algoritma %2$@ tidak dikenal)."; +"notice_encryption_enabled_ok" = "%@ mengaktifkan enkripsi ujung-ke-ujung."; +"notice_encrypted_message" = "Pesan terenkripsi"; +"notice_room_related_groups" = "Grup yang terkait dengan ruangan ini adalah: %@"; +"notice_room_aliases_for_dm" = "Aliasnya adalah: %@"; +"notice_room_aliases" = "Alias ruangannya adalah: %@"; +"notice_room_power_level_event_requirement" = "Tingkat daya minimum yang terkait dengan peristiwa adalah:"; +"notice_room_power_level_acting_requirement" = "Tingkat daya minimum yang harus dimiliki pengguna sebelum bertindak adalah:"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/is.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/is.lproj/MatrixKit.strings new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/is.lproj/MatrixKit.strings @@ -0,0 +1 @@ + diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/it.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/it.lproj/MatrixKit.strings new file mode 100644 index 000000000..630520ca6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/it.lproj/MatrixKit.strings @@ -0,0 +1,478 @@ +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "Nuovo utente:"; +"login_server_url_placeholder" = "URL (es. https://matrix.org)"; +"login_home_server_title" = "URL homeserver:"; +"login_home_server_info" = "Lo storico delle conversazioni e i dati utente sono salvati sul tuo homeserver"; +"login_identity_server_title" = "URL del server d'identità:"; +"login_identity_server_info" = "Matrix fornisce dei server d'identità per associare i tuoi recapiti (es. l'indirizzo email) al tuo ID Matrix. Attualmente esiste solo il server https://matrix.org."; +"login_user_id_placeholder" = "ID Matrix (es. @gianni:matrix.org o gianni)"; +"login_password_placeholder" = "Password"; +"login_optional_field" = "opzionale"; +"login_display_name_placeholder" = "Nome completo (es. Gianni Rossi)"; +"login_email_info" = "Indicare un indirizzo email consente ad altri utenti di trovarti facilmente in Matrix, e ti da modo di resettare la password in caso di necessità."; +"login_email_placeholder" = "Indirizzo email"; +"login_prompt_email_token" = "Inserisci il token di validazione della tua email:"; +"login_error_title" = "Accesso fallito"; +"login_error_no_login_flow" = "Impossibile ottenere i dati di autenticazione da questo homeserver"; +"login_error_do_not_support_login_flows" = "Al momento non è supportato alcuno dei flussi di accesso definiti da questo homeserver"; +"login_error_registration_is_not_supported" = "La registrazione non è consentita al momento"; +"login_error_forbidden" = "Nome utente o password errati"; +"login_error_unknown_token" = "Il token di accesso inserito non è stato riconosciuto"; +"login_error_bad_json" = "JSON malformato"; +"login_error_not_json" = "Contenuto JSON non valido"; +"login_error_limit_exceeded" = "Limite di richieste superato"; +"login_error_user_in_use" = "Questo nome utente è già in uso"; +"login_error_login_email_not_yet" = "Il link inviato via email che non è stata ancora visitato"; +"login_use_fallback" = "Usa la pagina alternativa"; +"login_leave_fallback" = "Annulla"; +"login_invalid_param" = "Parametro non valido"; +"register_error_title" = "Registrazione fallita"; +"login_error_forgot_password_is_not_supported" = "Le password dimenticate non sono supportate"; +"notice_room_join_rule" = "Regole per accedere: %@"; +// contacts list screen +"invitation_message" = "Vorrei comunicare con te usando Matrix. Visita il sito web http://matrix.org per avere maggiori informazioni."; +"login_mobile_device" = "Mobile"; +"login_tablet_device" = "Tablet"; +"login_desktop_device" = "Desktop"; +"login_error_resource_limit_exceeded_title" = "Superato il limite delle risorse"; +"login_error_resource_limit_exceeded_message_default" = "Questo homeserver ha superato uno dei suoi limiti di risorsa."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Questo homeserver ha raggiunto il limite massimo di utenti attivi in un mese."; +"login_error_resource_limit_exceeded_message_contact" = "\n\nContatta l’amministratore del sistema per continuare a usare questo servizio."; +"login_error_resource_limit_exceeded_contact_button" = "Contatta l’amministratore"; +// Action +"no" = "No"; +"yes" = "Si"; +"abort" = "Annulla"; +"back" = "Indietro"; +"close" = "Chiudi"; +"continue" = "Continua"; +"discard" = "Annulla"; +"dismiss" = "Annulla"; +"retry" = "Riprova"; +"sign_up" = "Registrati"; +"submit" = "Invia"; +"submit_code" = "Invia codice"; +"set_power_level" = "Imposta livello di accesso"; +"set_default_power_level" = "Ripristina livello di accesso"; +"set_moderator" = "Imposta moderatore"; +"set_admin" = "Imposta amministratore"; +"start_chat" = "Nuova chat"; +"start_voice_call" = "Nuova telefonata"; +"start_video_call" = "Nuova videochiamata"; +"mention" = "Menziona"; +"select_account" = "Seleziona un utente"; +"attach_media" = "Allega contenuto multimediale"; +"capture_media" = "Riprendi foto/video"; +"invite_user" = "Invita un utente Matrix"; +"reset_to_default" = "Ripristina predefinito"; +"resend_message" = "Reinvia messaggio"; +"select_all" = "Seleziona tutto"; +"cancel_upload" = "Annulla caricamento"; +"cancel_download" = "Annulla scaricamento"; +"show_details" = "Mostra dettagli"; +"answer_call" = "Rispondi a chiamata"; +"reject_call" = "Rifiuta chiamata"; +"end_call" = "Chiudi chiamata"; +"ignore" = "Ignora"; +"unignore" = "Non ignorare"; +// Events formatter +"notice_avatar_changed_too" = "(anche l’avatar è cambiato)"; +"notice_room_name_removed" = "%@ ha cancellato il nome del canale"; +"notice_room_topic_removed" = "%@ ha cancellato il titolo"; +"notice_event_redacted" = ""; +"notice_event_redacted_by" = " da %@"; +"notice_event_redacted_reason" = " [motivo: %@]"; +"notice_profile_change_redacted" = "%@ ha aggiornato il suo profilo %@"; +"notice_room_created" = "%@ ha creato e configurato la stanza."; +"notice_room_power_level_intro" = "Il livello di accesso dei partecipanti è:"; +"notice_room_power_level_acting_requirement" = "Il livello minimo di accesso per partecipare è:"; +"notice_room_power_level_event_requirement" = "Il livello minimo di accesso per visualizzare eventi è:"; +"notice_room_aliases" = "Gli alias di questo canale sono: %@"; +"notice_room_related_groups" = "I gruppi associati a questo canale sono: %@"; +"notice_encrypted_message" = "Messaggio criptato"; +"notice_encryption_enabled" = "%@ ha attivato la crittografia end-to-end (algoritmo %@)"; +"notice_image_attachment" = "allegato immagine"; +"notice_audio_attachment" = "allegato audio"; +"notice_video_attachment" = "allegato video"; +"notice_location_attachment" = "allegato posizione"; +"notice_file_attachment" = "allegato file"; +"notice_invalid_attachment" = "allegato non valido"; +"notice_unsupported_attachment" = "Allegato non supportato: %@"; +"notice_feedback" = "Evento di feedback (id: %@): %@"; +"notice_redaction" = "%@ ha modificato un evento (id: %@)"; +"notice_error_unsupported_event" = "Evento non supportato"; +"notice_error_unexpected_event" = "Evento inatteso"; +"notice_error_unknown_event_type" = "Tipo di evento sconosciuto"; +"notice_room_history_visible_to_anyone" = "%@ ha permesso a chiunque di visualizzare lo storico della conversazione."; +"notice_room_history_visible_to_members" = "%@ ha permesso di visualizzare lo storico della conversazione solo ai partecipanti."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ ha permesso di visualizzare lo storico della conversazione solo ai partecipanti, dal momento del loro invito."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ ha permesso di visualizzare lo storico della conversazione solo ai partecipanti, dal momento della loro entrata."; +"notice_crypto_unable_to_decrypt" = "** Impossibile decriptare: %@ **"; +"notice_crypto_error_unknown_inbound_session_id" = "La sessione del mittente non ci ha inviato le chiavi per questo messaggio."; +"notice_sticker" = "etichetta"; +"notice_in_reply_to" = "In risposta a"; +// room display name +"room_displayname_empty_room" = "Canale senza partecipanti"; +"room_displayname_two_members" = "%@ e %@"; +"room_displayname_more_than_two_members" = "%@ e %@ altri"; +// Settings +"settings" = "Impostazioni"; +"settings_enable_inapp_notifications" = "Abilita notifiche In-App"; +"settings_enable_push_notifications" = "Abilita notifiche push"; +"settings_enter_validation_token_for" = "Inserisci token di validazione per %@:"; +"notification_settings_room_rule_title" = "Canale: '%@'"; +// Devices +"device_details_title" = "Informazioni sessione\n"; +"device_details_name" = "Nome pubblico\n"; +"device_details_identifier" = "ID\n"; +"device_details_last_seen" = "Ultimo contatto\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_rename_prompt_title" = "Nome sessione"; +"device_details_rename_prompt_message" = "Il nome pubblico della sessione è visibile alle persone con cui comunichi"; +"device_details_delete_prompt_title" = "Autenticazione"; +"device_details_delete_prompt_message" = "Questa operazione necessita di ulteriore autenticazione.\nInserisci la tua password per procedere."; +// Encryption information +"room_event_encryption_info_title" = "Informazioni crittografia End-to-end\n\n"; +"room_event_encryption_info_event" = "Informazioni evento\n"; +"room_event_encryption_info_event_user_id" = "ID utente:\n"; +"room_event_encryption_info_event_identity_key" = "Chiave di identificazione Curve25519\n"; +"room_event_encryption_info_event_fingerprint_key" = "Richiamata chiave per l’impronta Ed25519\n"; +"room_event_encryption_info_event_algorithm" = "Algoritmo\n"; +"room_event_encryption_info_event_session_id" = "ID sessione\n"; +"room_event_encryption_info_event_decryption_error" = "Errore decrittazione\n"; +"room_event_encryption_info_event_unencrypted" = "non criptato"; +"room_event_encryption_info_event_none" = "nessuna"; +"room_event_encryption_info_device" = "\nInformazioni sessione mittente\n"; +"room_event_encryption_info_device_unknown" = "sessione sconosciuta\n"; +"room_event_encryption_info_device_name" = "Nome pubblico\n"; +"room_event_encryption_info_device_id" = "ID\n"; +"room_event_encryption_info_device_verification" = "Verifica\n"; +"room_event_encryption_info_device_fingerprint" = "Impronta Ed25519\n"; +"room_event_encryption_info_device_verified" = "Verificato"; +"room_event_encryption_info_device_not_verified" = "NON verificato"; +"room_event_encryption_info_device_blocked" = "Bloccato"; +"room_event_encryption_info_verify" = "Verifica..."; +"room_event_encryption_info_unverify" = "Annulla verifica"; +"room_event_encryption_info_block" = "Bloccato"; +"room_event_encryption_info_unblock" = "Sbloccato"; +"room_event_encryption_verify_title" = "Verifica sessione\n\n"; +"room_event_encryption_verify_message" = "Per verificare che questa sessione possa essere fidata, si prega di contattare il suo proprietario in altro modo (es. di persona o via telefonica) e chiedergli se la chiave che lui vede nelle Impostazioni Utente per la sessione sia uguale a questa:\n\n\tNome sessione: %@\n\tID sessione: %@\n\tChiave sessione: %@\n\nSe le chiavi sono identiche, premere il pulsante di verifica qui sotto. Se non lo sono, allora quancun altro sta intercettando questa sessione e probabilmente dovresti bloccarlo.\n\nIn futuro questo processo di verifica sarà più sofisticato."; +"room_event_encryption_verify_ok" = "Verifica"; +// Account +"account_save_changes" = "Salva cambiamenti"; +"account_link_email" = "Collegamento email"; +"account_linked_emails" = "Indirizzi email collegati"; +"account_email_validation_title" = "Verifica in corso"; +"account_email_validation_message" = "Controlla l'email e clicca sul collegamento che contiene. Dopo averlo fatto, clicca su continua."; +"account_email_validation_error" = "Impossibile verificare l'indirizzo email. Controlla l'email e clicca sul collegamento che contiene. Dopo averlo fatto, clicca su continua"; +"account_msisdn_validation_title" = "Verifica in corso"; +"account_msisdn_validation_message" = "Abbiamo inviato un SMS con un codice di attivazione. Inserisci il codice qui sotto."; +"account_msisdn_validation_error" = "Impossibile verificare il numero di telefono."; +"account_error_display_name_change_failed" = "Cambio del nome completo fallito"; +"account_error_picture_change_failed" = "Cambio dell'immagine fallito"; +"account_error_matrix_session_is_not_opened" = "La sessione Matrix non è attiva"; +"account_error_email_wrong_title" = "Indirizzo email non valido"; +"account_error_email_wrong_description" = "Questo non sembra essere un indirizzo email valido"; +"account_error_msisdn_wrong_title" = "Numero di telefono non valido"; +"account_error_msisdn_wrong_description" = "Questo non sembra essere un numero telefonico valido"; +"account_error_push_not_allowed" = "Notifiche non permesse"; +// Room creation +"room_creation_name_title" = "Nome canale:"; +"room_creation_name_placeholder" = "(es. gruppoPranzo)"; +"room_creation_alias_title" = "Alias canale:"; +"room_creation_alias_placeholder" = "(es. #blah:dominio.it)"; +"room_creation_alias_placeholder_with_homeserver" = "(es. #blah%@)"; +"room_creation_participants_title" = "Partecipanti:"; +"room_creation_participants_placeholder" = "(e.g. @gianni:homeserver1; @alice:homeserver2...)"; +// Room +"room_please_select" = "Seleziona un canale"; +"room_error_join_failed_title" = "Accesso al canale fallito"; +"room_error_join_failed_empty_room" = "Al momento non è possibile entrare in una stanza vuota."; +"room_error_name_edition_not_authorized" = "Non sei autorizzato a modificare il nome di questo canale"; +"room_error_topic_edition_not_authorized" = "Non sei autorizzato a modificare l'argomento di questo canale"; +"room_error_cannot_load_timeline" = "Caricamento storico dei messaggi fallito"; +"room_error_timeline_event_not_found_title" = "Caricamento della posizione nello storico fallito"; +"room_error_timeline_event_not_found" = "L'applicazione ha cercato di caricare un punto specifico dello storico dei messaggi in questo canale, ma non è riuscita a trovarlo"; +"room_left" = "Sei uscito dalla stanza"; +"room_no_power_to_create_conference_call" = "Hai bisogno del permesso per invitare a iniziare una conferenza in questo canale"; +"room_no_conference_call_in_encrypted_rooms" = "Le chiamate in conferenza non sono supportate nei canali criptati"; +// Reply to message +"message_reply_to_sender_sent_an_image" = "immagine inviata."; +"message_reply_to_sender_sent_a_video" = "video inviato."; +"message_reply_to_sender_sent_an_audio_file" = "file audio inviato."; +"message_reply_to_sender_sent_a_file" = "file inviato."; +"message_reply_to_message_to_reply_to_prefix" = "In risposta a"; +// Room members +"room_member_ignore_prompt" = "Sei sicuro di voler nascondere tutti i messaggi da questo utente?"; +"room_member_power_level_prompt" = "Non potrai annullare questa modifica perché stai innalzando i permessi dell'utente al tuo stesso livello di accesso.\nSei sicuro?"; +// Attachment +"attachment_size_prompt" = "Vuoi inviare come:"; +"attachment_original" = "Dim. effettiva (%@)"; +"attachment_small" = "Piccolo (~%@)"; +"attachment_medium" = "Medio (~%@)"; +"attachment_large" = "Grande (~%@)"; +"attachment_cancel_download" = "Interrompi scaricamento?"; +"attachment_cancel_upload" = "Interrompi caricamento?"; +"attachment_multiselection_size_prompt" = "Vuoi inviare le immagini come:"; +"attachment_multiselection_original" = "Originali"; +"attachment_e2e_keys_file_prompt" = "Questo file contiene le chiavi crittografiche esportate da un client Matrix.\nVuoi visualizzarlo o importare le chiavi che contiene?"; +"attachment_e2e_keys_import" = "Importa..."; +// Contacts +"contact_mx_users" = "Utenti Matrix"; +"contact_local_contacts" = "Contatti locali"; +// Groups +"group_invite_section" = "Inviti"; +"group_section" = "Gruppi"; +// Search +"search_no_results" = "Nessun risultato"; +"search_searching" = "Ricerca in corso..."; +// Time +"format_time_s" = "s"; +"format_time_m" = "m"; +"format_time_h" = "h"; +"format_time_d" = "d"; +// E2E import +"e2e_import_room_keys" = "Importa le chiavi del canale"; +"e2e_import_prompt" = "Questo processo ti consente di importare le chiavi di crittografia che hai precedentemente esportato da un altro client Matrix. Sarai quindi in grado di decifrare gli stessi messaggi leggibili dall'altro client.\nIl file esportato è protetto da password. Devi inserirla qui per decifrare il file."; +"e2e_import" = "Importa"; +"e2e_passphrase_enter" = "Inserisci password"; +// E2E export +"e2e_export_room_keys" = "Esporta chiavi del canale"; +"e2e_export_prompt" = "Questo processo consente di esportare in un file locale le chiavi per leggere i messaggi ricevuti in canali criptati. Sarai quindi in grado di importare il file in un altro client Matrix, in modo da rendergli possibile decriptare quei messaggi in futuro.\nIl file esportato consentirà a chiunque di decriptare tutti i messaggi che puoi leggere, quindi dovresti tenerlo al sicuro."; +"e2e_export" = "Esporta"; +"e2e_passphrase_confirm" = "Conferma password di accesso"; +"e2e_passphrase_empty" = "La password di accesso non può essere vuota"; +"e2e_passphrase_not_match" = "Le password di accesso devono corrispondere"; +"e2e_passphrase_create" = "Crea password di accesso"; +// Others +"user_id_title" = "ID utente:"; +"offline" = "non in linea"; +"unsent" = "Non inviato"; +"error" = "Errore"; +"error_common_message" = "Si è verificato un errore. Riprova più tardi."; +"not_supported_yet" = "Non ancora supportato"; +"default" = "predefinito"; +"private" = "Privato"; +"public" = "Pubblico"; +"power_level" = "Livello di accesso"; +"network_error_not_reachable" = "Verifica di essere connesso alla rete"; +"user_id_placeholder" = "es.: @gianni@homeserver"; +"ssl_homeserver_url" = "URL homeserver: %@"; +// Permissions +"camera_access_not_granted_for_call" = "Le video chiamate necessitano di accesso alla videocamera ma %@ non ha il permesso di usarla"; +"microphone_access_not_granted_for_call" = "Le telefonate necessitano l'accesso al microfono, ma %@ non ha il permesso di usarlo"; +"local_contacts_access_not_granted" = "La ricerca degli utenti fra i contatti locali necessita l'accesso alla rubrica, ma %@ non ha il permesso di usarla"; +"local_contacts_access_discovery_warning_title" = "Ricerca utenti"; +"local_contacts_access_discovery_warning" = "Per scoprire chi fra i tuoi contatti sta già usando Matrix, %@ può inviare gli indirizzi email e i numeri telefonici della tua rubrica al tuo server d'identità Matrix. Se possibile, i dati personali vengono codificati prima di essere inviati - controlla la politica di riservatezza del tuo server d'identità per maggiori dettagli."; +// Country picker +"country_picker_title" = "Scegli un paese"; +// Language picker +"language_picker_title" = "Scegli una lingua"; +"language_picker_default_language" = "Predefinito (%@)"; +"notice_room_invite" = "%@ invitato %@"; +"notice_room_third_party_invite" = "%@ ha invitato %@ a unirsi al canale"; +"notice_room_third_party_registered_invite" = "%@ ha accettato l'invito per %@"; +"notice_room_third_party_revoked_invite" = "%@ ha ritirato l'invito per %@ a unirsi al canale"; +"notice_room_join" = "%@ si è unito al canale"; +"notice_room_leave" = "%@ ha lasciato il canale"; +"notice_room_reject" = "%@ ha rifiutato l'invito"; +"notice_room_kick" = "%@ ha espulso %@"; +"notice_room_unban" = "%@ ha revocato il divieto di accesso a %@"; +"notice_room_ban" = "%@ ha vietato l'accesso a %@"; +"notice_room_withdraw" = "%@ ha ritirato l'invito di %@"; +"notice_room_reason" = ". Motivo: %@"; +"notice_avatar_url_changed" = "%@ ha modificato la sua immagine"; +"notice_display_name_set" = "%@ ha impostato il suo nome in %@"; +"notice_display_name_changed_from" = "%@ ha cambiato il suo nome da %@ a %@"; +"notice_display_name_removed" = "%@ ha rimosso il suo nome"; +"notice_topic_changed" = "%@ ha cambiato l'argomento in \"%@\"."; +"notice_room_name_changed" = "%@ ha cambiato il nome della stanza in %@."; +"notice_placed_voice_call" = "%@ ha effettuato una telefonata"; +"notice_placed_video_call" = "%@ ha iniziato una videochiamata"; +"notice_answered_video_call" = "%@ ha risposto alla chiamata"; +"notice_ended_video_call" = "%@ ha terminato la chiamata"; +"notice_conference_call_request" = "%@ ha richiesto una conferenza VoIP"; +"notice_conference_call_started" = "Conferenza VoIP iniziata"; +"notice_conference_call_finished" = "Conferenza VoIP terminata"; +// button names +"ok" = "OK"; +"cancel" = "Annulla"; +"save" = "Salva"; +"leave" = "Esci"; +"send" = "Invia"; +"copy_button_name" = "Copia"; +"resend" = "Invia di nuovo"; +"redact" = "Cancella"; +"share" = "Condividi"; +"delete" = "Elimina"; +"view" = "Visualizza"; +// actions +"action_logout" = "Esci"; +"create_room" = "Crea un canale"; +"login" = "Entra"; +"create_account" = "Crea utente"; +"membership_invite" = "Invitati"; +"membership_leave" = "Uscito"; +"membership_ban" = "Espulso"; +"num_members_one" = "%@ utente"; +"num_members_other" = "%@ utenti"; +"invite" = "Invita"; +"kick" = "Espelli"; +"ban" = "Vieta accesso"; +"unban" = "Consenti accesso"; +"message_unsaved_changes" = "Ci sono modifiche non salvate. Uscendo saranno perse."; +// Login Screen +"login_error_already_logged_in" = "Accesso già effettuato"; +"login_error_must_start_http" = "l'URL deve iniziare con http[s]://"; +// room details dialog screen +"room_details_title" = "Dettagli stanza"; +// Settings screen +"settings_title_config" = "Configurazione"; +"settings_title_notifications" = "Notifiche"; +// Notification settings screen +"notification_settings_disable_all" = "Disabilita tutte le notifiche"; +"notification_settings_enable_notifications" = "Abilita le notifiche"; +"notification_settings_enable_notifications_warning" = "Le notifiche sono al momento disabilitate per tutti i dispositivi."; +"notification_settings_global_info" = "Le impostazioni di notifica sono salvate nel tuo account e condivise fra i client che le supportano (incluse le notifiche del desktop)\n\nLe regole si applicano in ordine; la prima regola che corrisponde definisce l'esito del messaggio.\nQuindi: Le notifiche per-parola hanno la precedenza sulle notifiche per-canale, che precedono quelle per-mittente.\nSe esistono diverse regole dello stesso tipo, viene applicata la prima della lista."; +"notification_settings_per_word_notifications" = "Notifiche per-parola"; +"notification_settings_per_word_info" = "Le parole sono confrontate senza tenere conto dei caratteri maiuscoli/minuscoli, e possono includere asterischi. Quindi:\nblah corrisponde alla stringa blah con qualsiasi delimitatore di parola (es. segni di punteggiatura e spazi o segni di accapo).\nblah* corrisponde a qualsiasi parola inizi con blah.\n*blah* corrisponde a qualsiasi parola contenga le 4 lettere blah."; +"notification_settings_always_notify" = "Notifica sempre"; +"notification_settings_never_notify" = "Non notificare mai"; +"notification_settings_word_to_match" = "parola da cercare"; +"notification_settings_highlight" = "Evidenziare"; +"notification_settings_custom_sound" = "Suono personalizzato"; +"notification_settings_per_room_notifications" = "Notifiche per-canale"; +"notification_settings_per_sender_notifications" = "Notifiche per-mittente"; +"notification_settings_sender_hint" = "@utente:dominio.it"; +"notification_settings_select_room" = "Seleziona un canale"; +"notification_settings_other_alerts" = "Altri avvisi"; +"notification_settings_contain_my_user_name" = "Notifica con un suono i messaggi che contengono il mio nome utente"; +"notification_settings_contain_my_display_name" = "Notifica con un suono i messaggi che contengono il mio nome completo"; +"notification_settings_just_sent_to_me" = "Notifica con un suono i messaggi inviati solo a me"; +"notification_settings_invite_to_a_new_room" = "Notifica quando sono invitato in un nuovo canale"; +"notification_settings_people_join_leave_rooms" = "Notifica quando gli utenti entrano o escono dai canali"; +"notification_settings_receive_a_call" = "Notifica quando ricevo una chiamata"; +"notification_settings_suppress_from_bots" = "Sopprimi le notifiche dai bot"; +"notification_settings_by_default" = "Come predefinito..."; +"notification_settings_notify_all_other" = "Notifica tutti gli altri messaggi/canali"; +// gcm section +"settings_config_home_server" = "Homeserver: %@"; +"settings_config_identity_server" = "Server d'identità: %@"; +"settings_config_user_id" = "ID utente: %@"; +// call string +"call_waiting" = "Attendere..."; +"call_connecting" = "In connessione…"; +"call_ended" = "Chiamata terminata"; +"call_ring" = "Chiamata in corso..."; +"incoming_video_call" = "Videochiamata in arrivo"; +"incoming_voice_call" = "Telefonata in arrivo"; +"call_invite_expired" = "Tempo di chiamata scaduto"; +// unrecognized SSL certificate +"ssl_trust" = "Fidati"; +"ssl_logout_account" = "Esci"; +"ssl_remain_offline" = "Ignora"; +"ssl_fingerprint_hash" = "Impronta (%@):"; +"ssl_could_not_verify" = "Impossibile verificare l'identità del server remoto."; +"ssl_cert_not_trust" = "Potrebbe voler dire che qualcuno sta intercettando il tuo traffico, o che il tuo telefono non si fida del certificato offerto dal server remoto."; +"ssl_cert_new_account_expl" = "Se l'amministratore del server ti ha detto che questo sarebbe successo, accertati che l'impronta qui sotto corrisponda a quella che lui ti ha fornito."; +"ssl_unexpected_existing_expl" = "Il certificato è diverso da quello di cui il tuo telefono si fidava. Questo è un COMPORTAMENTO ANOMALO. Si consiglia di NON ACCETTARE questo nuovo certificato."; +"ssl_expected_existing_expl" = "Il certificato è cambiato da uno precedentemente accettato a uno che non è fidato. Il server potrebbe averlo rinnovato. Contatta l'amministratore del server per verificarne l'impronta."; +"ssl_only_accept" = "Accetta il certificato SOLAMENTE se l'amministratore del server ha pubblicato un'impronta che corrisponde a quella qui sopra."; +"notice_encryption_enabled_ok" = "%@ ha attivato la crittografia end-to-end."; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ ha attivato la crittografia end-to-end (algoritmo %2$@ non riconosciuto)."; +// Notice Events with "You" +"notice_room_invite_by_you" = "Hai invitato %@"; +"notice_room_invite_you" = "%@ ti ha invitato"; +"notice_room_third_party_invite_by_you" = "Hai mandato un invito a %@ a unirsi alla stanza"; +"notice_room_third_party_registered_invite_by_you" = "Hai accettato l'invito per %@"; +"notice_room_third_party_revoked_invite_by_you" = "Hai revocato l'invito per %@ a unirsi alla stanza"; +"notice_room_join_by_you" = "Sei entrato"; +"notice_room_leave_by_you" = "Sei uscito"; +"notice_room_reject_by_you" = "Hai rifiutato l'invito"; +"notice_room_kick_by_you" = "Hai buttato fuori %@"; +"notice_room_unban_by_you" = "Hai riammesso %@"; +"notice_room_ban_by_you" = "Hai bandito %@"; +"notice_room_withdraw_by_you" = "Hai ritirato l'invito di %@"; +"notice_avatar_url_changed_by_you" = "Hai cambiato il tuo avatar"; +"notice_display_name_set_by_you" = "Hai impostato il tuo nome visualizzato a %@"; +"notice_display_name_changed_from_by_you" = "Hai cambiato il tuo nome visualizzato da %@ a %@"; +"notice_display_name_removed_by_you" = "Hai rimosso il tuo nome visualizzato"; +"notice_topic_changed_by_you" = "Hai cambiato l'argomento in \"%@\"."; +"notice_room_name_changed_by_you" = "Hai cambiato il nome della stanza in %@."; +"notice_placed_voice_call_by_you" = "Hai iniziato una telefonata"; +"notice_placed_video_call_by_you" = "Hai iniziato una videochiamata"; +"notice_answered_video_call_by_you" = "Hai risposto alla chiamata"; +"notice_ended_video_call_by_you" = "Hai terminato la chiamata"; +"notice_conference_call_request_by_you" = "Hai richiesto una conferenza VoIP"; +"notice_room_name_removed_by_you" = "Hai rimosso il nome della stanza"; +"notice_room_topic_removed_by_you" = "Hai rimosso l'argomento"; +"notice_event_redacted_by_you" = " da te"; +"notice_profile_change_redacted_by_you" = "Hai aggiornato il tuo profilo %@"; +"notice_room_created_by_you" = "Hai creato e configurato la stanza."; +"notice_encryption_enabled_ok_by_you" = "Hai attivato la crittografia end-to-end."; +"notice_encryption_enabled_unknown_algorithm_by_you" = "Hai attivato la crittografia end-to-end (algoritmo %@ sconosciuto)."; +"notice_redaction_by_you" = "Hai corretto un evento (id: %@)"; +"notice_room_history_visible_to_anyone_by_you" = "Hai reso visibile a chiunque la cronologia futura della stanza."; +"notice_room_history_visible_to_members_by_you" = "Hai reso visibile a tutti i membri della stanza la cronologia futura della stanza."; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "Hai reso visibile a tutti i membri della stanza la cronologia futura della stanza, dal momento del loro invito."; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "Hai reso visibile a tutti i membri della stanza la cronologia futura della stanza, dal momento della loro entrata."; +// New +"notice_room_join_rule_invite" = "%@ ha reso la stanza solo su invito."; +"notice_room_join_rule_invite_by_you" = "Hai reso la stanza solo su invito."; +"notice_room_join_rule_public" = "%@ ha reso la stanza pubblica."; +"notice_room_join_rule_public_by_you" = "Hai reso la stanza pubblica."; +"notice_room_name_removed_for_dm" = "%@ ha rimosso il nome"; +"notice_room_created_for_dm" = "%@ è entrato."; +"notice_room_join_rule_invite_for_dm" = "%@ l'ha resa solo su invito."; +"notice_room_join_rule_invite_by_you_for_dm" = "L'hai resa solo su invito."; +"notice_room_join_rule_public_for_dm" = "%@ l'ha resa pubblica."; +"notice_room_join_rule_public_by_you_for_dm" = "L'hai resa pubblica."; +"notice_room_power_level_intro_for_dm" = "Il livello di accesso dei partecipanti è:"; +"notice_room_aliases_for_dm" = "Gli alias sono: %@"; +"notice_room_history_visible_to_members_for_dm" = "%@ ha reso visibili i messaggi futuri a tutti i membri della stanza."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ ha reso visibili i messaggi futuri a chiunque, dal momento dell'invito."; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ ha reso visibili i messaggi futuri a chiunque, dal momento dell'entrata."; +"room_left_for_dm" = "Sei uscito"; +"notice_room_third_party_invite_for_dm" = "%@ ha invitato %@"; +"notice_room_third_party_revoked_invite_for_dm" = "%@ ha revocato l'invito per %@"; +"notice_room_name_changed_for_dm" = "%@ ha cambiato il nome in %@."; +"notice_room_third_party_invite_by_you_for_dm" = "Hai invitato %@"; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "Hai revocato l'invito per %@"; +"notice_room_name_changed_by_you_for_dm" = "Hai cambiato il nome in %@."; +"notice_room_name_removed_by_you_for_dm" = "Hai rimosso il nome"; +"notice_room_created_by_you_for_dm" = "Sei entrato."; +"notice_room_history_visible_to_members_by_you_for_dm" = "Hai reso visibili i messaggi futuri a tutti i membri della stanza."; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "Hai reso visibili i messaggi futuri a chiunque, dal momento dell'invito."; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "Hai reso visibili i messaggi futuri a chiunque, dal momento dell'entrata."; +"call_more_actions_dialpad" = "Tastierino numerico"; +"call_more_actions_transfer" = "Trasferisci"; +"call_more_actions_audio_use_device" = "Altoparlante dispositivo"; +"call_more_actions_audio_use_headset" = "Usa audio da cuffie"; +"call_more_actions_change_audio_device" = "Cambia dispositivo audio"; +"call_more_actions_unhold" = "Riprendi"; +"call_more_actions_hold" = "In attesa"; +"call_holded" = "Hai messo la chiamata in attesa"; +"call_remote_holded" = "%@ ha messo la chiamata in attesa"; +"notice_declined_video_call_by_you" = "Hai rifiutato la chiamata"; +"notice_declined_video_call" = "%@ ha rifiutato la chiamata"; +"resume_call" = "Riprendi"; +"call_transfer_to_user" = "Trasferisci a %@"; +"call_consulting_with_user" = "Consultazione con %@"; +"call_video_with_user" = "Videochiamata con %@"; +"call_voice_with_user" = "Telefonata con %@"; +"call_ringing" = "Sta squillando…"; +"e2e_passphrase_too_short" = "Password troppo corta (deve avere almeno %d caratteri)"; +"microphone_access_not_granted_for_voice_message" = "I messaggi vocali hanno bisogno dell'accesso al microfono, ma %@ non ha il permesso di usarlo"; +"message_reply_to_sender_sent_a_voice_message" = "inviato un messaggio vocale."; +"attachment_large_with_resolution" = "Grande %@ (~%@)"; +"attachment_medium_with_resolution" = "Medio %@ (~%@)"; +"attachment_small_with_resolution" = "Piccolo %@ (~%@)"; +"attachment_size_prompt_message" = "Puoi disattivarlo nelle impostazioni."; +"attachment_size_prompt_title" = "Conferma dimensione da inviare"; +"auth_reset_password_error_not_found" = "Non trovato"; +"auth_reset_password_error_unauthorized" = "Non autorizzato"; +"auth_invalid_user_name" = "Nome utente non valido"; +"room_displayname_all_other_members_left" = "%@ (Uscito)"; +"auth_username_in_use" = "Nome utente in uso"; +"rename" = "Rinomina"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/ja.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/ja.lproj/MatrixKit.strings new file mode 100644 index 000000000..c4918a35e --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/ja.lproj/MatrixKit.strings @@ -0,0 +1,409 @@ +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "アカウント作成:"; +"login_server_url_placeholder" = "URL (例 https://matrix.org)"; +"login_home_server_title" = "接続先サーバURL:"; +"login_home_server_info" = "あなたの接続先サーバは、あなたの全ての会話とアカウント情報を保存します"; +"login_identity_server_title" = "認証サーバURL:"; +"login_password_placeholder" = "パスワード"; +"login_email_placeholder" = "メールアドレス"; +// Action +"no" = "いいえ"; +"yes" = "はい"; +"back" = "戻る"; +"close" = "閉じる"; +"continue" = "続く"; +"sign_up" = "登録"; +"resend_message" = "メッセージを再送信する"; +"select_all" = "全て選択"; +"show_details" = "詳細を表示する"; +"login_identity_server_info" = "Matrixは、どの電子メールなどがどのMatrix IDに属しているかを追跡するアイデンティティサーバーを提供します。 現在 https://matrix.org のみが存在します。"; +"login_user_id_placeholder" = "Matrix ID(例 @bob:matrix.org または bob)"; +"login_optional_field" = "オプション"; +"login_display_name_placeholder" = "表示名 (例 Bob Obson)"; +"login_email_info" = "メールアドレスを指定すると、他のユーザーがあなたをMatrixで簡単に見つけることができ、今後パスワードをリセットすることができます。"; +"login_prompt_email_token" = "メールの検証トークンを入力してください:"; +"login_error_title" = "ログインに失敗しました"; +"login_error_no_login_flow" = "このホームサーバーから認証情報を取得できませんでした"; +"login_error_do_not_support_login_flows" = "現在、このホームサーバーによって定義されたログインフローの一部またはすべてをサポートしていません"; +"login_error_registration_is_not_supported" = "登録は現在サポートされていません"; +"login_error_forbidden" = "無効なユーザー名/パスワード"; +"login_error_unknown_token" = "指定されたアクセストークンが認識されませんでした"; +"login_error_bad_json" = "不正な形式のJSON"; +"login_error_not_json" = "有効なJSONを含んでいませんでした"; +"login_error_limit_exceeded" = "あまりにも多くのリクエストが送られました"; +"login_error_user_in_use" = "このユーザー名はすでに使用されています"; +"login_error_login_email_not_yet" = "まだクリックされていないメールリンク"; +"login_use_fallback" = "フォールバックページを使用する"; +"login_leave_fallback" = "キャンセル"; +"login_invalid_param" = "無効なパラメーター"; +"register_error_title" = "登録に失敗しました"; +"login_error_forgot_password_is_not_supported" = "Forgot passwordは現在サポートされていません"; +"login_mobile_device" = "携帯"; +"login_tablet_device" = "タブレット"; +"login_desktop_device" = "デスクトップ"; +"login_error_resource_limit_exceeded_title" = "リソース制限を超えました"; +"login_error_resource_limit_exceeded_message_default" = "このホームサーバーは、リソース制限の1つを超えています。"; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "このホームサーバーは、月間アクティブユーザー制限を超えています。"; +"login_error_resource_limit_exceeded_message_contact" = "\n\nこのサービスを続行するには、サービス管理者に連絡してください。"; +"login_error_resource_limit_exceeded_contact_button" = "管理者に連絡する"; +"abort" = "中断する"; +"discard" = "破棄"; +"dismiss" = "却下する"; +"retry" = "再試行"; +"submit" = "提出"; +"submit_code" = "コードを送信"; +"set_default_power_level" = "権限レベルをリセット"; +"set_moderator" = "モデレーターを設定"; +"set_admin" = "管理者を設定"; +"start_chat" = "チャットを始める"; +"start_voice_call" = "音声通話を開始"; +"start_video_call" = "ビデオ通話を開始"; +"mention" = "記載"; +"select_account" = "アカウントを選択"; +"attach_media" = "ライブラリからメディアを添付"; +"capture_media" = "写真/ビデオを撮る"; +"invite_user" = "matrixユーザーを招待"; +"reset_to_default" = "デフォルトにリセット"; +"cancel_upload" = "アップロードをキャンセル"; +"cancel_download" = "ダウンロードをキャンセル"; +"answer_call" = "通話に応答する"; +"reject_call" = "通話を拒否する"; +"end_call" = "通話終了"; +"ignore" = "無視"; +// Events formatter +"notice_avatar_changed_too" = "(アバターも変わった)"; +"notice_room_name_removed" = "%@は部屋名を削除しました"; +"notice_room_topic_removed" = "%@はトピックを削除しました"; +"notice_event_redacted" = "<編集された%@>"; +"notice_event_redacted_by" = " %@により"; +"notice_event_redacted_reason" = " [理由: %@]"; +"notice_profile_change_redacted" = "%@は彼らのプロフィール %@を更新しました"; +"notice_room_created" = "%@は部屋を作成しました"; +"notice_room_join_rule" = "結合ルールは次のとおり: %@"; +"notice_room_power_level_intro" = "ルームメンバーの権限レベル:"; +"notice_room_power_level_acting_requirement" = "アクション前にユーザーの必要な最小権限レベル:"; +"notice_room_power_level_event_requirement" = "イベントに関連する最小権限レベル:"; +"notice_room_aliases" = "ルームエイリアス: %@"; +"notice_room_related_groups" = "この部屋に関連付けられたグループ: %@"; +"notice_encrypted_message" = "暗号化されたメッセージ"; +"notice_encryption_enabled" = "%@はエンドツーエンド暗号化を有効にする (アルゴリズム %@)"; +"notice_image_attachment" = "画像添付"; +"notice_audio_attachment" = "音声添付"; +"notice_video_attachment" = "動画添付"; +"notice_location_attachment" = "位置情報添付"; +"notice_file_attachment" = "ファイル添付"; +"notice_invalid_attachment" = "無効な添付"; +"notice_unsupported_attachment" = "サポートされていない添付: %@"; +"notice_feedback" = "フィードバックイベント (id: %@): %@"; +"notice_redaction" = "%@はイベントを編集しました (id: %@)"; +"notice_error_unsupported_event" = "サポートされていないイベント"; +"notice_error_unexpected_event" = "予期しないイベント"; +"notice_error_unknown_event_type" = "不明なイベントタイプ"; +"notice_room_history_visible_to_anyone" = "%@ 誰でも将来の部屋履歴を表示されます。"; +"notice_room_history_visible_to_members" = "%@ ルームメンバー全員に将来の部屋履歴を表示されます。"; +"notice_room_history_visible_to_members_from_invited_point" = "%@ ルームメンバー全員に招待した時点からの部屋履歴を表示されます。"; +"notice_room_history_visible_to_members_from_joined_point" = "%@ ルームメンバー全員に参加した時点からの部屋履歴を表示されます。"; +"notice_crypto_unable_to_decrypt" = "** 復号化できません: %@ **"; +"notice_crypto_error_unknown_inbound_session_id" = "送信者のセッションからこのメッセージのキーが送信されていません。"; +"notice_sticker" = "ステッカー"; +"notice_in_reply_to" = "に返信"; +// room display name +"room_displayname_empty_room" = "空の部屋"; +"room_displayname_two_members" = "%@ と %@"; +"room_displayname_more_than_two_members" = "%@ と %@ 他"; +// Settings +"settings" = "設定"; +"settings_enable_inapp_notifications" = "アプリ内通知を有効にする"; +"settings_enable_push_notifications" = "プッシュ通知を有効にする"; +"settings_enter_validation_token_for" = "%@の検証トークンを入力:"; +"notification_settings_room_rule_title" = "部屋: '%@'"; +// Devices +"device_details_title" = "セッション情報\n"; +"device_details_name" = "名前\n"; +"device_details_identifier" = "ID\n"; +"device_details_last_seen" = "ラストシーン\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_rename_prompt_message" = "端末名:"; +"device_details_delete_prompt_title" = "認証"; +"device_details_delete_prompt_message" = "この操作には、追加の認証が必要です。\n続行するには、パスワードを入力してください。"; +// Encryption information +"room_event_encryption_info_title" = "エンドツーエンド暗号化情報\n\n"; +"room_event_encryption_info_event" = "イベント情報\n"; +"room_event_encryption_info_event_user_id" = "ユーザーID\n"; +"room_event_encryption_info_event_identity_key" = "Curve25519 identity key\n"; +"room_event_encryption_info_event_fingerprint_key" = "クレームされたEd25519指紋キー\n"; +"room_event_encryption_info_event_algorithm" = "アルゴリズム\n"; +"room_event_encryption_info_event_session_id" = "セッションID\n"; +"room_event_encryption_info_event_decryption_error" = "復号化エラー\n"; +"room_event_encryption_info_event_unencrypted" = "暗号化されていない"; +"room_event_encryption_info_event_none" = "なし"; +"room_event_encryption_info_device" = "\n送信者セッション情報\n"; +"room_event_encryption_info_device_unknown" = "未知のセッション\n"; +"room_event_encryption_info_device_name" = "名前\n"; +"room_event_encryption_info_device_id" = "ID\n"; +"room_event_encryption_info_device_verification" = "検証\n"; +"room_event_encryption_info_device_fingerprint" = "Ed25519 fingerprint\n"; +"room_event_encryption_info_device_verified" = "検証済み"; +"room_event_encryption_info_device_not_verified" = "検証されていない"; +"room_event_encryption_info_device_blocked" = "ブラックリストに載せた"; +"room_event_encryption_info_verify" = "検証中..."; +"room_event_encryption_info_unverify" = "未検証"; +"room_event_encryption_info_block" = "ブラックリスト"; +"room_event_encryption_info_unblock" = "ブラックでないリスト"; +"room_event_encryption_verify_title" = "セッション検証\n\n"; +"room_event_encryption_verify_message" = "このセッションが信頼できることを確認するには、他の方法(個人や電話など)で所有者に連絡し、セッションのユーザー設定で表示される鍵が以下のキーと一致するかどうかを尋ねます。\n\nセッション名: %@\nセッションID: %@\nセッションkey: %@\n\n一致する場合は、下の確認ボタンを押します。 それ以外の人がこのセッションを傍受している場合は、代わりにブラックリストボタンを押してください。\n\n将来この検証プロセスはより洗練されたものになるでしょう。"; +"room_event_encryption_verify_ok" = "検証"; +// Account +"account_save_changes" = "変更を保存"; +"account_link_email" = "リンクメール"; +"account_linked_emails" = "リンクされたメール"; +"account_email_validation_title" = "検証保留中"; +"account_email_validation_message" = "メールをチェックし、それに含まれているリンクをクリックしてください。 これが完了したら、[続行]をクリックします。"; +"account_email_validation_error" = "メールアドレスを確認できません。 あなたのメールをチェックし、それに含まれているリンクをクリックしてください。 これが完了したら、[続行]をクリックします"; +"account_msisdn_validation_title" = "検証保留中"; +"account_msisdn_validation_message" = "アクティベーションコード付きのSMSを送信しました。 以下にこのコードを入力してください。"; +"account_msisdn_validation_error" = "電話番号を確認できません。"; +"account_error_display_name_change_failed" = "表示名の変更に失敗しました"; +"account_error_picture_change_failed" = "画像の変更に失敗しました"; +"account_error_matrix_session_is_not_opened" = "Matrixセッションが開かれていません"; +"account_error_email_wrong_title" = "無効な電子メールアドレス"; +"account_error_email_wrong_description" = "これは有効なメールアドレスではないようです"; +"account_error_msisdn_wrong_title" = "無効な電話番号"; +"account_error_msisdn_wrong_description" = "これは有効な電話番号ではないようです"; +// Room creation +"room_creation_name_title" = "部屋名:"; +"room_creation_name_placeholder" = "(例 ランチグループ)"; +"room_creation_alias_title" = "部屋の別名:"; +"room_creation_alias_placeholder" = "(例 #foo:example.org)"; +"room_creation_alias_placeholder_with_homeserver" = "(例 #foo%@)"; +"room_creation_participants_title" = "参加者:"; +"room_creation_participants_placeholder" = "(例 @bob:homeserver1; @john:homeserver2...)"; +// Room +"room_please_select" = "部屋を選択してください"; +"room_error_join_failed_title" = "部屋に参加できませんでした"; +"room_error_join_failed_empty_room" = "現在空の部屋に再参加することはできません。"; +"room_error_name_edition_not_authorized" = "この部屋の名前を編集する権限がありません"; +"room_error_topic_edition_not_authorized" = "この部屋のトピックを編集する権限がありません"; +"room_error_cannot_load_timeline" = "タイムラインの読み込みに失敗しました"; +"room_error_timeline_event_not_found_title" = "タイムラインの位置を読み込めませんでした"; +"room_error_timeline_event_not_found" = "アプリケーションがこのルームのタイムラインに特定のポイントをロードしようとしましたが、それを見つけることができませんでした"; +"room_left" = "あなたは部屋を出ました"; +"room_no_power_to_create_conference_call" = "この部屋で会議を開始するために招待する権限が必要です"; +"room_no_conference_call_in_encrypted_rooms" = "暗号化された会議室では会議通話はサポートされません"; +// Reply to message +"message_reply_to_sender_sent_an_image" = "画像を送信しました。"; +"message_reply_to_sender_sent_a_video" = "動画を送りました。"; +"message_reply_to_sender_sent_an_audio_file" = "オーディオファイルを送信しました。"; +"message_reply_to_sender_sent_a_file" = "ファイルを送信しました。"; +"message_reply_to_message_to_reply_to_prefix" = "に返信"; +// Room members +"room_member_ignore_prompt" = "このユーザーからのすべてのメッセージを非表示にしますか?"; +"room_member_power_level_prompt" = "この変更を元に戻すことはできません。ユーザーが自分と同じレベルの権限を持つように促しますが、よろしいですか?"; +// Attachment +"attachment_size_prompt" = "次のように送信しますか:"; +"attachment_original" = "実際のサイズ: %@"; +"attachment_small" = "小: %@"; +"attachment_medium" = "中: %@"; +"attachment_large" = "大: %@"; +"attachment_cancel_download" = "ダウンロードをキャンセルしますか?"; +"attachment_cancel_upload" = "アップロードをキャンセルしますか?"; +"attachment_multiselection_size_prompt" = "画像を次のように送信しますか:"; +"attachment_multiselection_original" = "実際のサイズ"; +"attachment_e2e_keys_file_prompt" = "このファイルには、Matrixクライアントからエクスポートされた暗号化キーが含まれています。\nファイルの内容を表示するか、ファイルの内容をインポートしますか?"; +"attachment_e2e_keys_import" = "インポート..."; +// Contacts +"contact_mx_users" = "Matrixユーザー"; +"contact_local_contacts" = "ローカルの連絡先"; +// Groups +"group_invite_section" = "招待"; +"group_section" = "グループ"; +// Search +"search_no_results" = "結果がありません"; +"search_searching" = "検索中..."; +// Time +"format_time_s" = "秒"; +"format_time_m" = "分"; +"format_time_h" = "時"; +"format_time_d" = "日"; +// E2E import +"e2e_import_room_keys" = "ルームキーをインポート"; +"e2e_import_prompt" = "このプロセスでは、以前に別のMatrixクライアントからエクスポートした暗号化キーをインポートできます。 これにより、他のクライアントが解読できるすべてのメッセージを解読することができます。\nエクスポートファイルはパスフレーズで保護されています。 ファイルを解読するには、パスフレーズをここに入力する必要があります。"; +"e2e_import" = "インポート"; +"e2e_passphrase_enter" = "パスフレーズを入力"; +// E2E export +"e2e_export_room_keys" = "ルームキーのエクスポート"; +"e2e_export_prompt" = "このプロセスでは、暗号化されたルームで受信したメッセージのキーをローカルファイルにエクスポートできます。 その後、クライアントがこれらのメッセージを復号化できるように、ファイルを別のMatrixクライアントにインポートすることができます。\nエクスポートされたファイルは、誰でも閲覧できる暗号化されたメッセージを復号化することができるので、安全に保つように注意する必要があります。"; +"e2e_export" = "エクスポート"; +"e2e_passphrase_confirm" = "パスフレーズを確認"; +"e2e_passphrase_empty" = "パスフレーズは空であってはいけません"; +"e2e_passphrase_not_match" = "パスフレーズは一致する必要があります"; +"e2e_passphrase_create" = "パスフレーズの作成"; +// Others +"user_id_title" = "ユーザーID:"; +"offline" = "オフライン"; +"unsent" = "未送信"; +"error" = "エラー"; +"error_common_message" = "エラーが発生しました。 後でもう一度お試しください。"; +"not_supported_yet" = "まだサポートされていません"; +"default" = "既定"; +"private" = "Private"; +"public" = "Public"; +"power_level" = "権限レベル"; +"network_error_not_reachable" = "ネットワーク接続を確認してください"; +"user_id_placeholder" = "例: @bob:homeserver"; +"ssl_homeserver_url" = "ホームサーバーのURL: %@"; +// Permissions +"camera_access_not_granted_for_call" = "ビデオ通話はカメラにアクセスする必要がありますが、%@にはそのカメラを使用する権限がありません"; +"microphone_access_not_granted_for_call" = "通話にはマイクへのアクセスが必要ですが、%@には使用許可がありません"; +"local_contacts_access_not_granted" = "ローカルの連絡先からユーザーを探すには連絡先にアクセスする必要がありますが、%@にはそのアクセス権限がありません"; +"local_contacts_access_discovery_warning_title" = "ユーザーの探索"; +"local_contacts_access_discovery_warning" = "%@は、ユーザーを検索するためにあなたの連絡先から電子メールと電話番号をアップロードしたい"; +// Country picker +"country_picker_title" = "国を選択する"; +// Language picker +"language_picker_title" = "言語を選択する"; +"language_picker_default_language" = "既定値 (%@)"; +"notice_room_invite" = "%@は%@を招待しました"; +"notice_room_third_party_invite" = "%@は、部屋に参加するよう%@へ招待状を送った"; +"notice_room_third_party_registered_invite" = "%@は%@の招待を受け入れました"; +"notice_room_join" = "%@ は参加しました"; +"notice_room_leave" = "%@ は退出しました"; +"notice_room_reject" = "%@は招待を拒否しました"; +"notice_room_kick" = "%@は%@を追い出しました"; +"notice_room_unban" = "%@は%@を追放解除した"; +"notice_room_ban" = "%@は%@を追放した"; +"notice_room_withdraw" = "%@は%@の招待を辞退しました"; +"notice_room_reason" = ". 理由: %@"; +"notice_avatar_url_changed" = "%@はアバターを変更しました"; +"notice_display_name_set" = "%@は表示名を%@に設定しました"; +"notice_display_name_changed_from" = "%@は表示名を%@から%@に変更しました"; +"notice_display_name_removed" = "%@は表示名を削除しました"; +"notice_topic_changed" = "%@はトピックを次のように変更しました: %@"; +"notice_room_name_changed" = "%@は部屋名を次のように変更しました: %@"; +"notice_placed_voice_call" = "%@は電話をかけました"; +"notice_placed_video_call" = "%@はビデオ電話をかけました"; +"notice_answered_video_call" = "%@は通話に応答しました"; +"notice_ended_video_call" = "%@は通話を終了しました"; +"notice_conference_call_request" = "%@はVoIP会議をリクエストしました"; +"notice_conference_call_started" = "VoIP会議が開始されました"; +"notice_conference_call_finished" = "VoIP会議が終了しました"; +// button names +"ok" = "OK"; +"cancel" = "キャンセル"; +"save" = "保存"; +"leave" = "保存しない"; +"send" = "送信"; +"copy_button_name" = "コピー"; +"resend" = "再送信"; +"redact" = "編集"; +"share" = "共有"; +"set_power_level" = "権限レベル"; +"delete" = "削除"; +"view" = "表示"; +// actions +"action_logout" = "ログアウト"; +"create_room" = "部屋を作る"; +"login" = "ログイン"; +"create_account" = "アカウントを作成する"; +"membership_invite" = "招待した"; +"membership_leave" = "退出"; +"membership_ban" = "追放"; +"num_members_one" = "%@ ユーザー"; +"num_members_other" = "%@ ユーザー"; +"invite" = "招待"; +"kick" = "追い出す"; +"ban" = "追放"; +"unban" = "追放解除"; +"message_unsaved_changes" = "保存されていない変更があります。 放置すると捨てられます。"; +// Login Screen +"login_error_already_logged_in" = "ログイン済み"; +"login_error_must_start_http" = "URLは http[s]:// で始まる必要があります"; +// room details dialog screen +"room_details_title" = "部屋の詳細"; +// contacts list screen +"invitation_message" = "私はmatrixであなたとチャットしたい。 詳細はウェブサイトhttp://matrix.orgをお尋ねください。"; +// Settings screen +"settings_title_config" = "構成"; +"settings_title_notifications" = "通知"; +// Notification settings screen +"notification_settings_disable_all" = "すべての通知を無効にする"; +"notification_settings_enable_notifications" = "通知を有効にする"; +"notification_settings_enable_notifications_warning" = "現在、すべての端末ですべての通知が無効になっています。"; +"notification_settings_global_info" = "通知設定はユーザーアカウントに保存され、デスクトップ通知を含むすべてのクライアント間で共有されます。\n\nルールは順番に適用されます。 一致する最初のルールは、メッセージの結果を定義します。\nだから:単語ごとの通知は、送信者ごとの通知よりも重要な部屋ごとの通知よりも重要です。\n同じ種類の複数のルールの場合、一致するリストの最初のルールが優先されます。"; +"notification_settings_per_word_notifications" = "単語単位の通知"; +"notification_settings_per_word_info" = "単語は大文字と小文字を区別せずに一致させ、*ワイルドカードを含めることができます。 従って:\nfooは、区切り文字で囲まれた文字列foo(例 句読点や空白、行の開始/終了)と一致します。\nfoo*は、fooで始まる単語に一致します。\n*foo*は、3文字のfooを含む単語に一致します。"; +"notification_settings_always_notify" = "常に通知"; +"notification_settings_never_notify" = "決して通知しない"; +"notification_settings_word_to_match" = "一致する単語"; +"notification_settings_highlight" = "Highlight"; +"notification_settings_custom_sound" = "カスタムサウンド"; +"notification_settings_per_room_notifications" = "1部屋あたりの通知"; +"notification_settings_per_sender_notifications" = "送信者ごとの通知"; +"notification_settings_sender_hint" = "@user:domain.com"; +"notification_settings_select_room" = "部屋を選択"; +"notification_settings_other_alerts" = "その他のアラート"; +"notification_settings_contain_my_user_name" = "私のユーザー名を含むメッセージについて音で私に通知してください"; +"notification_settings_contain_my_display_name" = "私の表示名が含まれているメッセージについて音で私に通知する"; +"notification_settings_just_sent_to_me" = "私に送られたメッセージについての音で私に知らせる"; +"notification_settings_invite_to_a_new_room" = "私が新しい部屋に招待されたときに知らせる"; +"notification_settings_people_join_leave_rooms" = "人が部屋に入退室したときに私に通知する"; +"notification_settings_receive_a_call" = "通話を受信したときに通知する"; +"notification_settings_suppress_from_bots" = "ボットからの通知を抑制する"; +"notification_settings_by_default" = "既定値では..."; +"notification_settings_notify_all_other" = "他のすべてのメッセージ/部屋について通知する"; +// gcm section +"settings_config_home_server" = "ホームサーバー: %@"; +"settings_config_identity_server" = "IDサーバー: %@"; +"settings_config_user_id" = "ユーザーID: %@"; +// call string +"call_waiting" = "待機中..."; +"call_connecting" = "通話接続中..."; +"call_ended" = "通話終了"; +"call_ring" = "呼び出し中..."; +"incoming_video_call" = "着信ビデオ通話"; +"incoming_voice_call" = "着信音声通話"; +"call_invite_expired" = "期限切れの招待コール"; +// unrecognized SSL certificate +"ssl_trust" = "信頼"; +"ssl_logout_account" = "ログアウト"; +"ssl_remain_offline" = "無視"; +"ssl_fingerprint_hash" = "指紋 (%@):"; +"ssl_could_not_verify" = "リモートサーバーのIDを確認できませんでした。"; +"ssl_cert_not_trust" = "これは、誰かがあなたのトラフィックを悪意を持って傍受しているか、あなたの電話機がリモートサーバーから提供された証明書を信頼していないことを意味します。"; +"ssl_cert_new_account_expl" = "サーバー管理者がこれが予期されると述べた場合は、以下の指紋が提供された指紋と一致することを確認してください。"; +"ssl_unexpected_existing_expl" = "証明書は、お使いの携帯電話にて信頼されたものから変更されました。 これは非常に珍しいことです。 この新しい証明書に同意しないことをお勧めします。"; +"ssl_expected_existing_expl" = "証明書が以前に信頼されたものから信頼されていないものに変更されました。 サーバーが証明書を更新した可能性があります。 予想される指紋については、サーバー管理者にお問い合わせください。"; +"ssl_only_accept" = "サーバー管理者が上記のものと一致する指紋を発行した場合にのみ、証明書を受け入れます。"; +"unignore" = "無視しない"; +"notice_encryption_enabled_ok" = "%@ がエンドツーエンド暗号化をオンにしました。"; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ がエンドツーエンド暗号化をオンにしました (不明なアルゴリズム %2$@)。"; +"device_details_rename_prompt_title" = "セッション名"; +"account_error_push_not_allowed" = "通知は許可されていません"; +"notice_room_third_party_revoked_invite" = "%@ は %@ の部屋への招待を取り消しました"; +// Notice Events with "You" +"notice_room_invite_by_you" = "%@を招待しました"; +"notice_room_invite_you" = "%@があなたを招待しました"; +"notice_room_join_by_you" = "参加しました"; +"notice_room_leave_by_you" = "退室しました"; +"notice_room_kick_by_you" = "%@をキックしました"; +"notice_room_unban_by_you" = "%@をBANしました"; +"notice_room_ban_by_you" = "%@をBANしました"; +"notice_avatar_url_changed_by_you" = "アバターを変更しました"; +"notice_display_name_set_by_you" = "表示名を%@に変更しました"; +"notice_display_name_changed_from_by_you" = "表示名を%@から%@に変更しました"; +"notice_display_name_removed_by_you" = "表示名を削除しました"; +"notice_topic_changed_by_you" = "トピックを変更しました: %@"; +"notice_room_name_changed_by_you" = "部屋の名前を変更しました: %@"; +"notice_placed_voice_call_by_you" = "音声通話を開始しました"; +"notice_placed_video_call_by_you" = "ビデオ通話を開始しました"; +"notice_answered_video_call_by_you" = "電話に出ました"; +"notice_ended_video_call_by_you" = "通話を終了しました"; +"notice_conference_call_request_by_you" = "VoIP会議をリクエストしました"; +"notice_room_name_removed_by_you" = "部屋名を削除しました"; +"notice_room_topic_removed_by_you" = "トピックを削除しました"; +"notice_profile_change_redacted_by_you" = "プロフィール %@を更新しました"; +"notice_room_created_by_you" = "部屋を作成しました"; +"notice_encryption_enabled_ok_by_you" = "あなたはエンドツーエンド暗号化をオンにしました。"; +"notice_encryption_enabled_unknown_algorithm_by_you" = "あなたはエンドツーエンド暗号化をオンにしました (不明なアルゴリズム %2$@)。"; +"notice_redaction_by_you" = "イベントを編集しました (id: %@)"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/kab.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/kab.lproj/MatrixKit.strings new file mode 100644 index 000000000..8749803a1 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/kab.lproj/MatrixKit.strings @@ -0,0 +1,530 @@ + + + +"ssl_expected_existing_expl" = "Aselkin yettwabeddel seg win yettwamanen ɣer win ur nettwaman ara. Ahat aqeddac iɛawed-d aselkin-ines. Nermes anedbal n uqeddac ɣef udsil umḍin yetturaǧun."; +"ssl_unexpected_existing_expl" = "Aselken yettubeddel deg ubdil n win yettwamanen deg tiliɣri-inek·inem. Aya MAČČI D AYEN IGERRZEN. Issefk UR TETTAQBALEḌ ARA aselkin-a amaynut."; +"ssl_cert_new_account_expl" = "Ma yella anedbal n uqeddac yenna-d belli aya yettuau, ẓer ma yella adsil umḍin ddaw yemṣada d udsil umḍin i d-mudden nutni."; +"ssl_cert_not_trust" = "Aya yebɣa ad d-yini yellawin d ugur i tikli-inek·inem s yir udem, neɣ tiliɣri-inek·inem ur yettkil ara ɣef uselkin i as-imudd uqeddac anmeggag."; +"ssl_could_not_verify" = "D awezɣi ad nsenqed timagit n uqeddac agemmaḍ."; +"ssl_remain_offline" = "Ttu"; +"ssl_logout_account" = "Tuffɣa"; + +// unrecognized SSL certificate +"ssl_trust" = "Ittkel"; +"incoming_voice_call" = "Asiwel s taɣect i d-ikecmen"; +"incoming_video_call" = "Asiwel s tvidyut i d-ikecmen"; +"call_ring" = "Yessawal..."; +"call_ended" = "Asiwel yekfa"; +"notification_settings_notify_all_other" = "Ṭṭef-d ilɣa i meṛṛa iznan/tixxamin"; +"settings_title_notifications" = "Ilɣa"; + +// Settings screen +"settings_title_config" = "Tawila"; + +// members list Screen + +// accounts list Screen + +// image size selection + +// invitation members list Screen + +// room creation dialog Screen + +// room info dialog Screen + +// room details dialog screen +"room_details_title" = "Talqayt n texxamt"; +"login_error_must_start_http" = "Ilaq ad yebdu URL s http[s]://"; +"ban" = "Agi"; +"kick" = "Suffeɣ"; +"invite" = "Nced"; +"membership_leave" = "Azelmaḍ"; +"membership_invite" = "Yettwancad"; +"create_account" = "Rnu amiḍan"; +"login" = "Isem n useqdac"; +"create_room" = "Rnu taxxamt"; + +// actions +"action_logout" = "Tuffɣa"; +"view" = "Wali"; +"delete" = "Kkes"; +"share" = "Bḍu"; +"redact" = "Sfeḍ"; +"resend" = "Ɛawed azen"; +"copy_button_name" = "Nɣel"; +"send" = "Azen"; +"leave" = "Ffeɣ"; +"save" = "Sekles"; +"cancel" = "Sefsex"; + +// Room Screen + +// general errors + +// Home Screen + +// Last seen time + +// call events + +/* -*- + Automatic localization for en + + The following key/value pairs were extracted from the android i18n file: + /console/src/main/res/values/strings.xml. +*/ + + +// titles + +// button names +"ok" = "IH"; +"notice_room_created_by_you_for_dm" = "Terniḍ."; +"notice_room_created_by_you" = "Aql-ak·akem terniḍ, tsewleḍ taxxamt."; +"notice_conference_call_request_by_you" = "Tsutreḍ-d asarag VoIP"; +"notice_avatar_url_changed_by_you" = "Tbeddleḍ avatar-inek·inem"; +"notice_room_reject_by_you" = "Tufiḍ tinubga"; +"notice_room_leave_by_you" = "Truḥeḍ"; +"notice_room_join_by_you" = "Terniḍ"; +"notice_conference_call_finished" = "Asarag VoIP yekfa"; +"notice_conference_call_started" = "Asarag VoIP yebda"; + +// Country picker +"country_picker_title" = "Fren tamurt"; +"public" = "Azayez"; +"private" = "Amaẓlay"; +"default" = "amezwer"; +"error" = "Tuccḍa"; +"e2e_passphrase_create" = "Rnu tafyirt tuffirt"; +"e2e_passphrase_not_match" = "Tifyar tuffirin ilaq ad mṣadant"; +"e2e_passphrase_empty" = "Tafyirt tuffirt ur ilaq ara ad ilint d tilmawin"; +"e2e_passphrase_confirm" = "Sentem tafyirt tuffirt"; +"e2e_export" = "Sifeḍ"; + +// E2E export +"e2e_export_room_keys" = "Sifeḍ tisura n texxamt"; +"e2e_passphrase_enter" = "Sekcem tafyirt tuffirt"; +"e2e_import" = "Kter"; + +// E2E import +"e2e_import_room_keys" = "Kter tisura n texxamt"; +"format_time_d" = "d"; +"format_time_h" = "sr"; + +// Time +"format_time_s" = "s"; +"group_section" = "Igrawen"; + +// Groups +"group_invite_section" = "Inced-d"; +"attachment_cancel_upload" = "Sefsex asali?"; +"attachment_cancel_download" = "Sefsex asider?"; +"room_member_power_level_prompt" = "Ur tettizmireḍ ara ad tesfesxeḍ asnifel-a acku tessebɣaseḍ aseqdac ad yesɛu aswir n tezmert am kečč·kemm.\nTebɣiḍ s tidet?"; +"room_no_conference_call_in_encrypted_rooms" = "Isiwlen isaragen ur ttwasefraken ara deg texxamin yettwawgelhen"; +"room_no_power_to_create_conference_call" = "Tesriḍ tisirag akken ad tebduḍ asarag deg texxamt-a"; +"room_left_for_dm" = "Truḥeḍ"; +"room_left" = "Teǧǧiḍ taxxamt"; +"room_error_timeline_event_not_found_title" = "Asali n yideg n tesnakudt ur yeddi ara"; +"room_error_join_failed_title" = "Anekcum ɣer texxamt ur yeddi ara"; +"account_error_email_wrong_description" = "Tagi ur tettban ara d tansa n yimayl tameɣtut"; +"account_error_email_wrong_title" = "Tansa n yimayl d tarameɣtut"; +"account_msisdn_validation_error" = "Asenqed n wuṭṭun n tilifun ur yeddi ara."; +"account_msisdn_validation_title" = "Asenqed yettṛaǧu"; +"account_email_validation_message" = "Ma ulac aɣilif, senqed imayl-ik/im syen sit ɣef useɣwen i yellan. Akken ara yemmed waya, sit ad tkemmleḍ."; +"account_email_validation_title" = "Asenqed yettṛaǧu"; + +// Account +"account_save_changes" = "Sekles ibeddilen"; +"room_event_encryption_verify_ok" = "Senqed"; +"room_event_encryption_info_unblock" = "Kkes seg tebdart taberkant"; +"room_event_encryption_info_block" = "Tabdart taberkant"; +"room_event_encryption_info_unverify" = "Ur yettusenqed ara"; +"room_event_encryption_info_device_blocked" = "Deg tebdart taberkant"; +"room_event_encryption_info_device_verified" = "Yettwasenqed"; +"room_event_encryption_info_event_none" = "ulac"; +"device_details_delete_prompt_message" = "Tamahelt-a tesra asentem-nniḍen.\nI ukemmel, ma ulac aɣilif sekcem awal-ik·im uffir."; +"device_details_delete_prompt_title" = "Asesteb"; +"device_details_rename_prompt_message" = "Isem n tiɣimit tazayezt yettban i yimdanen wukud tettmeslayeḍ"; + +// Settings +"settings" = "Iɣewwaren"; +"notice_encrypted_message" = "Izen yettwawgelhen"; +"notice_room_join_rule_invite_by_you_for_dm" = "Tgiḍ aya i tinubga kan."; +"notice_room_join_rule_invite_by_you" = "Terriḍ taxxamt s tinubga kan."; +"unignore" = "Ur yettwazgel ara"; +"ignore" = "Ttu"; +"show_details" = "Sken talqayt"; +"cancel_download" = "Sefsex Asider"; +"cancel_upload" = "Sefsex Asali"; +"select_all" = "Fren kulec"; +"reset_to_default" = "Wennez ɣer umezwer"; +"mention" = "Abder"; +"start_video_call" = "Yebda usiwel s tvidyut"; +"start_voice_call" = "Yebda usiwel s taɣect"; +"submit" = "Azen"; +"sign_up" = "Jerred"; +"retry" = "Ɛreḍ tikkelt-nniḍen"; +"dismiss" = "Agi"; +"discard" = "Ignorer"; +"continue" = "Kemmel"; +"close" = "Mdel"; +"back" = "Uɣal ɣer deffir"; +"abort" = "Sefsex"; +"yes" = "Ih"; + +// Action +"no" = "Uhu"; +"login_error_resource_limit_exceeded_contact_button" = "Nermes anedbal"; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Aqeddac-a agejdan yewweḍ ɣer talast n useqdac urmid n wayyur."; +"login_error_resource_limit_exceeded_message_default" = "Aqeddac-a agejdan iɛedda yiwet seg tlisa-ines tiɣbula."; +"login_error_resource_limit_exceeded_title" = "Talast n yiɣbula tettuɛedda"; +"login_desktop_device" = "Aniraw"; +"login_tablet_device" = "Taṭablit"; +"login_mobile_device" = "Aziraz"; +"login_leave_fallback" = "Sefex"; +"login_error_login_email_not_yet" = "Aseɣwen n yimayl iɣef mazal ur yettusit ara fell-as"; +"login_error_user_in_use" = "Isem n useqdac-a yettwaseqdac yakan"; +"login_error_limit_exceeded" = "Aṭas n yisuturen i yettwaznen"; +"login_error_not_json" = "Ulac deg-s JSON ameɣtu"; +"login_error_bad_json" = "JSON ur yemsil ara akken iwata"; +"login_error_unknown_token" = "Ajuṭu n unekcum i yettwafernen ur yettwassen ara"; +"login_error_forbidden" = "Isem n uqeddac/awal uffir d arameɣtu"; +"login_email_placeholder" = "Tansa n l'email"; +"login_password_placeholder" = "Mot de passe"; +/* *********************** */ +/* iOS specific */ +/* *********************** */ + +"matrix" = "Matrix"; +"account_msisdn_validation_message" = "Ad naze-n SMS deg-s tangalt n usenqed. Ttxil-k·m sekcem tangalt-a ddaw."; +"login_server_url_placeholder" = "URL (e.g. https://matrix.org)"; +"notification_settings_per_room_notifications" = "Ilɣa s texxamt"; +"notification_settings_per_word_notifications" = "Ilɣa s awal"; +"notice_redaction_by_you" = "Tsemsawiḍ aneḍru (asulay: %@)"; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "Tesfesxeḍ tinubga n %@"; +"notice_room_third_party_revoked_invite_for_dm" = "%@ isefsex tinubga n %@"; +"device_details_last_seen" = "Timeẓri taneggarut\n"; +"notice_feedback" = "Aneḍru n timawin (asulay: %@): %@"; +"login_error_registration_is_not_supported" = "Iklasen ur ttusefraken ara akka tura"; +"ssl_only_accept" = "Ur qebbel ara aselkin alamma isuffeɣ-d unedbal n uqeddac adsil umḍin yemṣadan d win yellan ddaw-a."; +"ssl_fingerprint_hash" = "Adsil umḍin (%@):"; +"call_invite_expired" = "Ancad ɣer usiwel yezri"; + +// Settings keys + +// call string +"call_waiting" = "Yettraǧu..."; +"settings_config_user_id" = "Asulay n useqdac:%@"; +"notification_settings_other_alerts" = "Ilɣuten-nniḍen"; +"notification_settings_select_room" = "Fren taxxamt"; +"notification_settings_sender_hint" = "@user:domain.com"; +"notification_settings_highlight" = "Asebrureq"; +"notification_settings_never_notify" = "Ur ttelɣu ara akk"; +"notification_settings_always_notify" = "Selɣu yal tikkelt"; +"notification_settings_enable_notifications" = "Rmed ilɣa"; +"num_members_other" = "%@ iseqdacen"; +"num_members_one" = "%@ n useqdac"; +"membership_ban" = "Yettwagdel"; +"notice_profile_change_redacted_by_you" = "Tleqqmeḍ amaɣnu-inek·inem %@"; +"notice_event_redacted_by_you" = " sɣur-k·m"; +"notice_display_name_set_by_you" = "Tesbadud isem n uskan ɣer %@"; +"notice_room_unban_by_you" = "Tgedleḍ %@"; +"notice_room_third_party_registered_invite_by_you" = "Tqebleḍ tinubga n %@"; +"notice_room_third_party_invite_by_you_for_dm" = "Tnecdeḍ-d %@"; +"notice_room_invite_you" = "%@ inced-ik·kem-id"; + +// Notice Events with "You" +"notice_room_invite_by_you" = "Tnecdeḍ-d %@"; +"notice_conference_call_request" = "%@ isuter asarag VoIP"; +"notice_ended_video_call" = "%@ iḥbes asiwel"; +"notice_answered_video_call" = "%@ yerra ɣef usiwel"; +"notice_placed_video_call" = "%@ isɛedda siwel s tvidyut"; +"notice_placed_voice_call" = "%@ isɛedda asiwel s taɣect"; +"notice_avatar_url_changed" = "%@ t·ibeddel·t avatar-ines"; +"notice_room_ban" = "%@ igdel %@"; +"notice_room_unban" = "%@ ur yegdil ara %@"; +"notice_room_kick" = "%@ isuffeɣ %@"; +"notice_room_reject" = "%@ yugi tinubga"; +"notice_room_leave" = "%@ azelmaḍ"; +"notice_room_join" = "%@ yerna"; +"notice_room_third_party_registered_invite" = "%@ yeqbel tinnubga n %@"; +"notice_room_third_party_invite_for_dm" = "%@ inced-d %@"; + +/* -*- + Automatic localization for en + + The following key/value pairs were extracted from the android i18n file: + /matrix-sdk/src/main/res/values/strings.xml. +*/ + +"notice_room_invite" = "%@ inced-d %@"; +"ssl_homeserver_url" = "URL n uqeddac agejdan: %@"; +"user_id_placeholder" = "am.: @bob:amedya.com"; +"network_error_not_reachable" = "Ma ulac aɣilif senqed tuqqna-inek·inem"; +"offline" = "aruqqin"; + +// Others +"user_id_title" = "Asulay n useqdac:"; +"format_time_m" = "m"; +"search_searching" = "Anadi la iteddu..."; + +// Search +"search_no_results" = "Ulac igmaḍ"; +"contact_local_contacts" = "Inermisen idiganen"; + +// Contacts +"contact_mx_users" = "Iseqdacen n Matrix"; +"attachment_e2e_keys_import" = "Kter..."; +"attachment_large" = "Meqqer: %@"; +"attachment_medium" = "Alemmas: %@"; +"attachment_small" = "Mecṭuḥ: %@"; +"message_reply_to_sender_sent_a_file" = "yuzen afaylu."; +"message_reply_to_sender_sent_a_video" = "yuzen-d tavidyut."; + +// Reply to message +"message_reply_to_sender_sent_an_image" = "Azen tugna."; + +// Room +"room_please_select" = "Ttxil-k·m fren taxxamt"; +"room_creation_participants_title" = "Imttekkiyen:"; +"room_creation_alias_placeholder_with_homeserver" = "(am. +foo%@)"; +"room_creation_name_placeholder" = "(am. lunchGroup)"; + +// Room creation +"room_creation_name_title" = "Isem n texxamt:"; +"account_error_msisdn_wrong_description" = "Ur yettban ara wagi d uṭṭun n tiliɣri ameɣtu"; +"account_error_msisdn_wrong_title" = "Yir uṭṭun n tiliɣri"; +"account_error_matrix_session_is_not_opened" = "Tiɣimit n Matrix ur teldi ara"; +"account_email_validation_error" = "Ulamek akk tettwasenqed tansa n yimayl. Ma ulac aɣilif senqed imayl-inek·inem syen sit ɣef useɣwen yellan deg-s. Akken ara tgeḍ aya, sit ɣef kemmel"; +"room_event_encryption_info_verify" = "Senqed..."; +"room_event_encryption_info_device_not_verified" = "UR yettwasenqed ARA"; +"room_event_encryption_info_device_fingerprint" = "Adsil umḍin Ed25519\n"; +"room_event_encryption_info_device_name" = "Isem azayez\n"; +"room_event_encryption_info_event_unencrypted" = "ur yettwawgelhen ara"; +"room_event_encryption_info_event_decryption_error" = "Tuccḍa deg tukksa n uwgelhen\n"; +"room_event_encryption_info_event_algorithm" = "Alguritm\n"; +"room_event_encryption_info_event_fingerprint_key" = "Tasarut n udsil umḍin Ed25519 tettusra\n"; +"room_event_encryption_info_event_identity_key" = "Tasarut n timagit Curve25519\n"; +"room_event_encryption_info_event_user_id" = "Asulay n useqdac\n"; +"room_event_encryption_info_event" = "Talɣut n uneḍru\n"; + +// Encryption information +"room_event_encryption_info_title" = "Talɣut n uwgelhen seg yixef ɣer yixef\n\n"; +"device_details_rename_prompt_title" = "Isem n tɣimit"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_name" = "Isem azayez\n"; + +// Devices +"device_details_title" = "Talɣut ɣef tɣimit\n"; +"notification_settings_room_rule_title" = "Taxxamt: '%@'"; +"settings_enter_validation_token_for" = "Sekcem ajiṭun n usentem i %@:"; +"settings_enable_push_notifications" = "Rmed ilɣa n push"; +"room_displayname_more_than_two_members" = "%@ d %@ d wiyaḍ"; +"room_displayname_two_members" = "%@ akked %@"; + +// room display name +"room_displayname_empty_room" = "Texxamt tilemt"; +"notice_crypto_unable_to_decrypt" = "**Ukamek yettwakkes uwgelhen: %@**"; +"notice_invalid_attachment" = "taceqquft yeddan d tarameɣtut"; +"notice_file_attachment" = "afaylu yeddan"; +"notice_room_related_groups" = "Igrawen icudden ɣer texxamt-a d: %@"; +"notice_room_join_rule_public" = "%@ yerra taxxamt d tazayazt."; +"notice_room_created_for_dm" = "%@ yerna."; +"notice_room_created" = "%@ yerna taxxamt syen iswel-itt."; +"notice_profile_change_redacted" = "%@ leqqmen amaɣnu-nsen %@"; +"notice_event_redacted_by" = " s %@"; +"notice_event_redacted" = ""; + +// Events formatter +"notice_avatar_changed_too" = "(ula d avaṭar yettubeddel)"; +"reject_call" = "Agi asiwel"; +"answer_call" = "Err ɣef usiwel"; +"resend_message" = "Ales tuzna n yizen"; +"capture_media" = "Ṭṭef tawlaft/tavidyut"; +"select_account" = "Fren amiḍan"; +"start_chat" = "Bdu adiwenni"; +"set_admin" = "Sbadu anedbal"; +"submit_code" = "Azen tangalt"; +"login_error_resource_limit_exceeded_message_contact" = "\n\nTtxil-k·m nermes anedbal-ik·im n uqeddac i wakken ad tkemmleḍ aseqdec n uqeddac-a."; +"register_error_title" = "Ajerred yecceḍ"; +"login_invalid_param" = "Aɣewwar d arameɣtu"; +"login_use_fallback" = "Seqdec asebtar n ufrananeggaru"; +"login_prompt_email_token" = "Ttxil-k sekcem ajuṭu-inek·inem n usentem n yimayl:"; +"login_optional_field" = "d afrayan"; +"login_identity_server_title" = "URL n uqeddac n timagit:"; +"login_home_server_title" = "URL n uqeddac agejdan:"; + +// Login Screen +"login_create_account" = "Rnu amiḍan:"; +"notification_settings_per_word_info" = "Awalen i d-yettwafen s war ma nefka azal i umṣada n yisekkilen, i izemren ad yesεu ajukeṛ *. Ihi:\nfoo yufa-d azrir foo zzin-as-d s yijemmaqen n wawalen (am. aisgez, tallunt d tazwara neɣ taggara n yizirig).\nfoo* yufa-d meṛṛa awalen i ibeddun s foo.\n*foo* yufa-d akk awalen ideg llan kraḍ n yisekkilen-a foo."; +"notification_settings_global_info" = "Iɣewwaren n yilɣa ttuskelsen deg umiḍan-inek·inem n useqdac, ad ttwabḍun gar meṛṛa imsaɣen ara ten-isferken (ula d iɣewwaren n tnarit).\n\nIlugan ttusnasen akken myezwaren; alugan amezwaru i yemṣadan tesbaduy agmuḍ n yizen.\nIhi: Ilɣa s wawal sεan azal ugar n yilɣa s texxamt i yesεan azal ula d nutni ɣef yilɣa s umazan.\nI wugar n yilugan n yiwen wanaw, amezwaru deg tebdart yemṣadan d netta i d tazwart."; +"local_contacts_access_discovery_warning" = "I usnirem n yinermisen s useqdec yakan n Matrix, %@ yezmer ad yazen tansiwin n yimayl d wuṭṭunen n tiliɣri n udlis-inek·inem n tansiwin ɣer uqeddac-inek·inem n timagit n Matrix i tferneḍ. Ma yella yettusefrak, isefka-inek·inem udmawanen ad ttwagzamen send ad ttwaznen - ttxil-k·m senqed tasertit n tudert tabaḍnit n uqeddac-ik·im n timagit i wugar n telqayt."; +"local_contacts_access_not_granted" = "I usnirem n yiseqdacen seg yinermisen idiganen, anekcum ɣer yinermisen yettusra maca %@ ur yesεi ara tisirag ad t-iseqdec"; +"e2e_export_prompt" = "Akala-a ad ak·am-imudd tisirag i usifeḍ n tsura n yiznan i d-tremseḍ deg texxamin yettwawgelhen, ɣer ufaylu adigan. Syen tzemreḍ ad tketreḍ afaylu deg umsaɣ-nniḍen n Matrix, i wakken amsaɣ-a ad yizmir ad yekkes awgelhen i yiznan-a.\nAfaylu i d-yettusifḍen ad imudd tisirag i yal win i izemren ad t-iɣer, ad yekkes awgelhen i yiznan yettwawgelhen i tzemreḍ ad twaliḍ. Γef waya ilaq ad t-tḥerzeḍ deg wadeg aɣellsan."; +"e2e_import_prompt" = "Akala-a ad ak·am-imudd tisirag i wakken ad tketreḍ tisura i d-tsifḍeḍ yakan seg umsaɣ-nniḍen n Matrix. Tzemreḍ mbeεd ad tekkseḍ awgelhen n yal izen iwumi yezmer umsaɣ-nniḍen ad asen-yekkes awgelhen.\nAfaylu i d-yettisifḍen yettwammesten s tefyirt tuffirt. Ilaq ad teskecmeḍ tafyirt tuffirt dagi, i wakken ad tekkseḍ awgelhen i ufaylu."; +"attachment_e2e_keys_file_prompt" = "Afaylu-a deg-s tisura n uwgelhen ttusifḍent-d seg umsaɣ Matrix.\nTebɣiḍ ad twaliḍ agbur n ufaylu neɣ ad d-tketreḍ tisura i yellan deg-s?"; +"room_error_timeline_event_not_found" = "Asnas yettaεraḍ ad d-isali kra n wagazen ufrinen deg tesnakudt n texxamt, maca ur tt-yifi ara"; +"room_event_encryption_verify_message" = "I usenqed n tɣimit-a ma tzemreḍ ad tettekleḍ fell-as, ttxil-k·m nermes bab-is s useqdec s ubrid-nniḍen (am. s timmad-is neɣ s usiwel) syen steqsi-t ma yella tasarut i yettwali deg yiɣewwaren-is n useqdac n tɣimit-a temṣada d tsarut yellan ddaw:\n\n Isem n tɣimit: %@\n Asulay n texxamt: %@\n Tasarut n texxamt: %@\n\nMa yella mṣadant, sit ɣef tqeffalt n usenqed ddaw. Ma yella ur mṣadant ara, ihi yella win i yettεekkiṛen tiɣimit-a, neɣ ahat tebɣiḍ ad tsiteḍ ɣef tqeffalt n tebdart taberkant deg ubdel.\n\nΓer sdat, akala-a n usenqed-a ad yuɣal yemmed ugar."; +"call_connecting" = "Asiwel iteddu..."; +"settings_config_identity_server" = "Aqeddac n timagit: %@"; + +// gcm section +"settings_config_home_server" = "Aqeddac agejdan: %@"; +"notification_settings_by_default" = "S umezwer..."; +"notification_settings_suppress_from_bots" = "Kkes ilɣa n yiṛubuten"; +"notification_settings_receive_a_call" = "Lɣu-yi-d mi ara yi-d-yaweḍ usiwel"; +"notification_settings_people_join_leave_rooms" = "Lɣu-yi-d mi ara ad d-rnun neɣ ad ǧǧen yimdanen tixxamin"; +"notification_settings_invite_to_a_new_room" = "Lɣu-yi-d mi ara ttunecdeɣ ɣer texxamt tamaynut"; +"notification_settings_just_sent_to_me" = "Lɣu-yi-d s yimesli ɣef yiznan i d-yettwaznen i nekk kan"; +"notification_settings_contain_my_display_name" = "Lɣu-yi-d s yimesli ɣef yiznan ideg yella yisem-iw ameskan"; +"notification_settings_contain_my_user_name" = "Lɣu-yi-d s yimesli ɣef yiznan ideg yella yisem-inu n useqdac"; +"notification_settings_per_sender_notifications" = "Ilɣa s umazan"; +"notification_settings_custom_sound" = "imesli udmawan"; +"notification_settings_word_to_match" = "awala ara d-yettwafen"; +"notification_settings_enable_notifications_warning" = "Meṛṛa ilɣa nsan akka tura ɣef meṛṛa ibenkan."; + +// Notification settings screen +"notification_settings_disable_all" = "Sens meṛṛa ilɣa"; + +// contacts list screen +"invitation_message" = "Bɣiɣ ad mmeslayeɣ yid-k·m s Matrix. Ttxil-k·m, rzu ɣer usmel web http://matrix.org i wugar n talɣut."; + +// Login Screen +"login_error_already_logged_in" = "Yeqqen yakan"; +"message_unsaved_changes" = "Llan isenfal ur nettusekles ara. Tuffɣa ad ten-tsefsex."; +"unban" = "Kkes agdel"; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "Terriḍ iznan si sya d asawen ad ttbanen i yal yiwen, segmi ara d-rnun."; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "Terriḍ azray n texxamt si sya d asawen ad d-yettban i meṛṛa imttekkiyen n texxamt, sgmi ara d-rnun."; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "Terriḍ iznan si sya d asawen ad d-ttbanen i yal yiwen, segmi ara d-ttusnubegten."; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "Terriḍ azray n texxamt si sya d asawen ad d-yettban i meṛṛa imttekkiyen n texxamt, segmi ara d-ttusnubegten."; +"notice_room_history_visible_to_members_by_you_for_dm" = "Terriḍ iznan si sya ɣer sdat ad d-ttbanen i meṛṛa imttekkiyen n texxamt."; +"notice_room_history_visible_to_members_by_you" = "Terriḍ azray n texxamt si sya ɣer sdat ad d-yettban i meṛṛa imttekkiyen n texxamt."; +"notice_room_history_visible_to_anyone_by_you" = "Terriḍ azray n texxamt si sya ɣer sdat ad yettban i yal yiwen."; +"notice_encryption_enabled_unknown_algorithm_by_you" = "Tremdeḍ awgelhen seg yixef ɣer yixef (alguritm d arussin %@)."; +"notice_encryption_enabled_ok_by_you" = "Tremdeḍ awgelhen seg yixef ɣer yixef."; +"notice_room_topic_removed_by_you" = "Tekkseḍ asentel"; +"notice_room_name_removed_by_you_for_dm" = "Tekkseḍ isem"; +"notice_room_name_removed_by_you" = "Tekkseḍ isem n texxamt"; +"notice_ended_video_call_by_you" = "Tekfiḍ asiwel"; +"notice_answered_video_call_by_you" = "Terriḍ ɣef usiwel"; +"notice_placed_video_call_by_you" = "Tseεeddaḍ asiwel s tvidyut"; +"notice_placed_voice_call_by_you" = "Tesεeddaḍ asiwel s umeslaw"; +"notice_room_name_changed_by_you_for_dm" = "Tbeddleḍ isem s %@."; +"notice_room_name_changed_by_you" = "Tbeddleḍ isem n texxamt s %@."; +"notice_topic_changed_by_you" = "Tbeddleḍ asentel s \"%@\"."; +"notice_display_name_removed_by_you" = "Tekkzeḍ isem-inek·inem ameskan"; +"notice_display_name_changed_from_by_you" = "Tbeddleḍ isem-inek ameskan seg %@ ɣer %@"; +"notice_room_withdraw_by_you" = "Tesfesxeḍ tinubga n %@"; +"notice_room_kick_by_you" = "Tsuffɣeḍ %@"; +"notice_room_ban_by_you" = "Tgedleḍ %@"; +"notice_room_third_party_revoked_invite_by_you" = "Tekkseḍ tinubga n %@ i wakken ad d-yernu ɣer texxamt"; +"notice_room_third_party_invite_by_you" = "Tuzneḍ tinubga i %@ i wakken ad d-yernu ɣer texxamt"; +"notice_room_name_changed_for_dm" = "%@ ibeddel isem s %@."; +"notice_room_name_changed" = "%@ ibeddel isem n texxamt s %@."; +"notice_topic_changed" = "%@ ibeddel asentel s \"%@\"."; +"notice_display_name_removed" = "%@ yekkes isem-is ameskan"; +"notice_display_name_changed_from" = "%@ ibeddel isem-is ameskan seg %@ ɣer %@"; +"notice_display_name_set" = "%@ yesbadu isem-ines ameskan s %@"; +"notice_room_reason" = "Ssebba: %@"; +"notice_room_withdraw" = "%@ isefsex tinubga n %@"; +"notice_room_third_party_revoked_invite" = "%@ yekkes tinubga n %@ i wakken ad d-yernu ɣer texxamt"; +"notice_room_third_party_invite" = "%@ yuzen tinubga i %@ i wakken ad d-yernu  ɣer texxamt"; +"language_picker_default_language" = "Amezwer (%@)"; + +// Language picker +"language_picker_title" = "Fren tutlayt"; +"local_contacts_access_discovery_warning_title" = "Asnirem n yiseqdacen"; +"microphone_access_not_granted_for_call" = "Isawalen sran ad kecmen ɣer usawaḍ maca %@ ur yesεi ara tisirag ad t-iseqdec"; + +// Permissions +"camera_access_not_granted_for_call" = "Isawalen s tvidyut sran anekcum ɣer tkamiṛat maca %@ ur yesεi ara tisirag ad tt-iseqdec"; +"power_level" = "Aswir n tezmert"; +"not_supported_yet" = "Ur yettusefrak ara akka tura"; +"error_common_message" = "Tella-d tuccḍa. Ttxil-kṃ εreḍ tikkelt-nniḍen ticki."; +"unsent" = "Ur yettwazen ara"; +"attachment_multiselection_original" = "Teɣzi tamirant"; +"attachment_multiselection_size_prompt" = "Tebɣiḍ ad tazneḍ iznan d:"; +"attachment_original" = "Teɣzi tamirant: %@"; + +// Attachment +"attachment_size_prompt" = "Tebɣiḍ ad t-tazneḍ d:"; + +// Room members +"room_member_ignore_prompt" = "D tidet tebɣiḍ ad teffreḍ meṛṛa iznan i d-yusan sɣur aseqdac-a?"; +"message_reply_to_message_to_reply_to_prefix" = "D tiririt i"; +"message_reply_to_sender_sent_an_audio_file" = "yuzen afaylu ameslaw."; +"room_error_cannot_load_timeline" = "Asali n tesnakudt ur yeddi ara"; +"room_error_topic_edition_not_authorized" = "Ur tesεiḍ tisirag ad tesnefleḍ asentel n texxamt-a"; +"room_error_name_edition_not_authorized" = "Ur tesεiḍ ara tisirag ad tesnefleḍ isem n texxamt-a"; +"room_error_join_failed_empty_room" = "D awezɣi akka tura ad talseḍ tuɣalin ɣer texxamt tilemt."; +"room_creation_participants_placeholder" = "(am. @bob:homeserver1; @john:homeserver2...)"; +"room_creation_alias_placeholder" = "(am. #foo:example.org)"; +"room_creation_alias_title" = "Isem yettunefken i texxamt:"; +"account_error_push_not_allowed" = "Ilɣa ur ttusirgen ara"; +"account_error_picture_change_failed" = "Asenfel n tugna yecceḍ"; +"account_error_display_name_change_failed" = "Asenfel n yisem ameskan yecceḍ"; +"account_linked_emails" = "Imaylen yettwacudden"; +"account_link_email" = "Rnu imayl"; +"room_event_encryption_verify_title" = "Senqed tiɣimit\n\n"; +"room_event_encryption_info_device_verification" = "Asenqed\n"; +"room_event_encryption_info_device_id" = "Asulay\n"; +"room_event_encryption_info_device_unknown" = "tiɣimit tarussint\n"; +"room_event_encryption_info_device" = "\nTalɣut n tɣimit n umazan\n"; +"room_event_encryption_info_event_session_id" = "Asulay n tɣimit\n"; +"device_details_identifier" = "Asulay\n"; +"settings_enable_inapp_notifications" = "Rmed ilɣa deg usnas"; +"notice_in_reply_to" = "D tiririt i"; +"notice_sticker" = "astiker"; +"notice_crypto_error_unknown_inbound_session_id" = "Tiɣimit n umazan ur aɣ-d-tuzin ara tisura i yizen-a."; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ yerra iznan si sya ɣer sdat ttbanen i yal yiwen, seg wasmi ara d-rnun."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ yerra azray n texxamt si sya ɣer sdat yettban i meṛṛa imttekkiyen, seg wasmi ara d-rnun."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ yerra azray n texxamt si sya ɣer sdat yettban i yal yiwen, segmi ara d-yettusnubget."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ yerra azray n texxamt si sya ɣer sdat yettban i meṛṛa imttekkiyen n texxamt, seg wasmi ara d-ttusnubegten."; +"notice_room_history_visible_to_anyone" = "%@ yerra azray n texxamt si sya ɣer sdat yettban i yal yiwen."; +"notice_room_history_visible_to_members" = "%@ yerra azray n texxamt si sya ɣer sdat yettban i meṛṛa imttekkiyen n texxmat."; +"notice_room_history_visible_to_members_for_dm" = "%@ yerra iznan si sya ɣer sdat ttbanen i meṛṛa imttekkiyen n texxamt."; +"notice_error_unknown_event_type" = "Anaw n uneḍru d arussin"; +"notice_error_unexpected_event" = "Aneḍru ur nettwaṛǧæ ara"; +"notice_error_unsupported_event" = "Aneḍru ur yettusefrak ara"; +"notice_redaction" = "%@ yekkes aneḍru (asulay: %@)"; +"notice_unsupported_attachment" = "taceqquft yeddan ur tettusefrak ara: %@"; +"notice_location_attachment" = "adig yeddan"; +"notice_video_attachment" = "tavidyut yeddan"; +"notice_audio_attachment" = "ameslaw yeddan"; +"notice_image_attachment" = "tugna yeddan"; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ yermed awgelhen seg yixef ɣer yixef (alguritm %2$@ d arussin)."; +"notice_encryption_enabled_ok" = "%@ yermed awgelhen seg yixef ɣer yixef."; +"notice_room_aliases_for_dm" = "Ismawen yettunefken d: %@"; +"notice_room_aliases" = "Ismawen yettunefken i texxamt d: %@"; +"notice_room_power_level_event_requirement" = "Iswiren n tezmert addayen icudden ɣer yineḍruyen d:"; +"notice_room_power_level_acting_requirement" = "Iswiren n tezmert addayen i yezmer ad yesεu useqdac send asedmer d:"; +"notice_room_power_level_intro" = "Aswir n tezmert n yimttekkiyen n texxamt d:"; +"notice_room_power_level_intro_for_dm" = "Aswir n tezmert n yimttekkiyen d:"; +"notice_room_join_rule_public_by_you_for_dm" = "Terriḍ aya d azayaz."; +"notice_room_join_rule_public_by_you" = "Terriḍ taxxamt d tazayazt."; +"notice_event_redacted_reason" = " [ssebba: %@]"; +"notice_room_join_rule_public_for_dm" = "%@ yerra aya d azayaz."; +"notice_room_join_rule_invite_for_dm" = "%@ yerra aya s tinubga kan."; +// New +"notice_room_join_rule_invite" = "%@ yerra taxxamt s tinubga kan."; +// Old +"notice_room_join_rule" = "Alugan n tmerna d: %@"; +"notice_room_topic_removed" = "%@ yekkes asentel"; +"notice_room_name_removed" = "%@ yekkes isem n texxamt"; +"notice_room_name_removed_for_dm" = "%@ yekkes isem"; +"end_call" = "Kfu asiwel"; +"invite_user" = "Snubget-d aseqdac n Matrix"; +"attach_media" = "Seddu amidyat seg temkarḍit"; +"set_moderator" = "Sbadu imḍebber"; +"set_power_level" = "Sbadu aswir n tezmert"; +"set_default_power_level" = "Wennez aswir n tezmert"; +"login_error_forgot_password_is_not_supported" = "Tatut n wawal uffir ur yettusefrak ara akka tura"; +"login_error_do_not_support_login_flows" = "Akka tura ur nsefrak ara ula yiwen neɣ meṛṛa aragen yettusbadun s uqeddac-a agejdan"; +"login_error_no_login_flow" = "Tiririt n telɣut n usesteb seg uqeddac-a agejdan ur teddi ara"; +"login_error_title" = "Anekcum yecceḍ"; +"login_email_info" = "Afran n tansa n yiamyl tettaǧǧa iseqdacen-nniḍen ad ak·akem-afen deg Matrix s sshala, rnu ad ak-tmudd abrid ad twennzeḍ awala-ik·im uffir ar sdat."; +"login_display_name_placeholder" = "Isem yettwaskanen (am. Bob Obson)"; +"login_user_id_placeholder" = "Asulay n Matrix (am. @bob:matrix.org neɣ bob)"; +"login_identity_server_info" = "Matrix yettmuddu-d iqeddacen n timagit i ucuddu n yimaylen, atg. Wuɣur ttuɣalen yisulayen n Matrix. Ala https://matrix.org i yellan akka tura."; +"login_home_server_info" = "Aqeddac-ik·im agejdan isseklas meṛṛa idiwenniyen-inek·inem d yisefka n umiḍan-inekịnem"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/ko.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/ko.lproj/MatrixKit.strings new file mode 100644 index 000000000..88e2af12a --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/ko.lproj/MatrixKit.strings @@ -0,0 +1,381 @@ +"view" = "보기"; +"back" = "돌아가기"; +"continue" = "계속"; +"leave" = "떠나기"; +"invite" = "초대"; +"retry" = "다시 시도"; +"cancel" = "취소"; +"save" = "저장"; +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "계정 만들기:"; +"login_server_url_placeholder" = "URL (예: https://matrix.org)"; +"login_home_server_title" = "홈서버 URL:"; +"login_identity_server_title" = "ID 서버 URL:"; +"login_user_id_placeholder" = "Matrix ID (예: @bob:matrix.org 혹은 bob)"; +"login_password_placeholder" = "비밀번호"; +"login_optional_field" = "선택"; +"login_email_placeholder" = "이메일 주소"; +"login_error_title" = "로그인 실패"; +"login_error_forbidden" = "잘못된 이름/비밀번호"; +"login_error_user_in_use" = "이 이름은 이미 사용중입니다"; +"login_leave_fallback" = "취소"; +"register_error_title" = "가입 실패"; +"login_error_forgot_password_is_not_supported" = "비밀번호 찾기는 현재 지원하지 않습니다"; +"login_mobile_device" = "모바일"; +"login_tablet_device" = "태블릿"; +"login_desktop_device" = "데스크톱"; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "이 홈서버가 월 간 활성 사용자 한도를 초과했습니다."; +"login_error_resource_limit_exceeded_message_contact" = "\n\n서비스를 계속 이용하려면 서비스 관리자에게 연락하세요."; +"login_error_resource_limit_exceeded_contact_button" = "관리자에게 연락하기"; +// Action +"no" = "아니요"; +"yes" = "네"; +"close" = "닫기"; +"sign_up" = "등록하기"; +"submit" = "제출"; +"submit_code" = "코드 제출"; +"set_moderator" = "중재자로 설정"; +"set_admin" = "관리자로 설정"; +"start_chat" = "대화 시작"; +"abort" = "중단"; +"discard" = "삭제"; +"start_voice_call" = "음성 통화 시작"; +"start_video_call" = "영상 통화 시작"; +"select_account" = "계정 선택"; +"capture_media" = "사진/영상 찍기"; +"invite_user" = "Matrix 사용자 초대"; +"reset_to_default" = "기본으로 되돌리기"; +"resend_message" = "메시지 다시 보내기"; +"select_all" = "모두 선택"; +"cancel_upload" = "업로드 취소"; +"cancel_download" = "다운로드 취소"; +"show_details" = "세부 정보 표시"; +"answer_call" = "전화 받기"; +"reject_call" = "전화 거부"; +"end_call" = "전화 끝내기"; +"ignore" = "무시"; +"unignore" = "무시하지 않기"; +"notice_sticker" = "스티커"; +// room display name +"room_displayname_empty_room" = "빈 방"; +"room_displayname_two_members" = "%@님과 %@님"; +// Settings +"settings" = "설정"; +"settings_enable_push_notifications" = "푸시 알림 켜기"; +// Devices +"device_details_title" = "기기 정보\n"; +"device_details_name" = "공개 이름\n"; +"device_details_identifier" = "ID\n"; +"device_details_rename_prompt_message" = "기기의 공개 이름은 대화하는 사람들에게 보여집니다"; +"device_details_delete_prompt_title" = "확인"; +"device_details_delete_prompt_message" = "이 작업은 추가 확인이 필요합니다.\n계속하려면, 비밀번호를 입력해주세요."; +// Encryption information +"room_event_encryption_info_title" = "종단간 암호화 정보\n\n"; +"room_event_encryption_info_event_user_id" = "사용자 ID\n"; +"room_event_encryption_info_event_algorithm" = "알고리즘\n"; +"room_event_encryption_info_event_unencrypted" = "암호화되지 않음"; +"room_event_encryption_info_device" = "\n발신자 기기 정보\n"; +"room_event_encryption_info_device_unknown" = "알 수 없는 기기\n"; +"room_event_encryption_info_device_name" = "공개 이름\n"; +"room_event_encryption_info_device_id" = "ID\n"; +"room_event_encryption_info_device_verification" = "확인\n"; +"room_event_encryption_info_device_fingerprint" = "Ed25519 지문\n"; +"room_event_encryption_info_device_verified" = "확인됨"; +"room_event_encryption_info_device_not_verified" = "확인되지 않음"; +"account_link_email" = "이메일 연결"; +"account_linked_emails" = "이메일 연결함"; +// Others +"user_id_title" = "사용자 ID:"; +"offline" = "오프라인"; +"error" = "오류"; +"not_supported_yet" = "아직 지원하지 않음"; +"default" = "기본"; +"ssl_homeserver_url" = "홈서버 URL: %@"; +// Country picker +"country_picker_title" = "나라를 고르세요"; +// Language picker +"language_picker_title" = "언어를 고르세요"; +"language_picker_default_language" = "기본 (%@)"; +"login_home_server_info" = "당신의 홈서버는 대화와 계정 정보를 저장합니다"; +"login_identity_server_info" = "Matrix는 어떤 이메일이 어떤 Matrix ID에 속하느 지 추적하도록 ID 서버를 제공합니다. 현재는 https://matrix.org만 존재합니다."; +"login_display_name_placeholder" = "표시 이름 (예: Bob Obson)"; +"login_email_info" = "다른 사용자가 Matrix에서 당신을 더 쉽게 찾고, 이후 비밀번호를 다시 설정할 수 있도록 이메일 주소를 지정하세요."; +"login_prompt_email_token" = "이메일 확인 토큰을 입럭해주세요:"; +"login_error_no_login_flow" = "이 홈서버에서 확인 정보를 검색하는데 실패함"; +"login_error_do_not_support_login_flows" = "현재 이 홈서버가 정의한 일부 혹은 모든 로그인 흐름을 지원하지 않음"; +"login_error_registration_is_not_supported" = "등록을 현재 지원하지 않음"; +"login_error_unknown_token" = "지정된 접근 토큰이 인식되지 않음"; +"login_error_bad_json" = "잘못된 JSON"; +"login_error_not_json" = "올바른 JSON을 포함하지 않음"; +"login_error_limit_exceeded" = "너무 많은 요청을 보냈습니다"; +"login_error_login_email_not_yet" = "아직 클릭하지 않은 이메일 링크"; +"login_use_fallback" = "대체 페이지 사용"; +"login_invalid_param" = "잘못된 매개변수"; +"login_error_resource_limit_exceeded_title" = "리소스 한도 초과됨"; +"login_error_resource_limit_exceeded_message_default" = "이 홈서버가 리소스 한도를 초과했습니다."; +"dismiss" = "버리기"; +"set_power_level" = "권한 등급 설정"; +"set_default_power_level" = "권한 등급 다시 설정"; +"mention" = "언급"; +"attach_media" = "라이브러리에서 미디어 첨부"; +// Events formatter +"notice_avatar_changed_too" = "(아바타도 변경됬습니다)"; +"notice_room_name_removed" = "%@님이 방 이름을 제거했습니다"; +"notice_room_topic_removed" = "%@님이 주제를 제거했습니다"; +"notice_event_redacted" = "<%@ 검열됨>"; +"notice_event_redacted_by" = " 사용자 %@님"; +"notice_event_redacted_reason" = " [이유: %@]"; +"notice_profile_change_redacted" = "%@님이 프로필 %@을(를) 업데이트했습니다"; +"notice_room_created" = "%@님이 방을 만들었습니다"; +"notice_room_join_rule" = "참가 규칙: %@"; +"notice_room_power_level_intro" = "방 구성원의 권한 등급:"; +"notice_room_power_level_acting_requirement" = "사용자가 활동할 수 있는 최소 권한 등급:"; +"notice_room_power_level_event_requirement" = "이벤트와 관련된 최소 권한 등급:"; +"notice_room_aliases" = "방의 별칭: %@"; +"notice_room_related_groups" = "이 방과 관련된 그룹: %@"; +"notice_encrypted_message" = "암호화된 메시지"; +"notice_encryption_enabled" = "%@님이 종단간 암호화를 켰습니다 (알고리즘 %@)"; +"notice_image_attachment" = "사진 첨부"; +"notice_audio_attachment" = "소리 첨부"; +"notice_video_attachment" = "영상 첨부"; +"notice_location_attachment" = "위치 첨부"; +"notice_file_attachment" = "파일 첨부"; +"notice_invalid_attachment" = "잘못된 첨부"; +"notice_unsupported_attachment" = "지원하지 않는 첨부: %@"; +"notice_feedback" = "피드백 이벤트 (ID: %@): %@"; +"notice_redaction" = "%@님이 이벤트를 검열했습니다 (ID: %@)"; +"notice_error_unsupported_event" = "지원하지 않는 이벤트"; +"notice_error_unexpected_event" = "예기치 못한 이벤트"; +"notice_error_unknown_event_type" = "알 수 없는 이벤트 유형"; +"notice_room_history_visible_to_anyone" = "%@님이 이후 방 기록을 누구나 볼 수 있게 했습니다."; +"notice_room_history_visible_to_members" = "%@님이 이후 방 기록을 모든 방 구성원이 볼 수 있게 했습니다."; +"notice_room_history_visible_to_members_from_invited_point" = "%@님이 이후 방 기록을 초대된 시점부터 모든 방 구성원이 볼 수 있게 했습니다."; +"notice_room_history_visible_to_members_from_joined_point" = "%@님이 이후 방 기록을 참가한 시점부터 모든 방 구성원이 볼 수 있게 했습니다."; +"notice_crypto_unable_to_decrypt" = "** 암호를 복호화할 수 없음: %@ **"; +"notice_crypto_error_unknown_inbound_session_id" = "발신자의 기기에서 이 메시지의 키를 보내지 않았습니다."; +"notice_in_reply_to" = "관련 대화"; +"room_displayname_more_than_two_members" = "%@님 외 %@명"; +"settings_enable_inapp_notifications" = "인앱 알림 켜기"; +"settings_enter_validation_token_for" = "%@의 확인 토큰을 입력하세요:"; +"notification_settings_room_rule_title" = "방: '%@'"; +"device_details_last_seen" = "마지막으로 본 순간\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"room_event_encryption_info_event" = "이벤트 정보\n"; +"room_event_encryption_info_event_identity_key" = "Curve25519 ID 키\n"; +"room_event_encryption_info_event_fingerprint_key" = "Ed25519 핑거프린트 키가 필요함\n"; +"room_event_encryption_info_event_session_id" = "세션 ID\n"; +"room_event_encryption_info_event_decryption_error" = "암호 복호화 오류\n"; +"room_event_encryption_info_event_none" = "없음"; +"room_event_encryption_info_device_blocked" = "블랙리스트 대상"; +"room_event_encryption_info_verify" = "확인 중..."; +"room_event_encryption_info_unverify" = "확인하지 않음"; +"room_event_encryption_info_block" = "블랙리스트"; +"room_event_encryption_info_unblock" = "블랙리스트 제외"; +"room_event_encryption_verify_title" = "기기 확인\n\n"; +"room_event_encryption_verify_message" = "이 기기를 신뢰할 수 있는지 확인하려면, 다른 방법을 사용하여 소유자와 연락해주세요 (예: 현실에서 혹은 전화로) 그리고 이 기기의 사용자 설정에서 볼 수 있는 키가 아래의 키와 일치하는지 물어보세요:\n\n\t기기 이름: %@\n\t기기 ID: %@\n\t기기 키: %@\n\n그것이 맞다면, 아래 확인 버튼을 누르세요. 맞지 않다면, 다른 사람이 이 기기를 가로채고 있는 것이고 블랙리스트에 올려야 합니다.\n\n앞으로 이 확인 절차는 더 정교해질 것입니다."; +"room_event_encryption_verify_ok" = "확인"; +// Account +"account_save_changes" = "변경 사항 저장"; +"account_email_validation_title" = "확인 보류 중"; +"account_email_validation_message" = "이메일을 확인하고 거기에 있는 링크를 클릭해주세요. 모두 끝나면, 계속을 클릭하세요."; +"account_email_validation_error" = "이메일 주소를 확인할 수 없습니다. 이메일을 확인하고 거기에 있는 링크를 클릭해주세요. 모두 끝나면, 계속을 클릭하세요"; +"account_msisdn_validation_title" = "확인 보류 중"; +"account_msisdn_validation_message" = "활성 코드가 있는 SMS를 보냈습니다. 아래에 이 코드를 입력해주세요."; +"account_msisdn_validation_error" = "전화번호를 확인할 수 없습니다."; +"account_error_display_name_change_failed" = "표시 이름 변경에 실패함"; +"account_error_picture_change_failed" = "사진 변경에 실패함"; +"account_error_matrix_session_is_not_opened" = "Matrix 세션이 열리지 않았습니다"; +"account_error_email_wrong_title" = "올바르지 않은 이메일 주소"; +"account_error_email_wrong_description" = "올바른 이메일 주소로 보이지 않습니다"; +"account_error_msisdn_wrong_title" = "올바르지 않은 전화번호"; +"account_error_msisdn_wrong_description" = "올바른 전화번호로 보이지 않습니다"; +"account_error_push_not_allowed" = "알림이 허용되지 않음"; +// Room creation +"room_creation_name_title" = "방 이름:"; +"room_creation_name_placeholder" = "(예: lunchGroup)"; +"room_creation_alias_title" = "방 별칭:"; +"room_creation_alias_placeholder" = "(예: #foo:example.org)"; +"room_creation_alias_placeholder_with_homeserver" = "(예: #foo%@)"; +"room_creation_participants_title" = "참가자:"; +"room_creation_participants_placeholder" = "(예: @bob:homeserver1; @john:homeserver2...)"; +// Room +"room_please_select" = "방을 선택해주세요"; +"room_error_join_failed_title" = "방 참가에 실패함"; +"room_error_join_failed_empty_room" = "현재 빈 방에 다시 참가할 수 없습니다."; +"room_error_name_edition_not_authorized" = "이 방 이름을 편집할 권한이 없습니다"; +"room_error_topic_edition_not_authorized" = "이 방 주제를 편집할 권한이 없습니다"; +"room_error_cannot_load_timeline" = "타임라인 불러오기에 실패함"; +"room_error_timeline_event_not_found_title" = "타임라인 위치 불러오기에 실패함"; +"room_error_timeline_event_not_found" = "애플리케이션이 이 방의 타임라인에서 특정 시점을 불러오려 했으나 찾을 수 없었습니다"; +"room_left" = "당신은 방을 떠났습니다"; +"room_no_power_to_create_conference_call" = "이 방에 회의를 시작하려면 초대할 권한이 필요합니다"; +"room_no_conference_call_in_encrypted_rooms" = "암호화된 방에서 회의 전화는 지원되지 않습니다"; +// Reply to message +"message_reply_to_sender_sent_an_image" = "사진 보내기."; +"message_reply_to_sender_sent_a_video" = "영상 보내기."; +"message_reply_to_sender_sent_an_audio_file" = "음성 파일 보내기."; +"message_reply_to_sender_sent_a_file" = "파일 보내기."; +"message_reply_to_message_to_reply_to_prefix" = "관련 대화"; +// Room members +"room_member_ignore_prompt" = "이 사용자의 모든 메시지를 숨기겠습니까?"; +"room_member_power_level_prompt" = "사용자를 자신과 같은 권한 등급으로 승급시키는 변경 사항은 취소할 수 없습니다.\n확신합니까?"; +// Attachment +"attachment_size_prompt" = "다음으로 보내겠습니까:"; +"attachment_original" = "실제 크기: %@"; +"attachment_small" = "작게: %@"; +"attachment_medium" = "중간: %@"; +"attachment_large" = "크게: %@"; +"attachment_cancel_download" = "다운로드를 취소합니까?"; +"attachment_cancel_upload" = "업로드를 취소합니까?"; +"attachment_multiselection_size_prompt" = "다음으로 사진을 보내겠습니까:"; +"attachment_multiselection_original" = "실제 크기"; +"attachment_e2e_keys_file_prompt" = "이 파일은 Matrix 클라이언트에서 내보낸 암호화 키를 갖고 있습니다.\n파일 내용물을 보거나 갖고 있는 키를 가져오고 싶나요?"; +"attachment_e2e_keys_import" = "가져오기..."; +// Contacts +"contact_mx_users" = "Matrix 사용자"; +"contact_local_contacts" = "로컬 연락처"; +// Groups +"group_invite_section" = "초대"; +"group_section" = "그룹"; +// Search +"search_no_results" = "결과 없음"; +"search_searching" = "검색 중..."; +// Time +"format_time_s" = "초"; +"format_time_m" = "분"; +"format_time_h" = "시"; +"format_time_d" = "일"; +// E2E import +"e2e_import_room_keys" = "방 키 가져오기"; +"e2e_import_prompt" = "이 과정으로 다른 Matrix 클라이언트에서 이전에 내보낸 암호화 키를 가져올 수 있습니다. 이 키로 다른 클라이언트에서 복호화할 수 있는 모든 메시지를 복호화할 수 있게 됩니다.\n내보낸 파일은 암호로 보호됩니다. 파일을 복호화하려면 여기에 암호를 입력해야 합니다."; +"e2e_import" = "가져오기"; +"e2e_passphrase_enter" = "암호 입력"; +// E2E export +"e2e_export_room_keys" = "방 키 내보내기"; +"e2e_export_prompt" = "이 과정으로 암호화된 방에서 받은 메시지의 키를 로컬 파일로 내보낼 수 있습니다. 그런 다음 이후 다른 Matrix 클라이언트에 파일을 가져올 수 있습니다, 이 키로 메시지를 복호화할 수 있게 됩니다.\n파일을 읽을 수 있는 모든 사용자는 내보낸 파일로 볼 수 있는 암호화된 메시지를 복호화할 수 있으므로, 안전하게 보관해야 합니다."; +"e2e_export" = "내보내기"; +"e2e_passphrase_confirm" = "암호 확인"; +"e2e_passphrase_empty" = "암호를 입력해주세요"; +"e2e_passphrase_not_match" = "암호가 일치하지 않음"; +"e2e_passphrase_create" = "암호 만들기"; +"unsent" = "보내지지 않음"; +"error_common_message" = "오류가 발생했습니다. 나중에 다시 시도해주세요."; +"private" = "보안"; +"public" = "공개"; +"power_level" = "권한 등급"; +"network_error_not_reachable" = "네트워크 연결 상태를 확인해주세요"; +"user_id_placeholder" = "예: @bob:homeserver"; +// Permissions +"camera_access_not_granted_for_call" = "영상 통화를 하려면 카메라에 접근해야 하지만 %@은(는) 사용할 권한이 없습니다"; +"microphone_access_not_granted_for_call" = "전화를 하려면 마이크에 접근해야 하지만 %@은(는) 사용할 권한이 없습니다"; +"local_contacts_access_not_granted" = "로컬 연락처에서 사용자를 검색하려면 연락처에 접근해야 하지만 %@은(는) 사용할 권한이 없습니다"; +"local_contacts_access_discovery_warning_title" = "사용자 검색"; +"local_contacts_access_discovery_warning" = "Matrix를 사용 중인 연락처 사람들을 찾기 위해 %@은(는) 주소록에 있는 이메일 주소와 전화번호를 선택한 Matrix ID 서버로 보낼 수 있습니다. 서버가 지원한다면, 개인 정보는 보내기 전에 해시됩니다 - 더 자세한 정보는 ID 서버의 개인 정보 정책을 확인해주세요."; +"notice_room_invite" = "%@님이 %@님을 초대했습니다"; +"notice_room_third_party_invite" = "%@님이 %@님에게 방에 참가하라는 초대를 보냈습니다"; +"notice_room_third_party_registered_invite" = "%@님이 %@님의 초대를 수락했습니다"; +"notice_room_join" = "%@님이 참가했습니다"; +"notice_room_leave" = "%@님이 떠났습니다"; +"notice_room_reject" = "%@님이 초대를 거절했습니다"; +"notice_room_kick" = "%@님이 %@님을 추방했습니다"; +"notice_room_unban" = "%@님이 %@님의 출입 금지를 풀었습니다"; +"notice_room_ban" = "%@님이 %@님을 출입 금지했습니다"; +"notice_room_withdraw" = "%@님이 %@님의 초대를 취소했습니다"; +"notice_room_reason" = ". 이유: %@"; +"notice_avatar_url_changed" = "%@님이 아바타를 바꿨습니다"; +"notice_display_name_set" = "%@님이 표시 이름을 %@(으)로 설정했습니다"; +"notice_display_name_changed_from" = "%@님이 표시 이름을 %@에서 %@(으)로 바꿨습니다"; +"notice_display_name_removed" = "%@님이 표시 이름을 제거했습니다"; +"notice_topic_changed" = "%@님이 주제를 다음으로 바꿨습니다: %@"; +"notice_room_name_changed" = "%@님이 방 이름을 다음으로 바꿨습니다: %@"; +"notice_placed_voice_call" = "%@님이 음성 통화를 걸었습니다"; +"notice_placed_video_call" = "%@님이 영상 통화를 걸었습니다"; +"notice_answered_video_call" = "%@님이 전화를 받았습니다"; +"notice_ended_video_call" = "%@님이 전화를 끊었습니다"; +"notice_conference_call_request" = "%@님이 VoIP 회의를 요청했습니다"; +"notice_conference_call_started" = "VoIP 회의가 시작했습니다"; +"notice_conference_call_finished" = "VoIP 회의가 끝났습니다"; +// button names +"ok" = "예"; +"send" = "보내기"; +"copy_button_name" = "복사"; +"resend" = "다시 보내기"; +"redact" = "감추기"; +"share" = "공유"; +"delete" = "삭제"; +// actions +"action_logout" = "로그아웃"; +"create_room" = "방 만들기"; +"login" = "로그인"; +"create_account" = "계정 만들기"; +"membership_invite" = "초대받음"; +"membership_leave" = "떠남"; +"membership_ban" = "출입 금지당함"; +"num_members_one" = "%@명의 사용자"; +"num_members_other" = "%@명의 사용자"; +"kick" = "추방"; +"ban" = "출입 금지"; +"unban" = "출입 금지 풀기"; +"message_unsaved_changes" = "저장하지 않은 변경 사항이 있습니다. 떠나게 되면 변경 사항은 버려집니다."; +// Login Screen +"login_error_already_logged_in" = "이미 로그인됨"; +"login_error_must_start_http" = "URL은 http[s]://로 시작해야 함"; +// room details dialog screen +"room_details_title" = "방 세부 사항"; +// contacts list screen +"invitation_message" = "저는 Matrix로 당신과 대화하고 싶습니다. 자세한 정보는 웹사이트 http://martix.org에 방문해주세요."; +// Settings screen +"settings_title_config" = "설정"; +"settings_title_notifications" = "알림"; +// Notification settings screen +"notification_settings_disable_all" = "모든 알림 끄기"; +"notification_settings_enable_notifications" = "알림 켜기"; +"notification_settings_enable_notifications_warning" = "모든 기기에 모든 알림이 현재부터 꺼집니다."; +"notification_settings_global_info" = "알림 설정은 사용자 계정에 저장되며 계정을 등록한 모든 클라이언트 간에 공유됩니다 (데스크톱 알림 포함)\n\n규칙은 순서대로 적용됩니다; 일치하는 것의 첫 번째 규칙은 메시지의 결과를 정의하는 것입니다.\n따라서: 발신자 별 알림보다 방 별 알림이, 방 별 알림보다 단어 별 알림이 더 중요합니다.\n동일한 종류의 여러 규칙이 있다면, 목록에서 일치하는 첫 번째 규칙이 우선됩니다."; +"notification_settings_per_word_notifications" = "단어 별 알림"; +"notification_settings_per_word_info" = "단어는 대소문자를 구분하지 않고, * 와이드카드 기호를 넣을 수 있습니다. 따라서:\nfoo라는 단어는 양끝에 단어가 이어지지 않은 경우 (예: 문장 부호, 공백 또는 문장의 시작과 끝)에 있는 단어를 맞춥니다.\nfoo*는 foo로 시작하는 모든 단어를 맞춥니다.\n*foo*는 foo라는 세 글자를 포함하는 모든 단어를 맞춥니다."; +"notification_settings_always_notify" = "항상 알림"; +"notification_settings_never_notify" = "절대 알리지 않기"; +"notification_settings_word_to_match" = "맞춰볼 단어"; +"notification_settings_highlight" = "강조"; +"notification_settings_custom_sound" = "맞춤 소리"; +"notification_settings_per_room_notifications" = "방 별 알림"; +"notification_settings_per_sender_notifications" = "발신자 별 알림"; +"notification_settings_sender_hint" = "@user:domain.com"; +"notification_settings_select_room" = "방 선택"; +"notification_settings_other_alerts" = "기타 경고"; +"notification_settings_contain_my_user_name" = "내 사용자 이름이 있는 메시지에 대해 소리로 알림"; +"notification_settings_contain_my_display_name" = "내 표시 이름이 있는 메시지에 대해 소리로 알림"; +"notification_settings_just_sent_to_me" = "나에게만 온 메시지에 대해 소리로 알림"; +"notification_settings_invite_to_a_new_room" = "내가 새 방에 초대받았을 때 알림"; +"notification_settings_people_join_leave_rooms" = "사람들이 참가하고 떠날 때 알림"; +"notification_settings_receive_a_call" = "전화를 받을 때 알림"; +"notification_settings_suppress_from_bots" = "봇의 알림 억제"; +"notification_settings_by_default" = "기본으로 되돌리기..."; +"notification_settings_notify_all_other" = "모든 다른 메시지/방의 알림"; +// gcm section +"settings_config_home_server" = "홈서버: %@"; +"settings_config_identity_server" = "ID 서버: %@"; +"settings_config_user_id" = "사용자 ID: %@"; +// call string +"call_waiting" = "대기 중..."; +"call_connecting" = "전화 연결 중..."; +"call_ended" = "전화 종료됨"; +"call_ring" = "전화 중..."; +"incoming_video_call" = "수신 영상 통화"; +"incoming_voice_call" = "수신 음성 통화"; +"call_invite_expired" = "전화 초대가 만료됨"; +// unrecognized SSL certificate +"ssl_trust" = "신뢰"; +"ssl_logout_account" = "로그아웃"; +"ssl_remain_offline" = "무시"; +"ssl_fingerprint_hash" = "핑거프린트 (%@):"; +"ssl_could_not_verify" = "원격 서버의 ID를 확인할 수 없습니다."; +"ssl_cert_not_trust" = "누군가가 악의적으로 트래픽을 가로채고 있거나, 휴대 전화가 원격 서버에서 제공한 인증을 신뢰하지 않습니다."; +"ssl_cert_new_account_expl" = "서버 관리자가 예상된다고 대답했다면, 아래 핑거프린트가 그들이 제공한 핑거프린트와 맞는지 확인하세요."; +"ssl_unexpected_existing_expl" = "휴대 전화를 인증했던 인증서가 다른 것으로 변경되었습니다. 이것은 매우 비정상적입니다. 이 새로운 인증서를 수락하지 않는 것을 권합니다."; +"ssl_expected_existing_expl" = "인증서가 신뢰했던 것에서 신뢰하지 않은 것으로 변경되었습니다. 서버가 인증서를 세로 작성했을 수 있습니자. 예상되는 핑거프린트를 위해 서버 관리자에게 연락하세요."; +"ssl_only_accept" = "위의 것과 일치한 핑거프린트를 서버 관리자가 게시해야 인증서를 수락할 수 있습니다."; +"notice_room_third_party_revoked_invite" = "%@님이 %@님에게 보낸 초대를 취소했습니다"; +"device_details_rename_prompt_title" = "기기 이름"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/lv.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/lv.lproj/MatrixKit.strings new file mode 100644 index 000000000..b7e38eef4 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/lv.lproj/MatrixKit.strings @@ -0,0 +1,119 @@ +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "Izveidot kontu:"; +"login_server_url_placeholder" = "URL adrese (piemēram, https://matrix.org)"; +"login_home_server_title" = "Mājas serveria URL:"; +"login_home_server_info" = "Tavs mājas serveris glabā visas sarunas un konta datus"; +"login_identity_server_title" = "Identifikācijas servera URL:"; +"view" = "Skatīt"; +"back" = "Atpakaļ"; +"continue" = "Turpināt"; +"leave" = "Atstāt"; +"invite" = "Uzaicināt"; +"retry" = "Atgriezties"; +"cancel" = "Atcelt"; +"save" = "Saglabāt"; +"login_password_placeholder" = "Parole"; +"login_display_name_placeholder" = "Displeja vārds (piem. Bob Obson)"; +"login_email_placeholder" = "Epasta adrese"; +"login_prompt_email_token" = "Lūdzu ievadi savu epasta pārbaudes kodu:"; +"login_error_title" = "Piekļuve neizdevās"; +"login_error_registration_is_not_supported" = "Reģistrācija pašlaik netiek atbalstīta"; +"login_error_forbidden" = "Nederīgs lietotājvārds/parole"; +"login_error_limit_exceeded" = "Pārāk daudz pieprasījumi tikuši izsūtīti"; +"login_error_user_in_use" = "Šis lietotājvārds jau tiek izmantots"; +"login_leave_fallback" = "Atcelt"; +"login_invalid_param" = "Nepareizs parametrs"; +"register_error_title" = "Reģistrācija neizdevās"; +"login_error_resource_limit_exceeded_title" = "Resursu Limits Pārsniegts"; +"yes" = "Jā"; +"close" = "Aizvērt"; +"sign_up" = "Pieteikties"; +"start_chat" = "Sākt čatu"; +"start_voice_call" = "Sākt Balss Zvanu"; +"start_video_call" = "Sākt Video Zvanu"; +"select_account" = "Izvēlies kontu"; +"invite_user" = "Uzaicini matrix Lietotāju"; +"select_all" = "Izvēlēties Visus"; +"cancel_upload" = "Atcelt Augšupielādi"; +"cancel_download" = "Atcelt Lejupielādi"; +"show_details" = "Rādīt Detaļas"; +"answer_call" = "Pacelt Zvanu"; +"reject_call" = "Atteikt Zvanu"; +"end_call" = "Beigt Zvanu"; +"ignore" = "Ignorēt"; +"unignore" = "At-Ignorēt"; +"notice_room_name_removed" = "%@ noņēma istabas nosaukumu"; +"notice_room_created" = "%@ izveidoja istabu"; +"notice_encrypted_message" = "Šifrēts ziņojums"; +"notice_image_attachment" = "attēla pielikums"; +"notice_audio_attachment" = "audio pielikums"; +"notice_video_attachment" = "video pielikums"; +"notice_location_attachment" = "atrašanās vietas pielikums"; +"notice_file_attachment" = "datnes pielikums"; +"notice_invalid_attachment" = "nederīgs pielikums"; +"notice_unsupported_attachment" = "Neatbalstīts pielikums: %@"; +"notice_error_unsupported_event" = "Neatbalstīts notikums"; +"notice_error_unexpected_event" = "Negaidīts notikums"; +"notice_error_unknown_event_type" = "Nezināms notikuma veids"; +"notice_sticker" = "uzlīme"; +"room_displayname_two_members" = "%@ un %@"; +"room_displayname_more_than_two_members" = "%@ un %@ citi"; +"notification_settings_room_rule_title" = "Istaba: '%@'"; +"device_details_identifier" = "ID\n"; +"device_details_last_seen" = "Pēdējoreiz redzēts\n"; +"device_details_rename_prompt_title" = "Sesijas Nosaukums"; +"device_details_delete_prompt_title" = "Autentifikācija"; +"room_event_encryption_info_event_user_id" = "Lietotāja ID\n"; +"room_event_encryption_info_event_identity_key" = "Curve25519 identitātes atslēga\n"; +"room_event_encryption_info_event_algorithm" = "Algoritms\n"; +"room_event_encryption_info_event_session_id" = "Sesijas ID\n"; +"room_event_encryption_info_event_decryption_error" = "Atšifrēšanas kļūda\n"; +"room_event_encryption_info_event_none" = "nav"; +"room_event_encryption_info_device_unknown" = "nezināma sesija\n"; +"room_event_encryption_info_device_name" = "Publiskais Nosaukums\n"; +"room_event_encryption_info_device_id" = "ID\n"; +"room_event_encryption_info_device_verification" = "Verifikācija\n"; +"room_event_encryption_info_device_verified" = "Verificēts"; +"room_event_encryption_info_device_not_verified" = "NAV verificēts"; +"notice_room_name_removed_for_dm" = "%@ noņēma nosaukumu"; + +// Events formatter +"notice_avatar_changed_too" = "(avatars arī tika nomainīts)"; +"resume_call" = "Atjaunot"; +"resend_message" = "Atkārtot ziņojuma sūtīšanu"; +"reset_to_default" = "Atietatīt uz noklusējumu"; +"capture_media" = "Uzņemt foto/video"; +"attach_media" = "Pievienot mediju no bibliotēkas"; +"mention" = "Pieminēt"; +"set_admin" = "Norādīt administratoru"; +"set_moderator" = "Norādīt moderatoru"; +"set_default_power_level" = "Nodzēst jaudas līmeni"; +"set_power_level" = "Iestatīt jaudas līmeni"; +"submit_code" = "Iesniegt kodu"; +"submit" = "Iesniegt"; +"dismiss" = "Noraidīt"; +"discard" = "Izmest"; +"abort" = "Pārtraukt"; + +// Action +"no" = "Nē"; +"login_error_resource_limit_exceeded_contact_button" = "Sazinieties ar administratoru"; +"login_error_resource_limit_exceeded_message_contact" = "\n\nLai turpinātu lietot šo pakalpojumu, lūdzu, sazinieties ar savu pakalpojuma administratoru."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Šis mājas serveris ir sasniedzis ikmēneša aktīvo lietotāju limitu."; +"login_error_resource_limit_exceeded_message_default" = "Šis mājas serveris ir sasniedzis vienu no savu resursu limitiem."; +"login_desktop_device" = "Dators"; +"login_tablet_device" = "Planšete"; +"login_mobile_device" = "Mobilā iekārta"; +"login_error_forgot_password_is_not_supported" = "“Aizmirsu paroli” patlaban nav atbalstīta"; +"login_use_fallback" = "Lietojiet atkāpšanās lappusi"; +"login_error_login_email_not_yet" = "E-pasta saite vel nav noklikšķināta"; +"login_error_not_json" = "Nesatur derīgu JSON"; +"login_error_bad_json" = "Bojāts JSON"; +"login_error_unknown_token" = "Netika atpazīta norādītā pieejas atslēga"; +"login_error_do_not_support_login_flows" = "Mēs patrez neatbalstām jebkādas pieteikšanās plūsmas, kas definētas no šī mājas servera"; +"login_error_no_login_flow" = "Mums neizdevās saņemt autentifikācijas informāciju no šī mājas servera."; +"login_email_info" = "E-pasta adreses norādīšana ļauj citiem lietotājiem viegli atras tevi Matix, kā arī nodrošinās tev iespēju nākotnē atjaunot savu paroli."; +"login_optional_field" = "neobligāts"; +"login_user_id_placeholder" = "Matrix ID (piem. @bob:matrix.org vai bobs)"; +"login_identity_server_info" = "Matrix nodrošina identifikācijas serverus, lai atšķirtu, kuri e-pasti, u.c., pieder kuram Matrix ID. Patlaban pastāv vienīgi https://matrix.org"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/nb-NO.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/nb-NO.lproj/MatrixKit.strings new file mode 100644 index 000000000..7f2347f95 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/nb-NO.lproj/MatrixKit.strings @@ -0,0 +1,544 @@ + + +"room_event_encryption_info_device_verification" = "Verifisering\n"; +"notice_display_name_set" = "%@ satte visningsnavnet sitt til %@"; +"room_no_power_to_create_conference_call" = "Du trenger tillatelse til å invitere for å starte en konferanse i dette rommet"; +"room_error_join_failed_empty_room" = "Det er for øyeblikket ikke mulig å bli med i et tomt rom igjen."; +"room_event_encryption_verify_message" = "For å verifisere at denne økten er til å stole på, vennligst kontakt eieren på andre måter (f.eks. personlig eller en telefonsamtale) og spør dem om nøkkelen de ser i brukerinnstillingene for denne økten samsvarer med nøkkelen nedenfor:\n\n\tØkt-navn: %@\n\tØkt-ID: %@\n\tØkt-nøkkel: %@\n\nHvis den stemmer overens, trykk på verifiserings-knappen nedenfor. Hvis den ikke gjør det, er det noen andre som overvåker denne økten, og du vil sannsynligvis trykke på svartelisteknappen i stedet.\n\nI fremtiden vil denne verifiseringsprosessen bli mer sofistikert."; +"room_event_encryption_info_block" = "Svarteliste"; +"device_details_delete_prompt_title" = "Autentisering"; +"notice_room_power_level_acting_requirement" = "Minimum tilgangsnivå en bruker må ha før utførelse av handling er:"; +"login_prompt_email_token" = "Vennligst skriv inn valideringstokenet ditt for e-post:"; +"login_server_url_placeholder" = "URL (f.eks. https://matrix.org)"; + +// Login Screen +"login_create_account" = "Opprett konto:"; +/* *********************** */ +/* iOS specific */ +/* *********************** */ + +"matrix" = "Matrix"; +"ssl_cert_not_trust" = "Dette kan bety at noen overvåker trafikken din, eller at telefonen ikke stoler på sertifikatet fra den eksterne serveren."; +"resend" = "Send på nytt"; +"login_identity_server_title" = "URL til identitetsserver:"; +"login_home_server_info" = "Hjemmeserveren lagrer alle samtalene og kontodataene dine"; +"login_home_server_title" = "Hjemmeserver-URL:"; +"login_identity_server_info" = "Matrix tilbyr identitetsservere for å spore hvilke e-postadresser etc. som tilhører hvilke Matrix-IDer. Bare https://matrix.org eksisterer for øyeblikket."; +"login_user_id_placeholder" = "Matrix-ID (f.eks. @Bob: matrix.org eller bob)"; +"login_email_info" = "Angi en e-postadresse slik at andre brukere lettere kan finne deg, og samtidig gi deg en måte å tilbakestille passordet ditt senere."; +"login_display_name_placeholder" = "Visningsnavn (f.eks. Bob Obson)"; +"login_optional_field" = "valgfri"; +"login_password_placeholder" = "Passord"; +"login_error_do_not_support_login_flows" = "Foreløpig støtter vi ikke påloggingsflytene som er definert for denne hjemmeserveren"; +"login_error_no_login_flow" = "Vi kunne ikke hente autentiseringsinformasjon fra denne hjemmeserveren"; +"login_error_title" = "Innlogging feilet"; +"login_email_placeholder" = "E-postadresse"; +"login_error_unknown_token" = "Angitt tilgangstoken ble ikke gjenkjent"; +"login_error_forbidden" = "Ugyldig brukernavn/passord"; +"login_error_registration_is_not_supported" = "Registrering støttes ikke for øyeblikket"; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Denne hjemmeserveren har nådd grensen for aktive månedlige brukere."; +"login_error_resource_limit_exceeded_message_default" = "Denne hjemmeserveren har overskredet en av ressursgrensene."; +"login_error_resource_limit_exceeded_title" = "Ressursgrensen er overskredet"; +"login_desktop_device" = "PC"; +"login_tablet_device" = "Nettbrett"; +"login_mobile_device" = "Mobil"; +"login_error_forgot_password_is_not_supported" = "Glemt passord støttes for øyeblikket ikke"; +"register_error_title" = "Registrering feilet"; +"login_invalid_param" = "Ugyldig parameter"; +"login_leave_fallback" = "Avbryt"; +"login_use_fallback" = "Bruk reserveside"; +"login_error_login_email_not_yet" = "E-postlinken som ikke er klikket på ennå"; +"login_error_user_in_use" = "Dette brukernavnet er allerede brukt"; +"login_error_limit_exceeded" = "Det er sendt for mange forespørsler"; +"login_error_not_json" = "Inneholdt ikke gyldig JSON"; +"login_error_bad_json" = "Feilformatert JSON"; +"set_power_level" = "Bestem tilgangsnivå"; +"submit_code" = "Send inn kode"; +"submit" = "Send inn"; +"sign_up" = "Meld deg på"; +"retry" = "Prøv på nytt"; +"dismiss" = "Ignorer"; +"discard" = "Forkast"; +"continue" = "Fortsett"; +"close" = "Lukk"; +"back" = "Tilbake"; +"abort" = "Avbryt"; +"yes" = "Ja"; + +// Action +"no" = "Nei"; +"login_error_resource_limit_exceeded_contact_button" = "Kontakt administrator"; +"login_error_resource_limit_exceeded_message_contact" = "\n\nKontakt tjenesteadministratoren din for å fortsette å bruke denne tjenesten."; +"set_default_power_level" = "Tilbakestill tilgangsnivå"; +"start_chat" = "Start samtale"; +"set_admin" = "Velg admin"; +"set_moderator" = "Velg moderator"; +"invite_user" = "Inviter bruker"; +"capture_media" = "Ta bilde/video"; +"attach_media" = "Legg ved media fra biblioteket"; +"select_account" = "Velg en konto"; +"mention" = "Nevn"; +"start_video_call" = "Start videoanrop"; +"start_voice_call" = "Start taleanrop"; +"answer_call" = "Svar på anrop"; +"show_details" = "Vis detaljer"; +"cancel_download" = "Avbryt nedlasting"; +"cancel_upload" = "Avbryt opplasting"; +"select_all" = "Velg alle"; +"resend_message" = "Send meldingen på nytt"; +"reset_to_default" = "Tilbakestill til standard"; +"reject_call" = "Avvis anrop"; +"end_call" = "Avslutt samtale"; +// Old +"notice_room_join_rule" = "Bli-med regelen er: %@"; +"notice_room_created_for_dm" = "%@ ble med."; +"notice_room_created" = "%@ opprettet og konfigurerte rommet."; +"notice_profile_change_redacted" = "%@ oppdaterte profilen sin %@"; +"notice_event_redacted_reason" = " [årsak: %@]"; +"notice_event_redacted_by" = " av %@"; +"notice_event_redacted" = ""; +"notice_room_topic_removed" = "%@ fjernet emnet"; +"notice_room_name_removed_for_dm" = "%@ fjernet navnet"; +"notice_room_name_removed" = "%@ fjernet romnavnet"; + +// Events formatter +"notice_avatar_changed_too" = "(avatar ble også endret)"; +"unignore" = "Opphev ignorering"; +"ignore" = "Ignorer"; +"resume_call" = "Gjenoppta"; +"notice_room_power_level_intro" = "Medlemmenes tilgangsnivå i rommet er:"; +"notice_room_join_rule_public_by_you_for_dm" = "Du gjorde dette offentlig."; +"notice_room_join_rule_public_by_you" = "Du gjorde rommet offentlig."; +"notice_room_join_rule_public_for_dm" = "%@ gjorde dette offentlig."; +"notice_room_join_rule_public" = "%@ gjorde rommet offentlig."; +"notice_room_join_rule_invite_by_you_for_dm" = "Du endret til kun inviterte."; +"notice_room_join_rule_invite_by_you" = "Du endret rommet til kun inviterte."; +"notice_room_join_rule_invite_for_dm" = "%@ endret til kun inviterte."; +// New +"notice_room_join_rule_invite" = "%@ endret rommet til kun inviterte."; +"notice_room_power_level_intro_for_dm" = "Medlemmenes tilgangsnivå er:"; +"notice_room_power_level_event_requirement" = "Minimum tilgangsnivå relatert til events er:"; +"notice_unsupported_attachment" = "Ikke støttet vedlegg: %@"; +"notice_invalid_attachment" = "ugyldig vedlegg"; +"notice_file_attachment" = "filvedlegg"; +"notice_location_attachment" = "lokasjonsvedlegg"; +"notice_video_attachment" = "videovedlegg"; +"notice_audio_attachment" = "lydvedlegg"; +"notice_image_attachment" = "bildevedlegg"; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ slo på ende-til-ende-kryptering (ukjent algoritme %2$@)."; +"notice_encryption_enabled_ok" = "%@ slo på ende-til-ende-kryptering."; +"notice_encrypted_message" = "Kryptert melding"; +"notice_room_related_groups" = "Gruppene som er tilknyttet dette rommet er: %@"; +"notice_room_aliases_for_dm" = "Aliasene er: %@"; +"notice_room_aliases" = "Rom-aliasene er: %@"; +"room_event_encryption_info_event" = "Hendelseinformasjon\n"; + +// Encryption information +"room_event_encryption_info_title" = "Ende-til-ende-krypteringsinformasjon\n\n"; +"device_details_delete_prompt_message" = "Denne operasjonen krever ekstra godkjenning.\nFor å fortsette, vennligst skriv inn passordet ditt."; +"device_details_rename_prompt_message" = "En økts offentlige navn er synlig for folk du kommuniserer med"; +"device_details_rename_prompt_title" = "Øktnavn"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_last_seen" = "Sist sett\n"; +"device_details_identifier" = "ID\n"; +"device_details_name" = "Offentlig navn\n"; + +// Devices +"device_details_title" = "Øktinformasjon\n"; +"notification_settings_room_rule_title" = "Rom: '%@'"; +"settings_enter_validation_token_for" = "Angi valideringstoken for %@:"; +"settings_enable_push_notifications" = "Aktiver push-varsler"; +"settings_enable_inapp_notifications" = "Aktiver varsler i appen"; + +// Settings +"settings" = "Innstillinger"; +"room_displayname_more_than_two_members" = "%@ og %@ andre"; +"room_displayname_two_members" = "%@ og %@"; + +// room display name +"room_displayname_empty_room" = "Tomt rom"; +"notice_in_reply_to" = "Som svar på"; +"notice_sticker" = "klistremerke"; +"notice_crypto_error_unknown_inbound_session_id" = "Avsenderøkten har ikke sendt oss nøklene til denne meldingen."; +"notice_crypto_unable_to_decrypt" = "** Kan ikke dekryptere: %@ **"; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ gjorde fremtidige meldinger synlige for alle, fra de ble med."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ gjorde fremtidige meldinger synlige for alle, fra de blir invitert."; +"notice_room_history_visible_to_anyone" = "%@ gjorde fremtidig romhistorie synlig for alle."; +"notice_error_unknown_event_type" = "Ukjent hendelsestype"; +"notice_error_unexpected_event" = "Uventet hendelse"; +"notice_error_unsupported_event" = "Ikke støttet hendelse"; +"notice_feedback" = "Tilbakemeldingshendelse (id: %@): %@"; +"notice_room_history_visible_to_members_from_joined_point" = "%@ synliggjorde fremtidig romhistorie for alle medlemmer i rommet, fra det tidspunktet de ble med."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ synliggjorde fremtidig romhistorie for alle medlemmer i rommet, fra det punktet de er invitert."; +"notice_room_history_visible_to_members_for_dm" = "%@ gjorde fremtidige meldinger synlige for alle medlemmer i rommet."; +"notice_room_history_visible_to_members" = "%@ synliggjorde fremtidig romhistorie for alle medlemmer i rommet."; +"notice_redaction" = "%@ holdt igjen en hendelse (id: %@)"; +"room_event_encryption_info_event_fingerprint_key" = "Benyttet Ed25519 fingeravtrykknøkkel\n"; +"room_event_encryption_info_event_identity_key" = "Curve25519 identitetsnøkkel\n"; +"room_event_encryption_info_event_user_id" = "Bruker-ID\n"; +"room_event_encryption_info_event_session_id" = "Økt-ID\n"; +"room_event_encryption_info_event_algorithm" = "Algoritme\n"; +"account_email_validation_message" = "Sjekk e-posten din og klikk på linken den inneholder. Når dette er gjort, klikker du på fortsett."; +"account_linked_emails" = "Linkede e-poster"; +"account_link_email" = "Link e-post"; + +// Account +"account_save_changes" = "Lagre endringer"; +"room_event_encryption_verify_ok" = "Verifiser"; +"room_event_encryption_verify_title" = "Verifiser økt\n\n"; +"room_event_encryption_info_unblock" = "Fjern svartelisting"; +"room_event_encryption_info_unverify" = "Fjern verifisering"; +"room_event_encryption_info_verify" = "Verifiser..."; +"room_event_encryption_info_device_blocked" = "Svartelistet"; +"room_event_encryption_info_device_not_verified" = "IKKE verifisert"; +"room_event_encryption_info_device_verified" = "Verifisert"; +"room_event_encryption_info_device_fingerprint" = "Ed25519 fingeravtrykk\n"; +"room_event_encryption_info_device_id" = "ID\n"; +"room_event_encryption_info_device_name" = "Offentlig navn\n"; +"room_event_encryption_info_device_unknown" = "ukjent økt\n"; +"room_event_encryption_info_device" = "\nInformasjon om avsenderøkt\n"; +"room_event_encryption_info_event_none" = "ingen"; +"room_event_encryption_info_event_unencrypted" = "ukryptert"; +"room_event_encryption_info_event_decryption_error" = "Dekrypteringsfeil\n"; +"account_email_validation_title" = "Venter på verifisering"; +"room_creation_alias_placeholder" = "(f.eks. #foo:example.org)"; +"room_creation_alias_title" = "Romalias:"; +"room_creation_name_placeholder" = "(f.eks. lunsjgruppe)"; + +// Room creation +"room_creation_name_title" = "Romnavn:"; +"account_error_push_not_allowed" = "Varsler ikke tillatt"; +"account_error_msisdn_wrong_description" = "Dette ser ikke ut til å være et gyldig telefonnummer"; +"account_error_msisdn_wrong_title" = "Ugyldig telefonnummer"; +"account_error_email_wrong_description" = "Dette ser ikke ut til å være en gyldig e-postadresse"; +"account_error_email_wrong_title" = "Ugyldig e-postadresse"; +"account_error_matrix_session_is_not_opened" = "Matrix-økt er ikke åpnet"; +"account_error_picture_change_failed" = "Endring av bildet feilet"; +"account_error_display_name_change_failed" = "Endring av visningsnavn feilet"; +"account_msisdn_validation_error" = "Kan ikke bekrefte telefonnummeret."; +"account_msisdn_validation_message" = "Vi har sendt en SMS med en aktiveringskode. Vennligst skriv inn denne koden nedenfor."; +"account_email_validation_error" = "Kan ikke bekrefte e-postadressen. Sjekk e-posten din og klikk på lenken den inneholder. Når dette er gjort, klikker du på fortsett"; +"account_msisdn_validation_title" = "Venter på verifisering"; +"room_error_join_failed_title" = "Deltakelse i rommet feilet"; + +// Room +"room_please_select" = "Vennligst velg et rom"; +"room_creation_participants_placeholder" = "(f.eks. @bob:hjemmeserver1; @john:hjemmeserver2 ...)"; +"room_creation_participants_title" = "Deltakere:"; +"room_creation_alias_placeholder_with_homeserver" = "(f.eks. #foo%@)"; +"room_member_power_level_prompt" = "Du vil ikke kunne angre denne endringen ettersom du gir brukeren samme tilgangsnivå som deg selv.\nEr du sikker?"; + +// Room members +"room_member_ignore_prompt" = "Er du sikker på at du vil skjule alle meldinger fra denne brukeren?"; +"message_reply_to_message_to_reply_to_prefix" = "Som svar på"; +"message_reply_to_sender_sent_a_file" = "sendte en fil."; +"message_reply_to_sender_sent_an_audio_file" = "sendte en lydfil."; +"message_reply_to_sender_sent_a_video" = "sendte en video."; + +// Reply to message +"message_reply_to_sender_sent_an_image" = "sendte et bilde."; +"room_no_conference_call_in_encrypted_rooms" = "Konferansesamtaler støttes ikke i krypterte rom"; +"room_left_for_dm" = "Du forlot"; +"room_left" = "Du forlot rommet"; +"room_error_timeline_event_not_found" = "Applikasjonen prøvde å laste inn et bestemt punkt i tidslinjen til dette rommet, men kunne ikke finne det"; +"room_error_timeline_event_not_found_title" = "Kunne ikke laste tidslinjeposisjonen"; +"room_error_cannot_load_timeline" = "Kunne ikke laste tidslinjen"; +"room_error_topic_edition_not_authorized" = "Du har ikke autorisasjon til å redigere dette romemnet"; +"room_error_name_edition_not_authorized" = "Du har ikke autorisasjon til å redigere dette romnavnet"; + +// Groups +"group_invite_section" = "Invitasjoner"; +"contact_local_contacts" = "Lokale kontakter"; + +// Contacts +"contact_mx_users" = "Matrix-brukere"; +"attachment_e2e_keys_import" = "Importer..."; +"attachment_e2e_keys_file_prompt" = "Denne filen inneholder krypteringsnøkler eksportert fra en Matrix-klient.\nVil du se filinnholdet eller importere nøklene den inneholder?"; +"attachment_multiselection_original" = "Faktisk størrelse"; +"attachment_multiselection_size_prompt" = "Vil du sende bilder som:"; +"attachment_cancel_upload" = "Avbryte opplastingen?"; +"attachment_cancel_download" = "Avbryte nedlastingen?"; +"attachment_large" = "Stor: %@"; +"attachment_medium" = "Medium: %@"; +"attachment_small" = "Liten: %@"; +"attachment_original" = "Faktisk størrelse: %@"; + +// Attachment +"attachment_size_prompt" = "Vil du sende som:"; +"search_searching" = "Søk pågår..."; + +// Search +"search_no_results" = "Ingen resultater"; +"group_section" = "Grupper"; +"format_time_h" = "t"; +"format_time_m" = "m"; + +// Time +"format_time_s" = "s"; +"e2e_import_prompt" = "Denne prosessen lar deg importere krypteringsnøkler som du tidligere hadde eksportert fra en annen app. Du vil da kunne dekryptere alle meldinger som den andre klienten kan dekryptere.\nEksportfilen er beskyttet med en passordfrase. Du bør angi passordet her for å dekryptere filen."; + +// E2E import +"e2e_import_room_keys" = "Importer romnøkler"; +"format_time_d" = "d"; +"e2e_import" = "Importer"; +"e2e_export_prompt" = "Denne prosessen lar deg eksportere nøklene for meldinger du har mottatt i krypterte rom til en lokal fil. Du vil da kunne importere filen til en annen app i fremtiden, slik at den også kan dekryptere disse meldingene.\nDen eksporterte filen lar alle som kan lese den dekryptere alle krypterte meldinger du kan se, så du bør passe på å lagre den sikkert."; + +// E2E export +"e2e_export_room_keys" = "Eksporter romnøkler"; +"e2e_passphrase_enter" = "Skriv inn passordfrase"; +"e2e_export" = "Eksporter"; +"e2e_passphrase_empty" = "Passordfrase kan ikke være tom"; +"e2e_passphrase_confirm" = "Bekreft passordfrase"; +"power_level" = "Tilgangsnivå"; +"public" = "Offentlig"; +"private" = "Privat"; +"default" = "standard"; +"not_supported_yet" = "Støttes ikke ennå"; +"error_common_message" = "En feil oppstod. Prøv igjen senere."; +"error" = "Feil"; +"unsent" = "Ikke sendt"; +"offline" = "offline"; + +// Others +"user_id_title" = "Bruker-ID:"; +"e2e_passphrase_create" = "Opprett passordfrase"; +"e2e_passphrase_not_match" = "Passordfrase må stemme overens"; +"user_id_placeholder" = "eks: @bob:hjemmeserver"; +"network_error_not_reachable" = "Vennligst kontroller nettverkstilkoblingen"; +"local_contacts_access_discovery_warning" = "For å finne kontakter som allerede bruker løsningen, kan %@ sende e-postadresser og telefonnumre i adresseboken til den valgte identitetsserveren. Der det støttes, blir personlige data indeksert før sending - vennligst sjekk identitetsserverens personvernregler for mer informasjon."; +"local_contacts_access_discovery_warning_title" = "Finne brukere"; +"local_contacts_access_not_granted" = "Å finne brukere fra lokale kontakter krever tilgang til dine kontakter, men %@ har ikke tillatelse til å bruke dem"; +"microphone_access_not_granted_for_call" = "Samtaler krever tilgang til mikrofonen, men %@ har ikke tillatelse til å bruke den"; + +// Permissions +"camera_access_not_granted_for_call" = "Videosamtaler krever tilgang til kameraet, men %@ har ikke tillatelse til å bruke det"; +"ssl_homeserver_url" = "Hjemmeserver-URL:% @"; + +/* -*- + Automatic localization for en + + The following key/value pairs were extracted from the android i18n file: + /matrix-sdk/src/main/res/values/strings.xml. +*/ + +"notice_room_invite" = "%@ inviterte %@"; +"language_picker_default_language" = "Standard (%@)"; + +// Language picker +"language_picker_title" = "Velg språk"; + +// Country picker +"country_picker_title" = "Velg et land"; +"notice_room_kick" = "%@ utviste %@"; +"notice_room_reject" = "%@ avviste invitasjonen"; +"notice_room_leave" = "%@ forlot"; +"notice_room_join" = "%@ ble med"; +"notice_room_third_party_registered_invite" = "%@ godtok invitasjonen til %@"; +"notice_room_third_party_invite_for_dm" = "%@ inviterte %@"; +"notice_room_third_party_invite" = "%@ sendte en invitasjon til %@ om å bli med i rommet"; +"notice_room_third_party_revoked_invite_for_dm" = "%@ tilbakekalte invitasjonen til %@ om å bli med i rommet"; +"notice_room_third_party_revoked_invite" = "%@ tilbakekalte invitasjonen til %@ om å bli med i rommet"; +"notice_answered_video_call" = "%@ svarte på anropet"; +"notice_placed_video_call" = "%@ startet et videoanrop"; +"notice_placed_voice_call" = "%@ startet et taleanrop"; +"notice_room_name_changed_for_dm" = "%@ endret navnet til %@."; +"notice_room_name_changed" = "%@ endret romnavnet til %@."; +"notice_topic_changed" = "%@ endret emnet til \"%@\"."; +"notice_display_name_removed" = "%@ fjernet visningsnavnet"; +"notice_display_name_changed_from" = "%@ endret visningsnavnet fra %@ til %@"; +"notice_avatar_url_changed" = "%@ byttet avatar"; +"notice_room_reason" = ". Grunnen til: %@"; +"notice_room_withdraw" = "%@ trakk tilbake invitasjonen til %@"; +"notice_room_ban" = "%@ utestengte %@"; +"notice_room_unban" = "%@ omgjorde utestenging %@"; +"notice_declined_video_call" = "%@ avviste anropet"; +"notice_ended_video_call" = "%@ avsluttet samtalen"; +"notice_room_third_party_invite_by_you" = "Du sendte en invitasjon til %@ om å bli med i rommet"; +"notice_room_invite_you" = "%@ inviterte deg"; + +// Notice Events with "You" +"notice_room_invite_by_you" = "Du inviterte %@"; +"notice_conference_call_finished" = "VoIP-konferansen avsluttet"; +"notice_conference_call_started" = "VoIP-konferansen startet"; +"notice_conference_call_request" = "%@ ba om en VoIP-konferanse"; +"notice_room_kick_by_you" = "Du utviste %@"; +"notice_room_reject_by_you" = "Du avviste invitasjonen"; +"notice_room_leave_by_you" = "Du forlot"; +"notice_room_join_by_you" = "Du ble med"; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "Du tilbakekalte invitasjonen til %@"; +"notice_room_third_party_revoked_invite_by_you" = "Du tilbakekalte invitasjonen til %@ om å bli med i rommet"; +"notice_room_third_party_registered_invite_by_you" = "Du godtok invitasjonen til %@"; +"notice_room_third_party_invite_by_you_for_dm" = "Du inviterte %@"; +"notice_room_unban_by_you" = "Du omgjorde utestenging av %@"; +"notice_room_ban_by_you" = "Du utestengte %@"; +"notice_answered_video_call_by_you" = "Du svarte på anropet"; +"notice_placed_video_call_by_you" = "Du startet et videoanrop"; +"notice_placed_voice_call_by_you" = "Du startet et taleanrop"; +"notice_room_name_changed_by_you_for_dm" = "Du endret navnet til %@."; +"notice_room_name_changed_by_you" = "Du endret romnavnet til %@."; +"notice_topic_changed_by_you" = "Du endret emnet til \"%@\"."; +"notice_display_name_removed_by_you" = "Du fjernet visningsnavnet ditt"; +"notice_display_name_changed_from_by_you" = "Du endret visningsnavnet ditt fra %@ til %@"; +"notice_display_name_set_by_you" = "Du satte visningsnavnet ditt til %@"; +"notice_room_withdraw_by_you" = "Du trakk tilbake invitasjonen til %@"; +"notice_avatar_url_changed_by_you" = "Du byttet avatar"; +"notice_declined_video_call_by_you" = "Du avviste anropet"; +"notice_ended_video_call_by_you" = "Du avsluttet samtalen"; +"login" = "Innlogging"; +"create_room" = "Opprett rom"; + +// actions +"action_logout" = "Logg ut"; +"view" = "Visning"; +"delete" = "Slett"; +"share" = "Del"; +"redact" = "Fjern"; +"copy_button_name" = "Kopier"; +"send" = "Send"; +"leave" = "Forlat"; +"save" = "Lagre"; +"cancel" = "Avbryt"; + +// Room Screen + +// general errors + +// Home Screen + +// Last seen time + +// call events + +/* -*- + Automatic localization for en + + The following key/value pairs were extracted from the android i18n file: + /console/src/main/res/values/strings.xml. +*/ + + +// titles + +// button names +"ok" = "OK"; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "Du gjorde fremtidige meldinger synlige for alle, fra de ble med."; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "Du gjorde fremtidig romhistorie synlig for alle medlemmer i rommet, fra det tidspunktet de ble med."; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "Du gjorde fremtidige meldinger synlige for alle, fra de blir invitert."; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "Du gjorde fremtidig romhistorie synlig for alle medlemmer i rommet, fra det tidspunktet de er invitert."; +"notice_room_history_visible_to_members_by_you_for_dm" = "Du gjorde fremtidige meldinger synlige for alle medlemmer i rommet."; +"notice_room_history_visible_to_members_by_you" = "Du gjorde fremtidig romhistorie synlig for alle medlemmer i rommet."; +"notice_room_history_visible_to_anyone_by_you" = "Du gjorde fremtidig romhistorie synlig for alle."; +"notice_redaction_by_you" = "Du fjernet en hendelse (id: %@)"; +"notice_encryption_enabled_unknown_algorithm_by_you" = "Du slo på ende-til-ende-kryptering (ukjent algoritme %@)."; +"notice_encryption_enabled_ok_by_you" = "Du slo på ende-til-ende-kryptering."; +"notice_room_created_by_you_for_dm" = "Du ble med."; +"notice_room_created_by_you" = "Du opprettet og konfigurerte rommet."; +"notice_profile_change_redacted_by_you" = "Du oppdaterte profilen din %@"; +"notice_event_redacted_by_you" = " av deg"; +"notice_room_topic_removed_by_you" = "Du fjernet emnet"; +"notice_room_name_removed_by_you_for_dm" = "Du fjernet navnet"; +"notice_room_name_removed_by_you" = "Du fjernet romnavnet"; +"notice_conference_call_request_by_you" = "Du ba om en VoIP-konferanse"; +"kick" = "Utvis"; +"invite" = "Inviter"; +"num_members_other" = "%@ brukere"; +"num_members_one" = "%@ bruker"; +"membership_ban" = "Utestengt"; +"membership_leave" = "Forlot"; +"membership_invite" = "Invitert"; +"create_account" = "Opprett konto"; +"notification_settings_global_info" = "Varslingsinnstillinger lagres i brukerkontoen din og deles mellom alle klienter som støtter dem (inkludert skrivebordsvarsler).\n\nRegler brukes i rekkefølge; den første regelen som samsvarer definerer resultatet for meldingen.\nSå: Varsler per ord er viktigere enn varsler per rom som er viktigere enn varsler per avsender.\nVed flere regler av samme type benyttes den første i listen som samsvarer."; +"notification_settings_enable_notifications_warning" = "Alle varsler er for øyeblikket deaktivert for alle enheter."; +"notification_settings_enable_notifications" = "Aktiver varsler"; + +// Notification settings screen +"notification_settings_disable_all" = "Deaktiver alle varsler"; +"settings_title_notifications" = "Varsler"; + +// Settings screen +"settings_title_config" = "Konfigurasjon"; + +// contacts list screen +"invitation_message" = "Jeg vil gjerne chatte med deg med Matrix. Besøk nettstedet https://matrix.org for å få mer informasjon."; + +// members list Screen + +// accounts list Screen + +// image size selection + +// invitation members list Screen + +// room creation dialog Screen + +// room info dialog Screen + +// room details dialog screen +"room_details_title" = "Romdetaljer"; +"login_error_must_start_http" = "URL må starte med http[s]://"; + +// Login Screen +"login_error_already_logged_in" = "Allerede logget inn"; +"message_unsaved_changes" = "Endringer er ikke lagret. Hvis du avslutter, forkastes de."; +"unban" = "Omgjør utestenging"; +"ban" = "Utesteng"; +"notification_settings_receive_a_call" = "Varsle meg når jeg mottar et anrop"; +"notification_settings_people_join_leave_rooms" = "Varsle meg når folk blir med eller forlater rom"; +"notification_settings_invite_to_a_new_room" = "Varsle meg når jeg blir invitert til et nytt rom"; +"notification_settings_just_sent_to_me" = "Varsle meg med lyd om meldinger sendt bare til meg"; +"notification_settings_contain_my_display_name" = "Varsle meg med lyd om meldinger som inneholder visningsnavnet mitt"; +"notification_settings_contain_my_user_name" = "Varsle meg med lyd om meldinger som inneholder brukernavnet mitt"; +"notification_settings_other_alerts" = "Andre varsler"; +"notification_settings_select_room" = "Velg et rom"; +"notification_settings_sender_hint" = "@bruker:domene.com"; +"notification_settings_per_sender_notifications" = "Varsler per avsender"; +"notification_settings_per_room_notifications" = "Varsler per rom"; +"notification_settings_custom_sound" = "Egendefinert lyd"; +"notification_settings_highlight" = "Fremhev"; +"notification_settings_word_to_match" = "ord som skal samsvare"; +"notification_settings_never_notify" = "Aldri varsle"; +"notification_settings_always_notify" = "Alltid varsle"; +"notification_settings_per_word_info" = "Ord samsvarer uten å ta hensyn til store eller små bokstaver, og kan inneholde et * jokertegn. Så:\nfoo samsvarer med strengen foo omgitt av ordavgrensere (f.eks. tegnsetting og mellomrom eller start/slutt på linjen).\nfoo* samsvarer med et slikt ord som begynner foo.\n*foo* samsvarer med et hvilket som helst ord som inkluderer de tre bokstavene foo."; +"notification_settings_per_word_notifications" = "Varsler per ord"; +"notification_settings_suppress_from_bots" = "Blokker meldinger fra roboter"; +"notification_settings_notify_all_other" = "Varsle for alle andre meldinger/rom"; +"notification_settings_by_default" = "Som standard ..."; +"incoming_video_call" = "Innkommende videoanrop"; +"call_ended" = "Samtale avsluttet"; +"call_ringing" = "Ringer…"; + +// Settings keys + +// call string +"call_connecting" = "Kobler til…"; +"settings_config_user_id" = "Bruker-ID: %@"; +"settings_config_identity_server" = "Identitetsserver: %@"; + +// gcm section +"settings_config_home_server" = "Hjemmeserver: %@"; +"incoming_voice_call" = "Innkommende taleanrop"; +"call_invite_expired" = "Anropsinvitasjon utløpt"; + +// unrecognized SSL certificate +"ssl_trust" = "Tillit"; +"call_transfer_to_user" = "Overfør til %@"; +"call_consulting_with_user" = "Rådfører seg med %@"; +"call_video_with_user" = "Videosamtale med %@"; +"call_voice_with_user" = "Taleanrop med %@"; +"call_more_actions_dialpad" = "Tastaturet"; +"call_more_actions_transfer" = "Overfør"; +"call_more_actions_audio_use_device" = "Bruk enhetslyd"; +"call_more_actions_audio_use_headset" = "Bruk hodetelefonlyd"; +"call_more_actions_change_audio_device" = "Bytt lydenhet"; +"call_more_actions_unhold" = "Gjenoppta"; +"call_more_actions_hold" = "Hold"; +"call_holded" = "Du holdt samtalen"; +"call_remote_holded" = "%@ satte samtalen på vent"; +"ssl_unexpected_existing_expl" = "Sertifikatet har endret seg fra et som telefonen din klarerte. Dette er VELDIG UVANLIG. Det anbefales derfor at du IKKE godtar dette nye sertifikatet."; +"ssl_cert_new_account_expl" = "Hvis serveradministratoren har sagt at dette forventes, må du forsikre deg om at fingeravtrykket nedenfor samsvarer med fingeravtrykket du har fått."; +"ssl_could_not_verify" = "Kunne ikke bekrefte identiteten til den eksterne serveren."; +"ssl_fingerprint_hash" = "Fingeravtrykk (%@):"; +"ssl_remain_offline" = "Ignorer"; +"ssl_logout_account" = "Logg ut"; +"ssl_expected_existing_expl" = "Sertifikatet er endret fra et tidligere klarert til et som ikke er klarert. Serveren kan ha fornyet sertifikatet. Kontakt serveradministratoren for forventet fingeravtrykk."; +"ssl_only_accept" = "Godta KUN sertifikatet hvis serveradministratoren har publisert et fingeravtrykk som samsvarer med det over."; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/nl.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/nl.lproj/MatrixKit.strings new file mode 100644 index 000000000..6dfa3be48 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/nl.lproj/MatrixKit.strings @@ -0,0 +1,534 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +/* *********************** */ +/* iOS specific */ +/* *********************** */ + +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "Account aanmaken:"; +"login_server_url_placeholder" = "URL (bv. https://matrix.org)"; +"login_home_server_title" = "Thuisserver-URL:"; +"login_home_server_info" = "Uw thuisserver slaat al uw gespreks- en accountgegevens op"; +"login_identity_server_title" = "Identiteitsserver-URL:"; +"login_identity_server_info" = "Matrix verstrekt identiteitsservers om te achterhalen welke e-mailadressen enz. bij welke Matrix-ID’s horen. Tot nu toe bestaat alleen https://matrix.org."; +"login_user_id_placeholder" = "Matrix-ID (bv. @jan:matrix.org of jan)"; +"login_password_placeholder" = "Wachtwoord"; +"login_optional_field" = "optioneel"; +"login_display_name_placeholder" = "Weergavenaam (bv. Jan Janssens)"; +"login_email_info" = "Door een e-mailadres in te voeren kunnen andere gebruikers u eenvoudiger op Matrix vinden, verder geeft het u een manier om uw wachtwoord in de toekomst te wijzigen."; +"login_email_placeholder" = "E-mailadres"; +"login_prompt_email_token" = "Voer uw e-mailadres-validatiebewijs in:"; +"login_error_title" = "Aanmelden Mislukt"; +"login_error_no_login_flow" = "Ophalen van authenticatie-informatie van deze thuisserver is mislukt"; +"login_error_do_not_support_login_flows" = "Momenteel bieden we geen ondersteuning voor sommige of alle aanmeldingsmethoden van deze thuisserver"; +"login_error_registration_is_not_supported" = "Registratie wordt momenteel niet ondersteund"; +"login_error_forbidden" = "Ongeldige gebruikersnaam/wachtwoord"; +"login_error_unknown_token" = "Het gespecificeerde toegangsbewijs is niet herkend"; +"login_error_bad_json" = "Ongeldige JSON"; +"login_error_not_json" = "Bevat geen geldige JSON"; +"login_error_limit_exceeded" = "Er zijn te veel verzoeken verzonden"; +"login_error_user_in_use" = "Deze gebruikersnaam is al in gebruik"; +"login_error_login_email_not_yet" = "De koppeling in de e-mail is nog niet geopend"; +"login_use_fallback" = "Terugvalpagina gebruiken"; +"login_leave_fallback" = "Annuleren"; +"login_invalid_param" = "Ongeldige parameter"; +"register_error_title" = "Registratie Mislukt"; +"login_error_forgot_password_is_not_supported" = "Wachtwoord vergeten wordt momenteel nog niet ondersteund"; +// Action +"no" = "Nee"; +"yes" = "Ja"; +"abort" = "Afbreken"; +"back" = "Terug"; +"close" = "Sluiten"; +"continue" = "Verdergaan"; +"discard" = "Verwerpen"; +"dismiss" = "Sluiten"; +"retry" = "Opnieuw proberen"; +"sign_up" = "Aanmelden"; +"submit" = "Versturen"; +"submit_code" = "Code versturen"; +"set_power_level" = "Machtsniveau instellen"; +"set_default_power_level" = "Machtsniveau opnieuw instellen"; +"set_moderator" = "Tot moderator benoemen"; +"set_admin" = "Tot beheerder benoemen"; +"start_chat" = "Gesprek beginnen"; +"start_voice_call" = "Spraakoproep beginnen"; +"start_video_call" = "Video-oproep beginnen"; +"mention" = "Vermelden"; +"select_account" = "Selecteer een account"; +"attach_media" = "Media van de bibliotheek bijvoegen"; +"capture_media" = "Foto/video maken"; +"invite_user" = "Matrix-gebruiker uitnodigen"; +"reset_to_default" = "Standaardwaarden herstellen"; +"resend_message" = "Bericht opnieuw versturen"; +"select_all" = "Alles selecteren"; +"cancel_upload" = "Upload annuleren"; +"cancel_download" = "Download annuleren"; +"show_details" = "Details weergeven"; +"answer_call" = "Oproep beantwoorden"; +"reject_call" = "Oproep afwijzen"; +"end_call" = "Ophangen"; +"ignore" = "Negeren"; +"unignore" = "Stoppen met negeren"; +// Events formatter +"notice_avatar_changed_too" = "(avatar is ook veranderd)"; +"notice_room_name_removed" = "%@ heeft de gespreksnaam verwijderd"; +"notice_room_topic_removed" = "%@ heeft het onderwerp verwijderd"; +"notice_event_redacted" = ""; +"notice_event_redacted_by" = " door %@"; +"notice_event_redacted_reason" = " [reden: %@]"; +"notice_profile_change_redacted" = "%@ heeft zijn/haar profiel bijgewerkt %@"; +"notice_room_created" = "%@ heeft de kamer aangemaakt en ingesteld."; +"notice_room_join_rule" = "De toetredingsregel is: %@"; +"notice_room_power_level_intro" = "De machtsniveaus van de gespreksleden zijn:"; +"notice_room_power_level_acting_requirement" = "De minimale machtsniveaus waarover een gebruiker moet beschikken vooraleer deze kan handelen zijn:"; +"notice_room_power_level_event_requirement" = "De minimale machtsniveaus gerelateerd aan gebeurtenissen zijn:"; +"notice_room_aliases" = "De gespreksbijnamen zijn: %@"; +"notice_encrypted_message" = "Versleuteld bericht"; +"notice_encryption_enabled" = "%@ heeft eind-tot-eind-versleuteling aangezet (%@-algoritme)"; +"notice_image_attachment" = "afbeeldingsbijlage"; +"notice_audio_attachment" = "audiobijlage"; +"notice_video_attachment" = "videobijlage"; +"notice_location_attachment" = "locatiebijlage"; +"notice_file_attachment" = "bestandsbijlage"; +"notice_invalid_attachment" = "ongeldige bijlage"; +"notice_unsupported_attachment" = "Niet-ondersteunde bijlage: %@"; +"notice_feedback" = "Feedbackgebeurtenis (id: %@): %@"; +"notice_redaction" = "%@ een gebeurtenis verwijderd (id: %@)"; +"notice_error_unsupported_event" = "Niet-ondersteunde gebeurtenis"; +"notice_error_unexpected_event" = "Onverwachte gebeurtenis"; +"notice_error_unknown_event_type" = "Onbekend gebeurtenistype"; +"notice_room_history_visible_to_anyone" = "%@ heeft de toekomstige gespreksgeschiedenis voor iedereen zichtbaar gemaakt."; +"notice_room_history_visible_to_members" = "%@ heeft de toekomstige gespreksgeschiedenis voor alle gespreksleden zichtbaar gemaakt."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ heeft de toekomstige gespreksgeschiedenis zichtbaar gemaakt voor alle gespreksleden, vanaf het moment dat ze uitgenodigd zijn."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ heeft de toekomstige gespreksgeschiedenis zichtbaar gemaakt voor alle gespreksleden, vanaf het moment dat ze toetreden."; +"notice_crypto_unable_to_decrypt" = "** Kan niet ontsleutelen: %@ **"; +"notice_crypto_error_unknown_inbound_session_id" = "De sessie van de afzender heeft ons geen sleutels voor dit bericht gestuurd."; +// room display name +"room_displayname_empty_room" = "Leeg gesprek"; +"room_displayname_two_members" = "%@ en %@"; +"room_displayname_more_than_two_members" = "%@ en %@ anderen"; +// Settings +"settings" = "Instellingen"; +"settings_enable_inapp_notifications" = "In-app-meldingen inschakelen"; +"settings_enable_push_notifications" = "Pushmeldingen inschakelen"; +"settings_enter_validation_token_for" = "Voer validatiebewijs voor %@ in:"; +"notification_settings_room_rule_title" = "Gesprek: ‘%@’"; +// Devices +"device_details_title" = "Sessie-informatie\n"; +"device_details_name" = "Publieke naam\n"; +"device_details_identifier" = "ID\n"; +"device_details_last_seen" = "Laatst gezien\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_rename_prompt_message" = "De publieke naam van een sessie is zichtbaar voor de personen waarmee u communiceert"; +"device_details_delete_prompt_title" = "Authenticatie"; +"device_details_delete_prompt_message" = "Deze handeling vereist bijkomende authenticatie.\nVoer uw wachtwoord in om verder te gaan."; +// Encryption information +"room_event_encryption_info_title" = "Informatie over eind-tot-eind-versleuteling\n\n"; +"room_event_encryption_info_event" = "Gebeurtenisinformatie\n"; +"room_event_encryption_info_event_user_id" = "Gebruikers-ID\n"; +"room_event_encryption_info_event_identity_key" = "Curve25519-identiteitssleutel\n"; +"room_event_encryption_info_event_fingerprint_key" = "Geclaimde Ed25519-vingerafdrukssleutel\n"; +"room_event_encryption_info_event_algorithm" = "Algoritme\n"; +"room_event_encryption_info_event_session_id" = "Sessie-ID\n"; +"room_event_encryption_info_event_decryption_error" = "Ontsleutelingsfout\n"; +"room_event_encryption_info_event_unencrypted" = "onversleuteld"; +"room_event_encryption_info_event_none" = "geen"; +"room_event_encryption_info_device" = "\nInformatie over sessie van afzender\n"; +"room_event_encryption_info_device_unknown" = "onbekende sessie\n"; +"room_event_encryption_info_device_name" = "Publieke naam\n"; +"room_event_encryption_info_device_id" = "ID\n"; +"room_event_encryption_info_device_verification" = "Verificatie\n"; +"room_event_encryption_info_device_fingerprint" = "Ed25519-vingerafdruk\n"; +"room_event_encryption_info_device_verified" = "Geverifieerd"; +"room_event_encryption_info_device_not_verified" = "NIET geverifieerd"; +"room_event_encryption_info_device_blocked" = "Geblokkeerd"; +"room_event_encryption_info_verify" = "Verifiëren…"; +"room_event_encryption_info_unverify" = "Ontverifiëren"; +"room_event_encryption_info_block" = "Blokkeren"; +"room_event_encryption_info_unblock" = "Deblokkeren"; +"room_event_encryption_verify_title" = "Sessie verifiëren\n\n"; +"room_event_encryption_verify_message" = "Om te verifiëren dat deze sessie vertrouwd kan worden, neemt u contact op met de eigenaar van de sessie op een andere manier (bv. persoonlijk of door te bellen) en vraagt u hem/haar of de sleutel die hij/zij in de gebruikersinstellingen ziet overeenkomt met de onderstaande sleutel:\n\n\tSessienaam: %@\n\tSessie-ID: %@\n\tSessiesleutel: %@\n\nAls het overeenkomt, klikt u hieronder op de knop ‘Verifiëren’. Als het niet overeenkomt, onderschept iemand anders deze sessie en drukt u in plaats daarvan op de knop ‘Blokkeren’.\n\nIn de toekomst zal dit verificatieproces verbeterd worden."; +"room_event_encryption_verify_ok" = "Verifiëren"; +// Account +"account_save_changes" = "Wijzigingen opslaan"; +"account_link_email" = "E-mailadres koppelen"; +"account_linked_emails" = "Gekoppelde e-mailadressen"; +"account_email_validation_title" = "Verificatie in afwachting"; +"account_email_validation_message" = "Bekijk uw e-mail en open de koppeling erin. Wanneer dit gedaan is, tikt u op verder gaan."; +"account_email_validation_error" = "Kan het e-mailadres niet verifiëren. Bekijk uw e-mail en open de koppeling erin. Wanneer dit gedaan is, tikt u op verder gaan"; +"account_msisdn_validation_title" = "Verificatie in afwachting"; +"account_msisdn_validation_message" = "We hebben een sms met een activatiecode verstuurd. Voer deze code hieronder in."; +"account_msisdn_validation_error" = "Kan het telefoonnummer niet verifiëren."; +"account_error_display_name_change_failed" = "Wijzigen van weergavenaam is mislukt"; +"account_error_picture_change_failed" = "Wijzigen van afbeelding is mislukt"; +"account_error_matrix_session_is_not_opened" = "Matrix-sessie is niet geopend"; +"account_error_email_wrong_title" = "Ongeldig e-mailadres"; +"account_error_email_wrong_description" = "Het ziet er niet naar uit dat dit een geldig e-mailadres is"; +"account_error_msisdn_wrong_title" = "Ongeldig telefoonnummer"; +"account_error_msisdn_wrong_description" = "Het ziet er niet naar uit dat dit een geldig telefoonnummer is"; +// Room creation +"room_creation_name_title" = "Gespreksnaam:"; +"room_creation_name_placeholder" = "(bv. lunchGroep)"; +"room_creation_alias_title" = "Gespreksbijnaam:"; +"room_creation_alias_placeholder" = "(bv. #foo:voorbeeld.org)"; +"room_creation_alias_placeholder_with_homeserver" = "(bv. #foo%@)"; +"room_creation_participants_title" = "Deelnemers:"; +"room_creation_participants_placeholder" = "(bv. @jan:thuisserver1; @joep:thuisserver2…)"; +// Room +"room_please_select" = "Selecteer een gesprek"; +"room_error_join_failed_title" = "Toetreden tot het gesprek is mislukt"; +"room_error_join_failed_empty_room" = "Het is momenteel niet mogelijk om tot een leeg gesprek toe te treden."; +"room_error_name_edition_not_authorized" = "U bent niet bevoegd om de naam van dit gesprek te wijzigen"; +"room_error_topic_edition_not_authorized" = "U bent niet bevoegd om het onderwerp van dit gesprek te wijzigen"; +"room_error_cannot_load_timeline" = "Laden van tijdslijn is mislukt"; +"room_error_timeline_event_not_found_title" = "Laden van tijdslijnpositie is mislukt"; +"room_error_timeline_event_not_found" = "De app heeft geprobeerd een specifiek punt in de tijdslijn van dit gesprek te laden, maar kon het niet vinden"; +"room_left" = "U heeft het gesprek verlaten"; +"room_no_power_to_create_conference_call" = "U heeft toestemming nodig om een vergadering in dit groepsgesprek te starten"; +"room_no_conference_call_in_encrypted_rooms" = "Vergadergesprekken worden niet ondersteund in versleutelde gesprekken"; +// Room members +"room_member_ignore_prompt" = "Weet u zeker dat u alle berichten van deze gebruiker wilt verbergen?"; +"room_member_power_level_prompt" = "U kunt deze veranderingen niet ongedaan maken aangezien u de gebruiker tot hetzelfde niveau als uzelf promoveert.\nWeet u het zeker?"; +// Attachment +"attachment_size_prompt" = "Wilt u het versturen als:"; +"attachment_original" = "Werkelijke grootte (%@)"; +"attachment_small" = "Klein (~%@)"; +"attachment_medium" = "Middel (~%@)"; +"attachment_large" = "Groot (~%@)"; +"attachment_cancel_download" = "Download annuleren?"; +"attachment_cancel_upload" = "Upload annuleren?"; +"attachment_multiselection_size_prompt" = "Wilt u afbeeldingen versturen als:"; +"attachment_multiselection_original" = "Werkelijke grootte"; +"attachment_e2e_keys_file_prompt" = "Dit bestand bevat versleutelingssleutels die uit een Matrix-client geëxporteerd zijn.\nWilt u de bestandsinhoud bekijken of de sleutels die het bevat importeren?"; +"attachment_e2e_keys_import" = "Bezig met importeren…"; +// Contacts +"contact_mx_users" = "Matrix-gebruikers"; +"contact_local_contacts" = "Lokale contacten"; +// Search +"search_no_results" = "Geen resultaten"; +// Time +"format_time_s" = "s"; +"format_time_m" = "m"; +"format_time_h" = "u"; +"format_time_d" = "d"; +// E2E import +"e2e_import_room_keys" = "Gesprekssleutels importeren"; +"e2e_import_prompt" = "Dit proces maakt het mogelijk om versleutelingssleutels die u eerder had geëxporteerd vanaf een andere Matrix-cliënt te importeren. Daarna kunt u alle berichten ontsleutelen die de andere cliënt ook kon ontsleutelen.\nHet exporteerbestand is beschermd met een wachtwoord. Voer hier het wachtwoord in om het bestand te ontsleutelen."; +"e2e_import" = "Importeren"; +"e2e_passphrase_enter" = "Voer wachtwoord in"; +// E2E export +"e2e_export_room_keys" = "Gesprekssleutels exporteren"; +"e2e_export_prompt" = "Dit proces maakt het mogelijk om de sleutels voor berichten die u heeft ontvangen in versleutelde gesprekken te exporteren naar een lokaal bestand. Daarna kunt u het bestand in de toekomst in een andere Matrix-cliënt importeren, zodat die cliënt ook deze berichten zal kunnen ontsleutelen.\nHet geëxporteerde bestand zal iedereen die het kan lezen de mogelijkheid bieden om de versleutelde berichten die u kunt zien te ontsleutelen, dus wees voorzichtig en bewaar het op een veilige plaats."; +"e2e_export" = "Exporteren"; +"e2e_passphrase_confirm" = "Bevestig wachtwoord"; +"e2e_passphrase_empty" = "Wachtwoord mag niet leeg zijn"; +"e2e_passphrase_not_match" = "Wachtwoorden moeten overeenkomen"; +// Others +"user_id_title" = "Gebruikers-ID:"; +"offline" = "offline"; +"unsent" = "Niet verstuurd"; +"error" = "Fout"; +"not_supported_yet" = "Nog niet ondersteund"; +"default" = "standaard"; +"private" = "Privé"; +"public" = "Publiek"; +"power_level" = "Machtsniveau"; +"network_error_not_reachable" = "Controleer uw netwerkverbinding"; +"user_id_placeholder" = "bv: @jan:thuisserver"; +"ssl_homeserver_url" = "Thuisserver-URL: %@"; +// Permissions +"camera_access_not_granted_for_call" = "Video-oproepen vereisen toegang tot de camera, maar %@ heeft hier geen toestemming voor"; +"microphone_access_not_granted_for_call" = "Oproepen vereisen toegang tot de camera, maar %@ heeft hier geen toestemming voor"; +"local_contacts_access_not_granted" = "Gebruikers zoeken op basis van uw lokale contacten vereist toegang tot die contacten, maar %@ heeft hier geen toestemming voor"; +"local_contacts_access_discovery_warning_title" = "Gebruikers zoeken"; +"local_contacts_access_discovery_warning" = "Om contacten te vinden die Matrix al gebruiken, kan %@ de e-mailadressen en telefoonnummers in uw adresboek naar uw gekozen Matrix-identiteitsserver sturen. Waar ondersteund worden de persoonlijke gegevens gehasht vóór het versturen - bekijk het privacybeleid van uw identiteitsserver voor meer informatie."; +// Country picker +"country_picker_title" = "Kies een land"; +/* -*- + Automatic localization for en + + The following key/value pairs were extracted from the android i18n file: + /matrix-sdk/src/main/res/values/strings.xml. +*/ + +"notice_room_invite" = "%@ heeft %@ uitgenodigd"; +"notice_room_third_party_invite" = "%@ heeft een uitnodiging gestuurd naar %@ om tot het gesprek toe te treden"; +"notice_room_third_party_registered_invite" = "%@ heeft de uitnodiging voor %@ aanvaard"; +"notice_room_join" = "%@ is tot de kamer toegetreden"; +"notice_room_leave" = "%@ heeft de kamer verlaten"; +"notice_room_reject" = "%@ heeft de uitnodiging geweigerd"; +"notice_room_kick" = "%@ heeft %@ uit de kamer gezet"; +"notice_room_unban" = "%@ heeft %@ ontbannen"; +"notice_room_ban" = "%@ heeft %@ verbannen"; +"notice_room_withdraw" = "%@ heeft de uitnodiging van %@ ingetrokken"; +"notice_room_reason" = ". Reden: %@"; +"notice_avatar_url_changed" = "%@ heeft zijn/haar avatar veranderd"; +"notice_display_name_set" = "%@ heeft zijn/haar weergavenaam veranderd naar %@"; +"notice_display_name_changed_from" = "%@ heeft zijn/haar weergavenaam veranderd van %@ naar %@"; +"notice_display_name_removed" = "%@ heeft zijn/haar weergavenaam verwijderd"; +"notice_topic_changed" = "%@ heeft het onderwerp veranderd naar \"%@\"."; +"notice_room_name_changed" = "%@ heeft de gespreksnaam veranderd naar %@."; +"notice_placed_voice_call" = "%@ heeft een spraakoproep gestart"; +"notice_placed_video_call" = "%@ heeft een video-oproep gestart"; +"notice_answered_video_call" = "%@ heeft de oproep beantwoord"; +"notice_ended_video_call" = "%@ heeft opgehangen"; +"notice_conference_call_request" = "%@ heeft een VoIP-vergadering aangevraagd"; +"notice_conference_call_started" = "VoIP-vergadering gestart"; +"notice_conference_call_finished" = "VoIP-vergadering beëindigd"; +// Room Screen + +// general errors + +// Home Screen + +// Last seen time + +// call events + +/* -*- + Automatic localization for en + + The following key/value pairs were extracted from the android i18n file: + /console/src/main/res/values/strings.xml. +*/ + +// titles + +// button names +"ok" = "Oké"; +"cancel" = "Annuleren"; +"save" = "Opslaan"; +"leave" = "Verlaten"; +"send" = "Versturen"; +"copy_button_name" = "Kopiëren"; +"resend" = "Opnieuw versturen"; +"redact" = "Verwijderen"; +"share" = "Delen"; +"delete" = "Verwijderen"; +"view" = "Bekijken"; +// actions +"action_logout" = "Afmelden"; +"create_room" = "Gesprek aanmaken"; +"login" = "Aanmelden"; +"create_account" = "Account aanmaken"; +"membership_invite" = "Uitgenodigd"; +"membership_leave" = "Verlaten"; +"membership_ban" = "Verbannen"; +"num_members_one" = "%@ gebruiker"; +"num_members_other" = "%@ gebruikers"; +"invite" = "Uitnodigen"; +"kick" = "Er uit zetten"; +"ban" = "Verbannen"; +"unban" = "Ontbannen"; +"message_unsaved_changes" = "Er zijn onopgeslagen wijzigingen. Verlaten zal ze verwijderen."; +// Login Screen +"login_error_already_logged_in" = "Reeds aangemeld"; +"login_error_must_start_http" = "URL moet beginnen met http[s]://"; +// members list Screen + +// accounts list Screen + +// image size selection + +// invitation members list Screen + +// room creation dialog Screen + +// room info dialog Screen + +// room details dialog screen +"room_details_title" = "Kamerdetails"; +// contacts list screen +"invitation_message" = "Ik wil graag praten via Matrix. Bezoek de website https://matrix.org voor meer informatie."; +// Settings screen +"settings_title_config" = "Configuratie"; +"settings_title_notifications" = "Meldingen"; +// Notification settings screen +"notification_settings_disable_all" = "Alle meldingen uitschakelen"; +"notification_settings_enable_notifications" = "Meldingen inschakelen"; +"notification_settings_enable_notifications_warning" = "Alle meldingen zijn momenteel voor alle apparaten uitgeschakeld."; +"notification_settings_global_info" = "Meldingsinstellingen worden op uw account opgeslagen en gedeeld met alle cliënten die dat ondersteunen (inclusief bureaubladmeldingen).\n\nRegels worden in volgorde toegepast; de eerste regel die overeenkomt bepaalt de uitkomst van een bericht.\nDus: per-woord-meldingen zijn belangrijker dan per-gespreks-meldingen, die op hun beurt weer belangrijker zijn dan per-afzender-meldingen.\nVoor meerdere regels van hetzelfde type geldt dat de eerste in de lijst die overeenkomt de prioriteit heeft."; +"notification_settings_per_word_notifications" = "Per-woord-meldingen"; +"notification_settings_per_word_info" = "Woorden komen niet hoofdlettergevoelig met elkaar overeen en kunnen een *-wildcard bevatten. Dus:\nfoo komt overeen met de tekenreeks ‘foo’, die omgeven wordt door woordscheidingstekens (zoals punctuatie en spaties, of het begin of einde van een regel).\nfoo* komt overeen met elk woord dat met ‘foo’ begint.\n*foo* komt overeen met elk woord dat de drie letters ‘foo’ bevat."; +"notification_settings_always_notify" = "Altijd melden"; +"notification_settings_never_notify" = "Nooit melden"; +"notification_settings_word_to_match" = "woord om mee overeen te komen"; +"notification_settings_highlight" = "Markeren"; +"notification_settings_custom_sound" = "Aangepast geluid"; +"notification_settings_per_room_notifications" = "Per-gespreks-meldingen"; +"notification_settings_per_sender_notifications" = "Per-afzender-meldingen"; +"notification_settings_sender_hint" = "@gebruiker:domein.com"; +"notification_settings_select_room" = "Selecteer een gesprek"; +"notification_settings_other_alerts" = "Andere meldingen"; +"notification_settings_contain_my_user_name" = "Meld mij met geluid over berichten die mijn gebruikersnaam bevatten"; +"notification_settings_contain_my_display_name" = "Meld mij met geluid over berichten die mijn weergavenaam bevatten"; +"notification_settings_just_sent_to_me" = "Meld mij met geluid over berichten die alleen naar mij gestuurd zijn"; +"notification_settings_invite_to_a_new_room" = "Meld mij wanneer ik in een nieuw gesprek uitgenodigd word"; +"notification_settings_people_join_leave_rooms" = "Meld mij wanneer mensen het gesprek verlaten of betreden"; +"notification_settings_receive_a_call" = "Meld mij wanneer ik een oproep ontvang"; +"notification_settings_suppress_from_bots" = "Meldingen van robots onderdrukken"; +"notification_settings_by_default" = "Standaard…"; +"notification_settings_notify_all_other" = "Melden voor alle andere berichten/gesprekken"; +// gcm section +"settings_config_home_server" = "Thuisserver: %@"; +"settings_config_identity_server" = "Identiteitsserver: %@"; +"settings_config_user_id" = "Gebruikers-ID: %@"; +// Settings keys + +// call string +"call_waiting" = "In afwachting…"; +"call_connecting" = "Verbinden…"; +"call_ended" = "Oproep beëindigd"; +"call_ring" = "Bellen…"; +"incoming_video_call" = "Inkomende video-oproep"; +"incoming_voice_call" = "Inkomende spraakoproep"; +"call_invite_expired" = "Oproepuitnodiging verlopen"; +// unrecognized SSL certificate +"ssl_trust" = "Vertrouwen"; +"ssl_logout_account" = "Afmelden"; +"ssl_remain_offline" = "Negeren"; +"ssl_fingerprint_hash" = "Vingerafdruk (%@):"; +"ssl_could_not_verify" = "Kan de identiteit van de externe server niet bepalen."; +"ssl_cert_not_trust" = "Dit kan betekenen dat iemand kwaadwillig uw verkeer onderschept, of dat uw telefoon het certificaat dat door de externe server wordt geleverd niet vertrouwt."; +"ssl_cert_new_account_expl" = "Als de serverbeheerder heeft gezegd dat dit de bedoeling is, wees er dan zeker van dat de vingerafdruk hieronder overeenkomt met de vingerafdruk die door hen wordt geleverd."; +"ssl_unexpected_existing_expl" = "Het certificaat is veranderd van één dat door uw telefoon werd vertrouwd naar een ander. Dit is HEEL ONGEBRUIKELIJK. Het wordt aangeraden om dit nieuwe certificaat NIET TE AANVAARDEN."; +"ssl_expected_existing_expl" = "Het certificaat is veranderd van een vertrouwd naar een onvertrouwd certificaat. De server heeft misschien zijn certificaat vernieuwd. Contacteer de serverbeheerder voor de verwachte vingerafdruk."; +"ssl_only_accept" = "Aanvaard het certificaat alleen als de serverbeheerder een vingerafdruk heeft gepubliceerd die overeenkomt met degene hierboven."; +"search_searching" = "Bezig met zoeken…"; +// Language picker +"language_picker_title" = "Kies een taal"; +"language_picker_default_language" = "Standaard (%@)"; +"login_mobile_device" = "Mobiel"; +"login_tablet_device" = "Tablet"; +"login_desktop_device" = "Desktop"; +"notice_room_related_groups" = "De groepen die geassocieerd zijn met dit gesprek zijn: %@"; +// Groups +"group_invite_section" = "Uitnodigingen"; +"group_section" = "Groepen"; +"notice_sticker" = "sticker"; +"notice_in_reply_to" = "In antwoord op"; +"error_common_message" = "Er is een fout opgetreden. Probeer het later opnieuw."; +"login_error_resource_limit_exceeded_title" = "Bronlimiet Overschreden"; +"login_error_resource_limit_exceeded_message_default" = "Deze thuisserver heeft één of meerdere van zijn bronlimieten overschreden."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Deze thuisserver heeft zijn limiet voor maandelijks actieve gebruikers bereikt."; +"login_error_resource_limit_exceeded_message_contact" = "\n\nGelieve contact op te nemen met uw dienstbeheerder om deze dienst te blijven gebruiken."; +"login_error_resource_limit_exceeded_contact_button" = "Beheerder contacteren"; +// Reply to message +"message_reply_to_sender_sent_an_image" = "heeft een afbeelding gestuurd."; +"message_reply_to_sender_sent_a_video" = "heeft een video gestuurd."; +"message_reply_to_sender_sent_an_audio_file" = "heeft een audiobestand gestuurd."; +"message_reply_to_sender_sent_a_file" = "heeft een bestand gestuurd."; +"message_reply_to_message_to_reply_to_prefix" = "In antwoord op"; +"e2e_passphrase_create" = "Wachtwoord aanmaken"; +"account_error_push_not_allowed" = "Meldingen niet toegestaan"; +"notice_room_third_party_revoked_invite" = "%@ heeft de uitnodiging voor %@ om tot het gesprek toe te treden ingetrokken"; +"device_details_rename_prompt_title" = "Sessienaam"; +"notice_encryption_enabled_ok" = "%@ heeft eind-tot-eind-versleuteling ingeschakeld."; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ heeft eind-tot-eind-versleuteling ingeschakeld (onbekend algoritme %2$@)."; +"notice_room_name_removed_for_dm" = "%@ heeft de naam verwijderd"; +"notice_room_ban_by_you" = "U heeft %@ verbannen"; +"notice_room_unban_by_you" = "U heeft %@ ontbannen"; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "U heeft toekomstige berichten voor iedereen zichtbaar gemaakt vanaf het moment dat zij deelnemen aan het gesprek."; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "U heeft de toekomstige gespreksgeschiedenis zichtbaar gemaakt voor alle gespreksleden, vanaf het moment dat ze toegetreden zijn."; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "U heeft toekomstige berichten voor iedereen zichtbaar gemaakt vanaf het moment dat zij zijn uitgenodigd."; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "U heeft de toekomstige gespreksgeschiedenis zichtbaar gemaakt voor alle gespreksleden, vanaf het moment dat ze uitgenodigd zijn."; +"notice_room_history_visible_to_members_by_you_for_dm" = "U heeft toekomstige berichten voor alle gespreksleden zichtbaar gemaakt."; +"notice_room_history_visible_to_members_by_you" = "U heeft de toekomstige gespreksgeschiedenis voor alle gespreksleden zichtbaar gemaakt."; +"notice_room_history_visible_to_anyone_by_you" = "U heeft de toekomstige gespreksgeschiedenis voor iedereen zichtbaar gemaakt."; +"notice_redaction_by_you" = "U heeft een gebeurtenis bewerkt (ID: %@)"; +"notice_encryption_enabled_unknown_algorithm_by_you" = "U heeft eind-tot-eind-versleuteling ingeschakeld (onbekend algoritme %@)."; +"notice_encryption_enabled_ok_by_you" = "U heeft eind-tot-eind-versleuteling ingeschakeld."; +"notice_room_created_by_you_for_dm" = "U bent toegetreden."; +"notice_room_created_by_you" = "U heeft de kamer aangemaakt en ingesteld."; +"notice_profile_change_redacted_by_you" = "U heeft uw profiel %@ bijgewerkt"; +"notice_event_redacted_by_you" = " door u"; +"notice_room_topic_removed_by_you" = "U heeft het onderwerp verwijderd"; +"notice_room_name_removed_by_you_for_dm" = "U heeft de naam verwijderd"; +"notice_room_name_removed_by_you" = "U heeft de gespreksnaam verwijderd"; +"notice_conference_call_request_by_you" = "U heeft een VoIP-vergadering aangevraagd"; +"notice_ended_video_call_by_you" = "U heeft opgehangen"; +"notice_answered_video_call_by_you" = "U heeft de oproep beantwoord"; +"notice_placed_video_call_by_you" = "U heeft een spraakoproep gestart"; +"notice_placed_voice_call_by_you" = "U heeft een spraakoproep gestart"; +"notice_room_name_changed_by_you_for_dm" = "U heeft de gespreksnaam veranderd naar %@."; +"notice_room_name_changed_by_you" = "U heeft de gespreksnaam veranderd naar %@."; +"notice_topic_changed_by_you" = "U heeft het onderwerp veranderd naar \"%@\"."; +"notice_display_name_removed_by_you" = "U heeft uw weergavenaam verwijderd"; +"notice_display_name_changed_from_by_you" = "U heeft uw weergavenaam veranderd van %@ naar %@"; +"notice_display_name_set_by_you" = "U heeft uw weergavenaam veranderd naar %@"; +"notice_avatar_url_changed_by_you" = "U heeft uw profielfoto veranderd"; +"notice_room_withdraw_by_you" = "U heeft %@'s uitnodiging teruggetrokken"; +"notice_room_kick_by_you" = "U heeft %@ verwijderd uit het gesprek"; +"notice_room_reject_by_you" = "U heeft de uitnodiging geweigerd"; +"notice_room_leave_by_you" = "U heeft het gesprek verlaten"; +"notice_room_join_by_you" = "U bent toegetreden"; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "U heeft %@'s uitnodiging ingetrokken"; +"notice_room_third_party_revoked_invite_by_you" = "U heeft de uitnodiging aan %@ om aan het gesprek deel te nemen ingetrokken"; +"notice_room_third_party_registered_invite_by_you" = "U heeft de uitnodiging van %@ aanvaard"; +"notice_room_third_party_invite_by_you_for_dm" = "U heeft %@ uitgenodigd"; +"notice_room_third_party_invite_by_you" = "U heeft %@ uitgenodigd om aan het gesprek deel te nemen"; +"notice_room_invite_you" = "%@ heeft u uitgenodigd"; + +// Notice Events with "You" +"notice_room_invite_by_you" = "U heeft %@ uitgenodigd"; +"notice_room_name_changed_for_dm" = "%@ heeft de gespreksnaam veranderd naar %@."; +"notice_room_third_party_revoked_invite_for_dm" = "%@ heeft %@'s uitnodiging ingetrokken"; +"notice_room_third_party_invite_for_dm" = "%@ heeft %@ uitgenodigd"; +"room_left_for_dm" = "U heeft het gesprek verlaten"; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ heeft toekomstige berichten voor iedereen zichtbaar gemaakt vanaf het moment dat zij deelnemen aan het gesprek."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ heeft toekomstige berichten voor iedereen zichtbaar gemaakt vanaf het moment dat zij zijn uitgenodigd."; +"notice_room_history_visible_to_members_for_dm" = "%@ heeft toekomstige berichten voor alle gespreksleden zichtbaar gemaakt."; +"notice_room_aliases_for_dm" = "De bijnamen zijn: %@"; +"notice_room_join_rule_public_by_you_for_dm" = "U maakte dit publiekelijk."; +"notice_room_join_rule_public_by_you" = "U heeft de kamer publiekelijk gemaakt."; +"notice_room_join_rule_public_for_dm" = "%@ maakte dit publiekelijk."; +"notice_room_join_rule_public" = "%@ heeft de kamer publiekelijk gemaakt."; +"notice_room_created_for_dm" = "%@ is toegetreden."; +"notice_room_power_level_intro_for_dm" = "Het machtsniveau van de gespreksleden is:"; +"notice_room_join_rule_invite_by_you_for_dm" = "U maakte dit gesprek alleen op uitnodiging."; +"notice_room_join_rule_invite_by_you" = "U heeft het toegangsbeleid gewijzigd naar alleen genodigden."; +// New +"notice_room_join_rule_invite" = "%@ heeft het toegangsbeleid gewijzigd naar alleen genodigden."; +"notice_room_join_rule_invite_for_dm" = "%@ heeft dit gesprek alleen op uitnodiging gemaakt."; +"call_more_actions_dialpad" = "Kiestoetsen"; +"call_more_actions_transfer" = "Doorschakelen"; +"call_more_actions_audio_use_device" = "Ingebouwde Luidspreker"; +"call_more_actions_audio_use_headset" = "Audio-koptelefoon gebruiken"; +"call_more_actions_change_audio_device" = "Audio-apparaat wisselen"; +"call_more_actions_unhold" = "Hervatten"; +"call_more_actions_hold" = "Vasthouden"; +"call_holded" = "U heeft de oproep in de wacht"; +"call_remote_holded" = "%@ heeft de oproep in de wacht"; +"notice_declined_video_call_by_you" = "U heeft de oproep afgewezen"; +"notice_declined_video_call" = "%@ heeft de oproep afgewezen"; +"resume_call" = "Hervatten"; +"call_consulting_with_user" = "In de wacht bij %@"; +"call_transfer_to_user" = "Doorverbinden met %@"; +"call_video_with_user" = "Video-oproep met %@"; +"call_voice_with_user" = "Spraakoproep met %@"; +"call_ringing" = "Bellen…"; +"e2e_passphrase_too_short" = "Wachtwoord is te kort (hij moet minimaal %d tekens lang zijn)"; +"microphone_access_not_granted_for_voice_message" = "Spraakberichten vereisen toegang tot de Microfoon maar %@ heeft geen toestemming om het te gebruiken"; +"message_reply_to_sender_sent_a_voice_message" = "heeft een spraakbericht gestuurd."; +"attachment_large_with_resolution" = "Groot %@ (~%@)"; +"attachment_medium_with_resolution" = "Middel %@ (~%@)"; +"attachment_small_with_resolution" = "Klein %@ (~%@)"; +"attachment_size_prompt_message" = "U kunt dit uitzetten in uw instellingen."; +"attachment_size_prompt_title" = "Bevestig de afmeting om te versturen"; +"room_displayname_all_other_participants_left" = "%@ (vertrok)"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/pl.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/pl.lproj/MatrixKit.strings new file mode 100644 index 000000000..d90ed3c37 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/pl.lproj/MatrixKit.strings @@ -0,0 +1,498 @@ +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "Stwórz konto:"; +"login_server_url_placeholder" = "URL (np. https://matrix.org)"; +"login_identity_server_title" = "URL serwera tożsamości:"; +"login_password_placeholder" = "Hasło"; +"login_optional_field" = "opcjonalne"; +"login_email_placeholder" = "Adres e-mail"; +"login_error_forbidden" = "Nieprawidłowa nazwa użytkownika/hasło"; +"login_error_unknown_token" = "Wprowadzony token dostępu nie został rozpoznany"; +"login_error_bad_json" = "Uszkodzony JSON"; +"login_error_not_json" = "Nie zawiera prawidłowego JSON"; +"login_error_limit_exceeded" = "Wysłano zbyt wiele żądań"; +"login_error_user_in_use" = "Ta nazwa użytkownika jest już używana"; +"login_error_login_email_not_yet" = "Nie kliknięto odnośnika z wiadomości e-mail"; +"login_leave_fallback" = "Anuluj"; +"login_error_resource_limit_exceeded_title" = "Przekroczono limit dostępu do zasobów"; +"login_error_resource_limit_exceeded_message_default" = "Ten serwer przekroczył jeden z limitów dostępu do zasobów."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Ten serwer osiągnął miesięczny limit aktywnych użytkowników."; +"login_error_resource_limit_exceeded_message_contact" = "\n\nSkontaktuj się z administratorem Twojego serwera domowego, aby korzystać dalej z tej usługi."; +"login_error_resource_limit_exceeded_contact_button" = "Skontaktuj się z administratorem"; +// Action +"no" = "Nie"; +"yes" = "Tak"; +"back" = "Powrót"; +"close" = "Zamknij"; +"continue" = "Kontynuuj"; +"dismiss" = "Zamknij"; +"retry" = "Ponów"; +"submit" = "Wyślij"; +"login_home_server_title" = "URL serwera domowego:"; +"login_display_name_placeholder" = "Wyświetlana nazwa (np. Bob Obson)"; +"login_invalid_param" = "Nieprawidłowy parametr"; +"login_tablet_device" = "Tablet"; +"discard" = "Odrzuć"; +"start_chat" = "Rozpocznij rozmowę"; +"start_voice_call" = "Rozpocznij połączenie głosowe"; +"start_video_call" = "Rozpocznij połączenie wideo"; +"mention" = "Wspomnij"; +"capture_media" = "Zrób zdjęcie/film"; +"resend_message" = "Wyślij ponownie wiadomość"; +"select_all" = "Zaznacz wszystko"; +"cancel_upload" = "Anuluj wysyłanie"; +"cancel_download" = "Anuluj pobieranie"; +"ignore" = "Ignoruj"; +"unignore" = "Przestań ignorować"; +// Events formatter +"notice_avatar_changed_too" = "(awatar też został zmieniony)"; +"notice_room_name_removed" = "%@ usunął(-ęła) nazwę pokoju"; +"notice_room_topic_removed" = "%@ usunął(-ęła) temat pokoju"; +"notice_event_redacted" = ""; +"notice_event_redacted_by" = " przez %@"; +"notice_event_redacted_reason" = " [powód: %@]"; +"notice_profile_change_redacted" = "%@ zaktualizował(-a) swój profil %@"; +"notice_encrypted_message" = "Wiadomość zaszyfrowana"; +"notice_encryption_enabled" = "%@ włączył(a) szyfrowanie end-to-end (algorytm %@)"; +"ssl_only_accept" = "Akceptuj certyfikat TYLKO wtedy gdy administrator opublikował odcisk palca pasujący do tego powyżej."; +"ssl_unexpected_existing_expl" = "Certyfikat zmienił stan z zaufanego na niezaufany. Jest to NIEZWYKLE RZADKIE. Zalecane jest NIE AKCEPTOWANIE nowego certyfikatu."; +"ssl_cert_not_trust" = "Może to oznaczać że ktoś zakłóca twoje połączenie, lub Twój telefon nie ufa certyfikatowi dostarczonemu przez zdalny serwer."; +"ssl_could_not_verify" = "Nie można zweryfikować tożsamości serwera."; +"ssl_fingerprint_hash" = "Fingerprint (%@):"; +"ssl_remain_offline" = "Ignoruj"; +"ssl_logout_account" = "Wyloguj"; +// unrecognized SSL certificate +"ssl_trust" = "Ufaj"; +"incoming_voice_call" = "Przychodzące połączenie głosowe"; +"incoming_video_call" = "Przychodzące połączenie wideo"; +"call_ring" = "Dzwonię…"; +"call_ended" = "Zakończono połączenie"; +"call_connecting" = "Łączenie…"; +"settings_config_user_id" = "ID użytkownika: %@"; +"settings_config_identity_server" = "Serwer tożsamości: %@"; +// gcm section +"settings_config_home_server" = "Serwer Domowy: %@"; +"notification_settings_notify_all_other" = "Powiadamiaj o wszystkich innych wiadomośsciach/pokojach"; +"notification_settings_select_room" = "Wybierz pokój"; +"notification_settings_sender_hint" = "@user:domain.com"; +"notification_settings_enable_notifications" = "Włącz powiadomienia"; +// Notification settings screen +"notification_settings_disable_all" = "Wyłącz wszystkie powiadomienia"; +"settings_title_notifications" = "Powiadomienia"; +// room details dialog screen +"room_details_title" = "Szczegóły pokoju"; +"login_error_must_start_http" = "URL musi zaczynać się od http[s]://"; +"select_account" = "Wybierz konto"; +"show_details" = "Pokaż szczegóły"; +"end_call" = "Zakończ rozmowę"; +"login_error_title" = "Logowanie nie powiodło się"; +"login_error_registration_is_not_supported" = "Rejestracja nie jest obecnie wspierana"; +"register_error_title" = "Rejestracja nie powiodła się"; +"abort" = "Przerwij"; +"sign_up" = "Zarejestruj się"; +"submit_code" = "Prześlij kod"; +"attach_media" = "Dołącz zawartość multimedialną z Biblioteki"; +"reset_to_default" = "Przywróć ustawienia domyślne"; +"notice_room_created" = "%@ stworzył(-a) i skonfigurował (-a) pokój."; +"notice_audio_attachment" = "załącznik (audio)"; +"notice_video_attachment" = "załącznik (wideo)"; +"notice_invalid_attachment" = "nieprawidłowy załącznik"; +"notice_unsupported_attachment" = "Niewspierany załącznik: %@"; +"notice_room_history_visible_to_members" = "%@ uczynił(-a) przyszłą historię pokoju widoczną dla wszystkich uczestników pokoju."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ uczynił(-a) przyszłą historię pokoju widoczną dla wszystkich uczestników pokoju, od momentu ich zaproszenia."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ uczynił(-a) przyszłą historię pokoju widoczną dla wszystkich uczestników pokoju, od momentu ich dołączenia."; +"notice_crypto_error_unknown_inbound_session_id" = "Sesja nadawcy nie wysłała kluczy do wiadomości dla bieżącej sesji."; +"notice_sticker" = "naklejka"; +"notice_in_reply_to" = "W odpowiedzi do"; +// room display name +"room_displayname_empty_room" = "Pusty pokój"; +"room_displayname_two_members" = "%@ i %@"; +"room_displayname_more_than_two_members" = "%@ i %@ innych"; +// Settings +"settings" = "Ustawienia"; +"notification_settings_room_rule_title" = "Pokój: '%@'"; +// Devices +"device_details_title" = "Informacje o sesji\n"; +"device_details_name" = "Publiczna nazwa\n"; +"device_details_identifier" = "ID\n"; +"device_details_last_seen" = "Ostatnio widziany(-a)\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_rename_prompt_message" = "Publiczna nazwa sesji jest widoczna dla osób z którymi się komunikujesz"; +"device_details_delete_prompt_title" = "Uwierzytelnienie"; +"device_details_delete_prompt_message" = "To działanie wymaga dodatkowego uwierzytelnienia.\nWprowadź hasło, aby kontynuować."; +// Encryption information +"room_event_encryption_info_title" = "Informacje o szyfrowaniu end-to-end\n\n"; +"room_event_encryption_info_event" = "Informacje o zdarzeniu\n"; +"room_event_encryption_info_event_user_id" = "ID użytkownika\n"; +"room_event_encryption_info_event_identity_key" = "Klucz tożsamości Curve25519\n"; +"room_event_encryption_info_event_algorithm" = "Algorytm\n"; +"room_event_encryption_info_event_decryption_error" = "Błąd deszyfrowania\n"; +"room_event_encryption_info_device" = "\nInformacje o sesji nadawcy\n"; +"room_event_encryption_info_device_unknown" = "nieznana sesja\n"; +"room_event_encryption_info_device_name" = "Publiczna nazwa\n"; +"room_event_encryption_info_device_id" = "ID\n"; +"notification_settings_never_notify" = "Nigdy nie powiadamiaj"; +"notification_settings_always_notify" = "Zawsze powiadamiaj"; +// Settings screen +"settings_title_config" = "Konfiguracja"; +"ban" = "Zbanuj"; +"unban" = "Odbanuj"; +"kick" = "Wyproś"; +"invite" = "Zaproś"; +"num_members_other" = "%@ użytkowników"; +"num_members_one" = "%@ użytkownik"; +"create_account" = "Stwórz konto"; +"create_room" = "Utwórz pokój"; +// actions +"action_logout" = "Wyloguj"; +"view" = "Podgląd"; +"delete" = "Usuń"; +"set_power_level" = "Ustaw poziom uprawnień"; +"share" = "Udostępnij"; +"redact" = "Usuń"; +"resend" = "Wyślij ponownie"; +"copy_button_name" = "Kopiuj"; +"send" = "Wyślij"; +"leave" = "Opuść"; +"save" = "Zapisz"; +"cancel" = "Anuluj"; +// button names +"ok" = "OK"; +"notice_conference_call_finished" = "Zakończono konferencję VoIP"; +"notice_conference_call_started" = "Rozpoczęto konferencję VoIP"; +"notice_conference_call_request" = "%@ zaprasza do konferencji VoIP"; +"set_default_power_level" = "Resetuj poziom uprawnień"; +"notice_file_attachment" = "załącznik (plik)"; +"notice_room_history_visible_to_anyone" = "%@ uczynił(-a) przyszłą historię pokoju widoczną dla każdego."; +"notice_crypto_unable_to_decrypt" = "** Nie można odszyfrować: %@ **"; +"ssl_cert_new_account_expl" = "Jeśli administrator serwera oświadczył, że jest to oczekiwane, upewnij się, że poniższy odcisk palca odpowiada odciskowi palca dostarczonemu przez niego."; +"notification_settings_by_default" = "Domyślnie…"; +"notification_settings_suppress_from_bots" = "Ogranicz powiadomienia od botów"; +"notification_settings_custom_sound" = "Dźwięk niestandardowy"; +"login_home_server_info" = "Twój serwer domowy przechowuje wszystkie Twoje rozmowy i dane konta"; +"login_user_id_placeholder" = "Identyfikator Matrix (np. @bob:matrix.org lub bob)"; +"search_searching" = "Wyszukiwanie..."; + +// Search +"search_no_results" = "Brak wyników"; +"group_section" = "Grupy"; + +// Groups +"group_invite_section" = "Zaproszenia"; +"attachment_e2e_keys_import" = "Importuj..."; +"attachment_multiselection_original" = "Rzeczywisty rozmiar"; +"attachment_multiselection_size_prompt" = "Czy chcesz wysłać obrazy jako:"; +"attachment_cancel_upload" = "Przerwać wgrywanie?"; +"attachment_cancel_download" = "Przerwać pobieranie?"; +"membership_ban" = "Zbanowany(-a)"; +"login_desktop_device" = "Komputer"; +"login_mobile_device" = "Smartphone"; +"login_error_forgot_password_is_not_supported" = "Przywracanie hasła nie jest obecnie obsługiwane"; +"login_prompt_email_token" = "Wprowadź token weryfikacyjny wysłany na e-mail:"; +"room_event_encryption_info_event_session_id" = "ID sesji\n"; +"room_event_encryption_info_event_fingerprint_key" = "Odebrany klucz Ed25519 fingerprint\n"; +"device_details_rename_prompt_title" = "Nazwa sesji"; +"settings_enter_validation_token_for" = "Wprowadź token weryfikacyjny dla %@:"; +"settings_enable_push_notifications" = "Włącz powiadomienia push"; +"settings_enable_inapp_notifications" = "Włącz powiadomienia aplikacyjne"; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ uczynił(-a) przyszłą historię pokoju widoczną dla wszystkich uczestników pokoju, od momentu ich dołączenia."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ uczynił(-a) przyszłą historię pokoju widoczną dla wszystkich uczestników pokoju, od momentu ich zaproszenia."; +"notice_room_history_visible_to_members_for_dm" = "%@ uczynił(-a) przyszłą historię pokoju widoczną dla wszystkich uczestników pokoju."; +"notice_error_unknown_event_type" = "Nieznany typ zdarzenia"; +"notice_error_unexpected_event" = "Niespodziewane zdarzenie"; +"notice_error_unsupported_event" = "Nieobsługiwane zdarzenie"; +"notice_redaction" = "%@ zredagował(-a) zdarzenie (id: %@)"; +"notice_feedback" = "Opis zdarzenia (id: %@): %@"; +"notice_location_attachment" = "załącznik (lokalizacja)"; +"notice_image_attachment" = "załącznik (obraz)"; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ włączył(-a) szyfrowanie end-to-end (nierozpoznany algorytm %2$@)."; +"notice_encryption_enabled_ok" = "%@ włączył(-a) szyfrowanie end-to-end."; +"notice_room_related_groups" = "Społeczności powiązane z tym pokojem: %@"; +"notice_room_aliases_for_dm" = "Aliasy pokoju: %@"; +"notice_room_aliases" = "Aliasy pokoju: %@"; +"notice_room_power_level_event_requirement" = "Minimalny poziom uprawnień związany ze zdarzeniami:"; +"notice_room_power_level_acting_requirement" = "Minimalny poziom uprawnień uczestnika pokoju, aby mógł podjąć działania:"; +"notice_room_power_level_intro_for_dm" = "Poziom uprawnień uczestników pokoju:"; +"notice_room_power_level_intro" = "Poziom uprawnień uczestników pokoju:"; +"notice_room_join_rule_public_by_you_for_dm" = "Ustawiłeś(-aś) widoczność tego pokoju jako 'pokój publiczny'."; +"notice_room_join_rule_public_by_you" = "Ustawiłeś(-aś) widoczność tego pokoju jako 'pokój publiczny'."; +"notice_room_join_rule_public_for_dm" = "%@ ustawił(-a) widoczność tego pokoju jako 'pokój publiczny'."; +"notice_room_join_rule_public" = "%@ ustawił(-a) widoczność tego pokoju jako 'pokój publiczny'."; +"notice_room_join_rule_invite_by_you_for_dm" = "Ustawiłeś(-aś) dostępność tego pokoju na 'Tylko osoby, które zostały zaproszone'."; +"notice_room_join_rule_invite_by_you" = "Ustawiłeś(-aś) dostępność tego pokoju na 'Tylko osoby, które zostały zaproszone'."; +"notice_room_join_rule_invite_for_dm" = "%@ ustawił(-a) dostępność tego pokoju na 'Tylko osoby, które zostały zaproszone'."; +// New +"notice_room_join_rule_invite" = "%@ ustawił(-a) dostępność tego pokoju na 'Tylko osoby, które zostały zaproszone'."; +// Old +"notice_room_join_rule" = "Reguła dołączenia to: %@"; +"notice_room_created_for_dm" = "%@ stworzył(-a) pokój."; +"notice_room_name_removed_for_dm" = "%@ usunął(-ęła) nazwę pokoju"; +"resume_call" = "Wznów"; +"reject_call" = "Odrzuć połączenie"; +"answer_call" = "Odbierz połączenie"; +"invite_user" = "Zaproś użytkownika Matrix"; +"set_admin" = "Nadaj uprawnienia Administratora"; +"set_moderator" = "Nadaj uprawnienia Moderatora"; +"login_use_fallback" = "Użyj strony zastępczej"; +"login_error_do_not_support_login_flows" = "Obecnie nie obsługujemy żadnego lub wszystkich przepływów logowania zdefiniowanych przez ten Serwer Domowy"; +"login_error_no_login_flow" = "Nie udało się pobrać informacji uwierzytelniających z tego serwera domowego"; +"login_email_info" = "Podanie adresu e-mail pozwala innym użytkownikom na znalezienie Ciebie w sieci Matrix oraz pozwala na zresetowanie hasła."; +"login_identity_server_info" = "Matrix zapewnia serwery tożsamości do śledzenia, które e-maile itp. Należą do których identyfikatorów Matrix. Obecnie istnieje tylko https://matrix.org."; +"ssl_expected_existing_expl" = "Certyfikat zmienił się z wcześniej zaufanego na taki, który nie jest zaufany. Serwer mógł odnowić swój certyfikat. Skontaktuj się z administratorem serwera, aby uzyskać oczekiwany odcisk palca."; +"call_transfer_to_user" = "Transfer do %@"; +"call_consulting_with_user" = "Konsultacje z %@"; +"call_video_with_user" = "Połączenie wideo z %@"; +"call_voice_with_user" = "Połączenie głosowe z %@"; +"call_more_actions_dialpad" = "Klawiatura numeryczna"; +"call_more_actions_transfer" = "Transferuj"; +"call_more_actions_audio_use_device" = "Wyjście audio"; +"call_more_actions_audio_use_headset" = "Użyj zestawu głosowego"; +"call_more_actions_change_audio_device" = "Zmień urządzenie dźwiękowe"; +"call_more_actions_unhold" = "Wznów"; +"call_more_actions_hold" = "Wstrzymaj"; +"call_holded" = "Wstrzymałeś połączenie"; +"call_remote_holded" = "%@ wstrzymał(-a) połączenie"; +"call_invite_expired" = "Zaproszenie do rozmowy wygasło"; +"call_ringing" = "Dzwonię…"; +"notification_settings_receive_a_call" = "Powiadom mnie, gdy ktoś do mnie dzwoni"; +"notification_settings_people_join_leave_rooms" = "Powiadamiaj mnie, gdy ktoś dołącza do pokoju lub go opuszcza"; +"notification_settings_invite_to_a_new_room" = "Powiadamiaj mnie, gdy jestem zaproszony do nowego pokoju"; +"notification_settings_just_sent_to_me" = "Powiadamiaj mnie dźwiękiem o wiadomościach wysłanych tylko do mnie"; +"notification_settings_contain_my_display_name" = "Powiadamiaj mnie dźwiękiem o wiadomościach zawierających moją nazwę wyświetlaną"; +"notification_settings_contain_my_user_name" = "Powiadamiaj mnie dźwiękiem o wiadomościach zawierających moją nazwę użytkownika"; +"notification_settings_other_alerts" = "Inne Alarmy"; +"notification_settings_per_sender_notifications" = "Powiadomienia per-nadawca"; +"notification_settings_per_room_notifications" = "Powiadomienia per-pokój"; +"notification_settings_highlight" = "Wyróżnienie"; +"notification_settings_word_to_match" = "słowo do dopasowania"; +"notification_settings_per_word_info" = "Słowa dopasowują wielkość liter bez uwzględniania wielkości liter i mogą zawierać * symbol wieloznaczny. Więc:\nfoo dopasowuje ciąg foo otoczony ogranicznikami słów (np. interpunkcja i białe spacje lub początek / koniec linii).\nfoo* odpowiada każdemu słowu zaczynającemu się foo.\n*foo* pasuje do każdego takiego słowa, które zawiera 3 litery foo."; +"notification_settings_per_word_notifications" = "Powiadomienia według słów"; +"notification_settings_global_info" = "Ustawienia powiadomień są zapisywane na koncie użytkownika i są udostępniane wszystkim klientom, które je obsługują.\n\nReguły powiadomień są stosowane w kolejności; pierwsza pasująca reguła określa wynik wiadomości.\nTak więc: powiadomienia według słów są ważniejsze niż powiadomienia dotyczące pokoju, które są ważniejsze niż powiadomienia według nadawcy.\nW przypadku wielu reguł tego samego typu pierwszeństwo ma pierwsza na liście pasująca reguła."; +"notification_settings_enable_notifications_warning" = "Wszystkie powiadomienia są obecnie wyłączone dla wszystkich urządzeń."; + +// contacts list screen +"invitation_message" = "Chciałbym z Tobą porozmawiać za pomocą sieci Matrix. Odwiedź witrynę http://matrix.org, aby uzyskać więcej informacji."; + +// Login Screen +"login_error_already_logged_in" = "Jesteś już zalogowany(-a)"; +"message_unsaved_changes" = "Istnieją niezapisane zmiany. Opuszczenie spowoduje ich odrzucenie."; +"membership_leave" = "Odszedł(-a)"; +"membership_invite" = "Zaproszony(-a)"; +"login" = "Zaloguj się"; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "Sprawiłeś(-aś), że przyszłe wiadomości będą widoczne tylko dla uczestników pokoju od momentu, gdy dołączyli."; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "Sprawiłeś(-aś), że przyszłe wiadomości będą widoczne tylko dla uczestników pokoju od momentu, gdy dołączyli."; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "Sprawiłeś(-aś), że przyszłe wiadomości będą widoczne dla wszystkich uczestników pokoju od momentu, gdy zostali zaproszeni."; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "Sprawiłeś(-aś), że przyszłe wiadomości będą widoczne dla wszystkich uczestników pokoju od momentu, gdy zostali zaproszeni."; +"notice_room_history_visible_to_members_by_you_for_dm" = "Sprawiłeś(-aś), że przyszłe wiadomości będą widoczne dla wszystkich uczestników pokoju."; +"notice_room_history_visible_to_members_by_you" = "Sprawiłeś(-aś), że przyszłe wiadomości będą widoczne dla wszystkich uczestników pokoju."; +"notice_room_history_visible_to_anyone_by_you" = "Sprawiłeś(-aś), że przyszłe wiadomości będą widoczne dla każdego."; +"notice_redaction_by_you" = "Zredagowałeś(-aś) zdarzenie (id: %@)"; +"notice_encryption_enabled_unknown_algorithm_by_you" = "Włączyłeś(-aś) szyfrowanie end-to-end (nierozpoznany algorytm %@)."; +"notice_encryption_enabled_ok_by_you" = "Włączyłeś(-aś) szyfrowanie end-to-end."; +"notice_room_created_by_you_for_dm" = "Utworzyłeś(-aś) i skonfigurowałeś(-aś) pokój."; +"notice_room_created_by_you" = "Utworzyłeś(-aś) i skonfigurowałeś(-aś) pokój."; +"notice_profile_change_redacted_by_you" = "Zaktualizowałeś(-aś) swój profil %@"; +"notice_event_redacted_by_you" = " przez Ciebie"; +"notice_room_topic_removed_by_you" = "Usunąłeś(-aś) temat"; +"notice_room_name_removed_by_you_for_dm" = "Usunąłeś(-aś) nazwę pokoju"; +"notice_room_name_removed_by_you" = "Usunąłeś(-aś) nazwę pokoju"; +"notice_conference_call_request_by_you" = "Poprosiłeś(-aś) o konferencję VoIP"; +"notice_declined_video_call_by_you" = "Odrzuciłeś(-aś) połączenie"; +"notice_ended_video_call_by_you" = "Zakończyłeś(-aś) połączenie"; +"notice_answered_video_call_by_you" = "Odebrałeś(-aś) połączenie"; +"notice_placed_video_call_by_you" = "Nawiązałeś(-aś) rozmowę wideo"; +"notice_placed_voice_call_by_you" = "Nawiązałeś(-aś) połączenie głosowe"; +"notice_room_name_changed_by_you_for_dm" = "Zmieniłeś(-aś) nazwę na %@."; +"notice_room_name_changed_by_you" = "Zmieniłeś(-aś) nazwę pokoju na %@."; +"notice_topic_changed_by_you" = "Zmieniłeś(-aś) temat na \"%@\"."; +"notice_display_name_removed_by_you" = "Usunąłeś(-aś) swoją wyświetlaną nazwę"; +"notice_display_name_changed_from_by_you" = "Zmieniłeś(-aś) wyświetlaną nazwę z %@ na %@"; +"notice_display_name_set_by_you" = "Zmieniłeś(-aś) wyświetlaną nazwę na %@"; +"notice_avatar_url_changed_by_you" = "Zmieniłeś(-aś) swój awatar"; +"notice_room_withdraw_by_you" = "Wycofałeś(-aś) zaproszenie do pokoju dla %@"; +"notice_room_ban_by_you" = "Zbanowałeś(-aś) %@"; +"notice_room_unban_by_you" = "Odbanowałeś(-aś) %@"; +"notice_room_kick_by_you" = "Wyprosiłeś(-aś) %@"; +"notice_room_reject_by_you" = "Odrzuciłeś(-aś) zaproszenie"; +"notice_room_leave_by_you" = "Opuściłeś(-aś) pokój"; +"notice_room_join_by_you" = "Dołączyłeś(-aś) do pokoju"; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "Odrzuciłeś(-aś) zaproszenie %@"; +"notice_room_third_party_revoked_invite_by_you" = "Odrzuciłeś(-aś) zaproszenie do pokoju od %@"; +"notice_room_third_party_registered_invite_by_you" = "Przyjąłeś(-aś) zaproszenie od %@"; +"notice_room_third_party_invite_by_you_for_dm" = "Zaprosiłeś(-aś) %@"; +"notice_room_third_party_invite_by_you" = "Wysłałeś(-aś) zaproszenie do pokoju dla %@"; +"notice_room_invite_you" = "%@ zaprosił(-a) Ciebie"; + +// Notice Events with "You" +"notice_room_invite_by_you" = "Zaprosiłeś(-aś) %@"; +"notice_declined_video_call" = "%@ odrzucił(-a) połączenie"; +"notice_ended_video_call" = "%@ zakończył(-a) połączenie"; +"notice_answered_video_call" = "%@ odebrał(-a) połączenie"; +"notice_placed_video_call" = "%@ nawiązał(-a) połączenie wideo"; +"notice_placed_voice_call" = "%@ nawiązał(-a) połączenie głosowe"; +"notice_room_name_changed_for_dm" = "%@ zmienił(-a) nazwę na %@."; +"notice_room_name_changed" = "%@ zmienił(-a) nazwę pokoju na %@."; +"notice_topic_changed" = "%@ zmienił(-a) temat na \"%@\"."; +"notice_display_name_removed" = "%@ usunął(-ęła) swoją wyświetlaną nazwę"; +"notice_display_name_changed_from" = "%@ zmienił(-a) swoją wyświetlaną nazwę z %@ na %@"; +"notice_display_name_set" = "%@ zmienił(-a) swoją wyświetlaną nazwę na %@"; +"notice_avatar_url_changed" = "%@ zmienił(-a) swój awatar"; +"notice_room_reason" = ". Powód: %@"; +"notice_room_withdraw" = "%@ wycofał(-a) zaproszenie %@"; +"notice_room_ban" = "%@ zbanował(-a) %@"; +"notice_room_unban" = "%@ odbanował(-a) %@"; +"notice_room_kick" = "%@ wyprosiła(-a) %@"; +"notice_room_reject" = "%@ odrzucił(-a) zaproszenie"; +"notice_room_leave" = "%@ opuścił(-a) pokój"; +"notice_room_join" = "%@ dołączył(-a)"; +"notice_room_third_party_revoked_invite_for_dm" = "%@ odrzucił(-a) zaproszenie do pokoju od %@"; +"notice_room_third_party_registered_invite" = "%@ przyjął(-ęła) zaproszenie od %@"; +"notice_room_third_party_revoked_invite" = "%@ odrzucił(-a) zaproszenie do pokoju od %@"; +"notice_room_third_party_invite_for_dm" = "%@ zaprosił(-a) %@"; +"notice_room_third_party_invite" = "%@ wysłał(-a) zaproszenie do pokoju dla %@"; + +/* -*- + Automatic localization for en + + The following key/value pairs were extracted from the android i18n file: + /matrix-sdk/src/main/res/values/strings.xml. +*/ + +"notice_room_invite" = "%@ zaprosił(-a) %@"; +"language_picker_default_language" = "Domyślny (%@)"; + +// Language picker +"language_picker_title" = "Wybierz język"; + +// Country picker +"country_picker_title" = "Wybierz kraj"; +"local_contacts_access_discovery_warning" = "Aby znaleźć kontakty, które są użytkownikami sieci Matrix, %@ może wysłać adresy e-mail i numery telefonów z Twojej książki adresowej do wybranego serwera tożsamości Matrix. Jeśli serwer na to pozwala, dane osobowe są szyfrowane przed wysłaniem - zapoznaj się z polityką prywatności Twojego serwera tożsamości, aby uzyskać więcej informacji."; +"local_contacts_access_discovery_warning_title" = "Odkrywanie użytkowników"; +"local_contacts_access_not_granted" = "Wyszukiwanie użytkowników sieci Matrix na podstawie lokalnych kontaktów wymaga dostępu do Twoich kontaktów, ale %@ nie ma uprawnień, które umożliwiłyby ich użycie"; +"microphone_access_not_granted_for_call" = "Połączenia głosowe wymagają dostępu do mikrofonu, ale %@ nie ma pozwolenia na jego używanie"; + +// Permissions +"camera_access_not_granted_for_call" = "Rozmowy wideo wymagają dostępu do kamery, ale %@ nie ma pozwolenia na jej używanie"; +"ssl_homeserver_url" = "URL Serwera Domowego: %@"; +"user_id_placeholder" = "np.: @bob:homeserver"; +"network_error_not_reachable" = "Sprawdź połączenie internetowe"; +"power_level" = "Poziom uprawnień"; +"public" = "Publiczny"; +"private" = "Prywatny"; +"default" = "domyślny"; +"not_supported_yet" = "Jeszcze nie obsługiwane"; +"error_common_message" = "Wystąpił błąd. Spróbuj ponownie później."; +"error" = "Błąd"; +"unsent" = "Niewysłane"; +"offline" = "offline"; + +// Others +"user_id_title" = "ID użytkownika:"; +"e2e_passphrase_create" = "Utwórz hasło"; +"e2e_passphrase_not_match" = "Hasła muszą się zgadzać"; +"e2e_passphrase_empty" = "Hasło nie może być puste"; +"e2e_passphrase_confirm" = "Potwierdź hasło"; +"e2e_export" = "Eksport"; +"e2e_export_prompt" = "Ten proces umożliwia wyeksportowanie do pliku lokalnego kluczy wiadomości odebranych w zaszyfrowanych pokojach. Dzięki temu będziesz mógł w przyszłości zaimportować plik do innego klienta Matrix i odszyfrować te wiadomości.\nWyeksportowany plik pozwoli każdemu, kto może go odczytać, na odszyfrowanie wszelkich zaszyfrowanych wiadomości, które widzisz, więc powinieneś zadbać o jego bezpieczeństwo."; + +// E2E export +"e2e_export_room_keys" = "Eksportuj klucze pokoju"; +"e2e_passphrase_enter" = "Wprowadź hasło"; +"e2e_import" = "Importuj"; +"e2e_import_prompt" = "Ten proces umożliwia zaimportowanie kluczy szyfrowania, które zostały wcześniej wyeksportowane z innego klienta Matrix. Będziesz mógł odszyfrować wszystkie wiadomości, które inny klient mógłby odszyfrować. \nPlik eksportu jest chroniony hasłem. Wprowadź hasło, aby odszyfrować plik."; + +// E2E import +"e2e_import_room_keys" = "Importuj klucze pokoju"; +"format_time_d" = "d"; +"format_time_h" = "h"; +"format_time_m" = "m"; + +// Time +"format_time_s" = "s"; +"contact_local_contacts" = "Lokalne Kontakty"; + +// Contacts +"contact_mx_users" = "Użytkownicy Matrix"; +"attachment_e2e_keys_file_prompt" = "Ten plik zawiera klucze szyfrowania wyeksportowane z klienta Matrix.\nChcesz przejrzeć zawartość pliku czy zaimportować zawarte w nim klucze?"; +"attachment_large" = "Duży (%@)"; +"attachment_medium" = "Średni (%@)"; +"attachment_small" = "Mały (%@)"; +"attachment_original" = "Rzeczywisty rozmiar (%@)"; + +// Attachment +"attachment_size_prompt" = "Czy chcesz wysłać jako:"; +"room_member_power_level_prompt" = "Nie będzie można cofnąć tej zmiany, ponieważ nadajesz użytkownikowi uprawnienia równoważne do Twoich. \nCzy jesteś pewny(-a)?"; + +// Room members +"room_member_ignore_prompt" = "Czy na pewno chcesz ukryć wszystkie wiadomości od tego użytkownika?"; +"message_reply_to_message_to_reply_to_prefix" = "W odpowiedzi na"; +"message_reply_to_sender_sent_a_file" = "wysłał(-a) plik."; +"message_reply_to_sender_sent_a_video" = "wysłał(-a) plik wideo."; +"message_reply_to_sender_sent_an_audio_file" = "wysłał(-a) plik audio."; + +// Reply to message +"message_reply_to_sender_sent_an_image" = "wysłał(-a) obraz."; +"room_no_conference_call_in_encrypted_rooms" = "Połączenia konferencyjne nie są obsługiwane w zaszyfrowanych pokojach"; +"room_no_power_to_create_conference_call" = "Nie masz uprawnień do rozpoczęcia konferencji w tym pokoju"; +"room_left_for_dm" = "Opuściłeś(-aś) pokój"; +"room_left" = "Opuściłeś(-aś) pokój"; +"room_error_timeline_event_not_found" = "Aplikacja próbowała załadować określony punkt na osi czasu tego pokoju, ale nie mogła go znaleźć"; +"room_error_timeline_event_not_found_title" = "Nie udało się załadować pozycji na osi czasu"; +"room_error_cannot_load_timeline" = "Nie udało się załadować osi czasu"; +"room_error_topic_edition_not_authorized" = "Nie masz uprawnień do edytowania tematu tego pokoju"; +"room_error_name_edition_not_authorized" = "Nie masz uprawnień do edytowania nazwy tego pokoju"; +"room_error_join_failed_empty_room" = "Obecnie nie jest możliwe ponowne dołączenie do pustego pokoju."; +"room_error_join_failed_title" = "Nie udało się dołączyć do pokoju"; + +// Room +"room_please_select" = "Wybierz pokój"; +"room_creation_participants_placeholder" = "(np. @bob:homeserver1; @john:homeserver2...)"; +"room_creation_participants_title" = "Uczestnicy:"; +"room_creation_alias_placeholder_with_homeserver" = "(np. #foo%@)"; +"room_creation_alias_placeholder" = "(np. #foo:example.org)"; +"room_creation_alias_title" = "Alias pokoju:"; +"room_creation_name_placeholder" = "(np. grupa obiadowa)"; + +// Room creation +"room_creation_name_title" = "Nazwa pokoju:"; +"account_error_push_not_allowed" = "Powiadomienia nie są dozwolone"; +"account_error_msisdn_wrong_description" = "To nie wygląda na prawidłowy numer telefonu"; +"account_error_msisdn_wrong_title" = "Nieprawidłowy numer telefonu"; +"account_error_email_wrong_description" = "To nie wygląda na prawidłowy adres e-mail"; +"account_error_email_wrong_title" = "Niepoprawny adres e-mail"; +"account_error_matrix_session_is_not_opened" = "Sesja Matrix nie jest otwarta"; +"account_error_picture_change_failed" = "Zmiana obrazu nie powiodła się"; +"account_error_display_name_change_failed" = "Zmiana wyświetlanej nazwy nie powiodła się"; +"account_msisdn_validation_error" = "Nie można zweryfikować numeru telefonu."; +"account_msisdn_validation_message" = "Wysłaliśmy SMS-a z kodem aktywacyjnym. Wpisz otrzymany kod poniżej."; +"account_msisdn_validation_title" = "Oczekiwanie na weryfikację"; +"account_email_validation_error" = "Nie można zweryfikować adresu e-mail. Sprawdź swoją skrzynkę e-mail i kliknij zawarte w niej łącze. Gdy to zrobisz, kliknij kontynuuj"; +"account_email_validation_message" = "Sprawdź swoją skrzynkę e-mail i kliknij zawarte w niej łącze. Gdy to zrobisz, kliknij kontynuuj."; +"account_email_validation_title" = "Oczekiwanie na weryfikację"; +"account_linked_emails" = "Połączone adresy e-mail"; +"account_link_email" = "Połącz adres e-mail"; + +// Account +"account_save_changes" = "Zapisz zmiany"; +"room_event_encryption_verify_ok" = "Zweryfikuj"; +"room_event_encryption_verify_message" = "Aby sprawdzić, czy tej sesji można zaufać, skontaktuj się z jej właścicielem w inny sposób (np. osobiście lub telefonicznie) i zapytaj, czy klucz, który widzą w swoich ustawieniach użytkownika dla tej sesji, odpowiada kluczowi poniżej:\n\nNazwa sesji: %@\nIdentyfikator sesji: %@\nKlucz sesji: %@\n\nJeżeli klucze są identyczne, naciśnij przycisk weryfikacji poniżej. Jeżeli klucze się różnią, to oznacza to, że ktoś inny mógł przechwycić tę sesję — w takim przypadku naciśnij przycisk zablokuj.\n\nW przyszłości proces weryfikacji będzie bardziej wyrafinowany."; +"room_event_encryption_verify_title" = "Zweryfikuj sesję\n\n"; +"room_event_encryption_info_unblock" = "Odblokuj"; +"room_event_encryption_info_block" = "Zablokuj"; +"room_event_encryption_info_unverify" = "Cofnij weryfikację"; +"room_event_encryption_info_verify" = "Zweryfikuj..."; +"room_event_encryption_info_device_blocked" = "Zablokowany"; +"room_event_encryption_info_device_not_verified" = "NIE zweryfikowano"; +"room_event_encryption_info_device_verified" = "Zweryfikowano"; +"room_event_encryption_info_device_fingerprint" = "Ed25519 fingerprint\n"; +"room_event_encryption_info_device_verification" = "Weryfikacja\n"; +"room_event_encryption_info_event_none" = "brak"; +"room_event_encryption_info_event_unencrypted" = "niezaszyfrowane"; +"e2e_passphrase_too_short" = "Hasło jest zbyt krótkie (Hasło musi składać się z co najmniej %d znaków)"; +"microphone_access_not_granted_for_voice_message" = "Wiadomości głosowe wymagają dostępu do mikrofonu ale %@ nie posiada uprawnień do użycia go"; +"message_reply_to_sender_sent_a_voice_message" = "wysłał(-a) wiadomość głosową."; +"attachment_large_with_resolution" = "Duży %@ (~%@)"; +"attachment_medium_with_resolution" = "Średni %@ (~%@)"; +"attachment_small_with_resolution" = "Mały %@ (~%@)"; +"attachment_size_prompt_message" = "Możesz to wyłączyć w ustawieniach."; +"attachment_size_prompt_title" = "Potwierdź rozmiar, który chcesz wysłać"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/pt_BR.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/pt_BR.lproj/MatrixKit.strings new file mode 100644 index 000000000..19b5d5cc3 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/pt_BR.lproj/MatrixKit.strings @@ -0,0 +1,479 @@ +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "Criar conta:"; +"login_server_url_placeholder" = "URL (e.g. https://matrix.org)"; +"login_home_server_title" = "URL de Servidorcasa:"; +"login_home_server_info" = "Seu servidorcasa armazena todas as suas conversas e dados de conta"; +"login_identity_server_title" = "URL de servidor de identidade:"; +"login_identity_server_info" = "Matrix provê servidores de identidade para rastrear quais emails, etc. pertencem a quais IDs Matrix. Somente https://matrix.org existe atualmente."; +"login_user_id_placeholder" = "ID Matrix (e.g. @bob:matrix.org ou bob)"; +"login_password_placeholder" = "Senha"; +"login_optional_field" = "opcional"; +"login_display_name_placeholder" = "Nome de exibição (e.g. Bob Obson)"; +"login_email_info" = "Especificar um endereço de email permite que outras(os) usuárias(os) encontrem você mais facilmente, e vai dar a você uma forma de resettar sua senha no futuro."; +"login_email_placeholder" = "Endereço de email"; +"login_prompt_email_token" = "Por favor entre seu token de validação de email:"; +"login_error_title" = "Login Falhou"; +"login_error_no_login_flow" = "Nós falhamos para recuperar informação de autenticação deste servidorcasa"; +"view" = "Visualizar"; +"login_error_do_not_support_login_flows" = "Atualmente nós não suportamos qualquer ou todos os fluxos de login definidos por este servidorcasa"; +"back" = "Voltar"; +"continue" = "Continuar"; +"leave" = "Sair"; +"invite" = "Convidar"; +"retry" = "Retentar"; +"cancel" = "Cancelar"; +"save" = "Salvar"; +"login_error_registration_is_not_supported" = "Registro não é suportado atualmente"; +"login_error_forbidden" = "Nome de usuária(o)/senha inválidos"; +"login_error_unknown_token" = "O token de acesso especificado não foi reconhecido"; +"login_error_bad_json" = "JSON malformado"; +"login_error_not_json" = "Não continha JSON válido"; +"login_error_limit_exceeded" = "Requisições demais têm sido enviadas"; +"login_error_user_in_use" = "Este nome de usuária(o) já é usado"; +"login_desktop_device" = "Desktop"; +// Action +"no" = "Não"; +"yes" = "Sim"; +"abort" = "Abortar"; +"close" = "Fechar"; +"discard" = "Descartar"; +"dismiss" = "Dispensar"; +"sign_up" = "Fazer signup"; +"submit" = "Submeter"; +"submit_code" = "Submeter código"; +"set_default_power_level" = "Resettar Nível de Poder"; +"set_moderator" = "Definir Moderador(a)"; +"set_admin" = "Definir Admin"; +"start_chat" = "Começar Chat"; +"start_voice_call" = "Começar Chamada de Voz"; +"start_video_call" = "Começar Chamada de Vídeo"; +"mention" = "Mencionar"; +"select_account" = "Selecionar uma conta"; +"attach_media" = "Anexar Mídia desde Biblioteca"; +"capture_media" = "Tirar Foto/Vídeo"; +"invite_user" = "Convidar Usuária(o) matrix"; +"reset_to_default" = "Resettar para default"; +"resend_message" = "Reenviar a mensagem"; +"select_all" = "Selecionar Todas"; +"cancel_upload" = "Cancelar Upload"; +"cancel_download" = "Cancelar Download"; +"show_details" = "Mostrar Detalhes"; +"answer_call" = "Atender Chamada"; +"reject_call" = "Rejeitar Chamada"; +"end_call" = "Terminar Chamada"; +"ignore" = "Ignorar"; +"unignore" = "Designorar"; +"login_error_forgot_password_is_not_supported" = "Esqueceu senha não é suportada atualmente"; +"login_error_login_email_not_yet" = "O link de email que ainda não tem sido clicado"; +"login_use_fallback" = "Usar página de fallback"; +"login_leave_fallback" = "Cancelar"; +"login_invalid_param" = "Parâmetro inválido"; +"register_error_title" = "Registro Falhou"; +"login_tablet_device" = "Tablet"; +"login_error_resource_limit_exceeded_title" = "Limite de Recursos Excedido"; +"login_error_resource_limit_exceeded_message_default" = "Este servidorcasa tem excedido um de seus limites de recursos."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Este servidorcasa tem atingido seu limite de Usuárias(os) Mensalmente Ativos."; +"login_error_resource_limit_exceeded_message_contact" = "\n\nPor favor contacte seu/sua administrador(a) de serviço para continuar usando este serviço."; +"login_error_resource_limit_exceeded_contact_button" = "Contactar Administrador(a)"; +// Events formatter +"notice_avatar_changed_too" = "(avatar foi mudado também)"; +"notice_room_name_removed" = "%@ removeu o nome da sala"; +"notice_room_topic_removed" = "%@ removeu o tópico"; +"notice_event_redacted_by" = " por %@"; +"notice_event_redacted_reason" = " [razão: %@]"; +"login_mobile_device" = "Celular"; +"notice_event_redacted" = ""; +"notice_profile_change_redacted" = "%@ atualizou perfil dela(e) %@"; +"notice_room_created" = "%@ criou e configurou a sala."; +"notice_room_join_rule" = "A regra de se juntar é: %@"; +"notice_room_power_level_intro" = "Os níveis de poder de membros da sala são:"; +"notice_room_power_level_acting_requirement" = "Os níveis de poder mínimos que um/a usuária(o) deve ter antes de agir são:"; +"notice_room_power_level_event_requirement" = "Os níveis mínimos de poder relacionados a eventos são:"; +"notice_room_aliases" = "Os aliases da sala são: %@"; +"notice_room_related_groups" = "Os grupos associados a esta sala são: %@"; +"notice_encrypted_message" = "Mensagem encriptada"; +"set_power_level" = "Definir Nível de Poder"; +"power_level" = "Nível de Poder"; +"notice_encryption_enabled" = "%@ ativou a criptografia de ponta a ponta (algorithm %@)"; +"notice_image_attachment" = "anexo de imagem"; +"notice_audio_attachment" = "anexo de áudio"; +"notice_video_attachment" = "anexo de vídeo"; +"notice_location_attachment" = "anexo de local"; +"notice_file_attachment" = "anexo de arquivo"; +"notice_invalid_attachment" = "anexo inválido"; +"notice_unsupported_attachment" = "Anexo insuportado: %@"; +"notice_feedback" = "Evento de feedback (id: %@): %@"; +"notice_redaction" = "%@ redigiu um evento (id: %@)"; +"notice_error_unsupported_event" = "Evento insuportado"; +"notice_error_unexpected_event" = "Evento não-esperado"; +"notice_error_unknown_event_type" = "Tipo de evento desconhecido"; +"notice_room_history_visible_to_anyone" = "%@ fez histórico da sala futuro visível para qualquer pessoa."; +"notice_room_history_visible_to_members" = "%@ fez histórico da sala futuro visível para todos os membros da sala."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ fez histórico da sala futuro visível para todos os membros da sala, do ponto que foram convidados."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ fez histórico da sala futuro visível para todos os membros da sala, do ponto que se juntaram."; +"notice_crypto_unable_to_decrypt" = "** Incapaz de decriptar: %@ **"; +"notice_crypto_error_unknown_inbound_session_id" = "A sessão do/da enviador(a) não nos tem enviado as chaves para esta mensagem."; +"notice_sticker" = "sticker"; +"notice_in_reply_to" = "Em resposta a"; +// room display name +"room_displayname_empty_room" = "Sala vazia"; +"room_displayname_two_members" = "%@ e %@"; +"room_displayname_more_than_two_members" = "%@ e %@ outros"; +// Settings +"settings" = "Configurações"; +"settings_enable_inapp_notifications" = "Habilitar notificações Em-App"; +"settings_enable_push_notifications" = "Habilitar notificações push"; +"settings_enter_validation_token_for" = "Entrar token de validação para %@:"; +"notification_settings_room_rule_title" = "Sala: '%@'"; +// Devices +"device_details_title" = "Informação de sessão\n"; +"device_details_name" = "Nome Público\n"; +"device_details_identifier" = "ID\n"; +"device_details_last_seen" = "Visto por último\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_rename_prompt_message" = "O nome público de uma sessão é visível para pessoas com quem você se comunica"; +"device_details_delete_prompt_title" = "Autenticação"; +"device_details_delete_prompt_message" = "Esta operação requer autenticação adicional.\nPara continuar, por favor entre sua senha."; +// Encryption information +"room_event_encryption_info_title" = "Informação de encriptação ponta-a-ponta\n\n"; +"room_event_encryption_info_event" = "Informação de evento\n"; +"room_event_encryption_info_event_user_id" = "ID de usuária(o)\n"; +"room_event_encryption_info_event_algorithm" = "Algoritmo\n"; +"room_event_encryption_info_event_session_id" = "ID de sessão\n"; +"room_event_encryption_info_event_unencrypted" = "não-encriptado"; +"room_event_encryption_info_event_none" = "nenhuma"; +"room_event_encryption_info_device" = "\nInformação de sessão de enviador(a)\n"; +"room_event_encryption_info_device_unknown" = "sessão desconhecida\n"; +"room_event_encryption_info_device_name" = "Nome Público\n"; +"room_event_encryption_info_device_id" = "ID\n"; +"room_event_encryption_info_device_verification" = "Verificação\n"; +"room_event_encryption_info_event_decryption_error" = "Erro de decriptação\n"; +"room_event_encryption_info_device_verified" = "Verificado"; +"room_event_encryption_info_device_not_verified" = "NÃO verificado"; +"room_event_encryption_info_device_blocked" = "Na lista negra"; +"room_event_encryption_info_verify" = "Verificar..."; +"room_event_encryption_info_unverify" = "Desverificar"; +"room_event_encryption_info_block" = "Adicionar à lista negra"; +"room_event_encryption_info_unblock" = "Remover da lista negra"; +"room_event_encryption_verify_title" = "Verificar sessão\n\n"; +"room_event_encryption_verify_message" = "Para verificar que esta sessão pode ser confiada, por favor contacte a/o dona(o) dela usando alguma outro meio (e.g. em pessoa ou uma chamada de telefone) e pergunte-lhe se a chave que ela/ele vê em suas Configurações de Usuária(o) para esta sessão bate com a chave abaixo:\n\n\tNome de sessão: %@\n\tID de sessão: %@\n\tChave de sessão: %@\n\nSe ela bate, pressione o botão verificar abaixo. Se não bate, então alguma outra pessoa está interceptando esa sessão e você provavelmente quer pressionar o botão adicionar à lista negra em vez disso.\n\nNo futuro este processo de verificação vai ser mais sofisticado."; +"room_event_encryption_verify_ok" = "Verificar"; +// Account +"account_save_changes" = "Salvar mudanças"; +"account_link_email" = "Linkar Email"; +"account_linked_emails" = "Emails linkados"; +"account_email_validation_title" = "Verificação Pendendo"; +"account_email_validation_message" = "Por favor cheque seu email e clique no link que ele contém. Uma vez que isto seja feito, clique em continuar."; +"account_email_validation_error" = "Incapaz de verificar endereço de email. Por favor cheque seu email e clique no link que ele contém. Uma vez que isto seja feito, clique em continuar"; +"account_msisdn_validation_title" = "Verificação Pendendo"; +"account_msisdn_validation_message" = "Nós temos enviado um SMS com um código de ativação. Por favor entre este código abaixo."; +"account_msisdn_validation_error" = "Incapaz de verificar número de telefone."; +"room_event_encryption_info_event_identity_key" = "Chave de identidade Curve25519\n"; +"room_event_encryption_info_event_fingerprint_key" = "Chave de impressão digital Ed25519 clamada\n"; +"room_event_encryption_info_device_fingerprint" = "Impressão digital Ed25519\n"; +"account_error_display_name_change_failed" = "Mudança de nome de exibição falhou"; +"account_error_picture_change_failed" = "Mudança de imagem falhou"; +"account_error_matrix_session_is_not_opened" = "Sessão Matrix não está aberta"; +"account_error_email_wrong_title" = "Endereço de Email Inválido"; +"account_error_email_wrong_description" = "Isto não parece ser um endereço de email válido"; +"account_error_msisdn_wrong_title" = "Número de Telefone Inválido"; +"account_error_msisdn_wrong_description" = "Isto não parece ser um número de telefone válido"; +// Room creation +"room_creation_name_title" = "Nome de sala:"; +"room_creation_name_placeholder" = "(e.g. grupoDeAlmoço)"; +"room_creation_alias_title" = "Alias de sala:"; +"room_creation_participants_title" = "Participantes:"; +// Room +"room_please_select" = "Por favor selecione uma sala"; +"room_error_join_failed_title" = "Falha para se juntar a sala"; +"room_error_join_failed_empty_room" = "Não é atualmente possível se juntar a uma sala vazia."; +"room_error_name_edition_not_authorized" = "Você não está autorizada(o) a editar o nome deste sala"; +"room_error_topic_edition_not_authorized" = "Você não está autorizada(o) a editar o tópico desta sala"; +"room_error_cannot_load_timeline" = "Falha para carregar timeline"; +"room_error_timeline_event_not_found_title" = "Falha para carregar posição de timeline"; +"room_error_timeline_event_not_found" = "O aplicativo estava tentando carregar um ponto específico na timeline desta sala mas foi incapaz de o encontrar"; +"room_left" = "Você saiu da sala"; +"room_no_power_to_create_conference_call" = "Você precisa de permissão para convidar para começar uma conferência nesta sala"; +"room_no_conference_call_in_encrypted_rooms" = "Chamadas de conferência não são suportadas em salas encriptadas"; +// Reply to message +"message_reply_to_sender_sent_an_image" = "enviou uma imagem."; +"message_reply_to_sender_sent_a_video" = "enviou um vídeo."; +"room_creation_alias_placeholder" = "(e.g. #foo:exemplo.org)"; +"room_creation_alias_placeholder_with_homeserver" = "(e.g. #foo%@)"; +"room_creation_participants_placeholder" = "(e.g. @bob:servidorcasa1; @john:servidorcasa2...)"; +"message_reply_to_sender_sent_an_audio_file" = "enviou um arquivo de áudio."; +"message_reply_to_sender_sent_a_file" = "enviou um arquivo."; +"message_reply_to_message_to_reply_to_prefix" = "Em resposta a"; +// Room members +"room_member_ignore_prompt" = "Você tem certeza que você quer esconder todas as mensagens desta(e) usuária(o)?"; +"room_member_power_level_prompt" = "Você não vai ser capaz de desfazer esta mudança como você está promovendo a/o usuária(o) para ter o mesmo nível de poder que você mesma(o).\nVocê tem certeza?"; +// Attachment +"attachment_size_prompt" = "Você quer enviar como:"; +"attachment_original" = "Tamanho de Verdade (%@)"; +"attachment_small" = "Pequeno (~%@)"; +"attachment_medium" = "Médio (~%@)"; +"attachment_large" = "Grande (~%@)"; +"attachment_cancel_download" = "Cancelar o download?"; +"attachment_cancel_upload" = "Cancelar o upload?"; +"attachment_multiselection_size_prompt" = "Você quer enviar imagens como:"; +"attachment_multiselection_original" = "Tamanho de Verdade"; +"attachment_e2e_keys_file_prompt" = "Este arquivo contém chaves de encriptação exportadas de um cliente Matrix.\nVocê quer visualizar o conteúdo do arquivo ou importar as chaves que ele contém?"; +"attachment_e2e_keys_import" = "Importar..."; +// Contacts +"contact_mx_users" = "Usuárias(os) Matrix"; +"contact_local_contacts" = "Contatos Locais"; +// Groups +"group_invite_section" = "Convites"; +"group_section" = "Grupos"; +// Search +"search_no_results" = "Nenhum Resultado"; +"search_searching" = "Pesquisa em Progresso..."; +// Time +"format_time_s" = "s"; +"format_time_m" = "m"; +"format_time_h" = "h"; +"format_time_d" = "d"; +// E2E import +"e2e_import_room_keys" = "Importar chaves de sala"; +"e2e_import_prompt" = "Esse processo permite a você importar chaves de encriptação que você tinha previamente exportado de um outro cliente Matrix. Você vai então ser capaz de descriptar quaisquer mensagens que o outro cliente podia decriptar.\nO arquivo de exportação é protegido com uma frasepasse. Você deveria inserir a frasepasse aqui, para decriptar o arquivo."; +"e2e_import" = "Importar"; +"e2e_passphrase_enter" = "Entrar frasepasse"; +// E2E export +"e2e_export_room_keys" = "Exportar chaves de sala"; +"e2e_export_prompt" = "Este processo permite a você exportar as chaves para mensagens que você recebeu em salas encriptadas para um arquivo local. Você vai então ser capaz importar o arquivo para um outro cliente Matrix no futuro, para que o cliente também possa decriptar estas mensagens.\nO arquivo exportado vai permitir a qualquer pessoa que o possa ler decriptar quaisquer mensagens encriptadas que você pode ver, então você deveria ser cuidadosa(o) para mantê-lo protegido."; +"e2e_export" = "Exportar"; +"e2e_passphrase_confirm" = "Confirmar frasepasse"; +"e2e_passphrase_empty" = "Frasepasse não deve estar vazia"; +"e2e_passphrase_not_match" = "Frasepasses devem corresponder"; +"e2e_passphrase_create" = "Criar frasepasse"; +// Others +"user_id_title" = "ID de usuária(o):"; +"offline" = "offline"; +"unsent" = "Não-enviado"; +"error" = "Erro"; +"error_common_message" = "Um erro ocorreu. Por favor tente de novo mais tarde."; +"not_supported_yet" = "Não suportado ainda"; +"default" = "padrão"; +"private" = "Privado"; +"public" = "Público"; +"network_error_not_reachable" = "Por favor cheque sua conectividade de rede"; +"user_id_placeholder" = "ex: @bob:servidorcasa"; +"ssl_homeserver_url" = "URL de servidorcasa: %@"; +// Permissions +"camera_access_not_granted_for_call" = "Chamadas de vídeo requerem acesso à Câmera mas %@ não tem permissão para usá-la"; +"microphone_access_not_granted_for_call" = "Chamadas requerem acesso ao Microfone mas %@ não tem permissão para usá-lo"; +"local_contacts_access_not_granted" = "Descoberta de usuárias(os) desde contatos locais requer acesso a seus contatos mas %@ não tem permissão para usá-los"; +"local_contacts_access_discovery_warning_title" = "Descoberta de usuárias(os)"; +"local_contacts_access_discovery_warning" = "Para descobrir contatos já usando Matrix, %@ pode enviar endereços de email e números de telefone em seu livro de endereços para seu servidor de identidade Matrix escolhido. Onde suportado, dados pessoais são hashados antes do envio - por favor cheque a política de privacidade de seu servidor de identidade para mais detalhes."; +// Country picker +"country_picker_title" = "Escolha um país"; +// Language picker +"language_picker_title" = "Escolha uma língua"; +"language_picker_default_language" = "Default (%@)"; +"notice_room_invite" = "%@ convidou %@"; +"notice_room_third_party_invite" = "%@ enviou um convite para %@ para se juntar à sala"; +"notice_room_third_party_registered_invite" = "%@ aceitou o convite para %@"; +"notice_room_join" = "%@ juntou-se"; +"notice_room_leave" = "%@ saiu"; +"notice_room_reject" = "%@ rejeitou o convite"; +"notice_room_kick" = "%@ expulsou %@"; +"notice_room_unban" = "%@ desbaniu %@"; +"notice_room_ban" = "%@ baniu %@"; +"notice_room_withdraw" = "%@ retirou o convite de %@"; +"notice_room_reason" = ". Razão: %@"; +"notice_avatar_url_changed" = "%@ mudou o avatar dela(e)"; +"notice_display_name_set" = "%@ definiu o nome de exibição dela(e) para %@"; +"notice_display_name_changed_from" = "%@ mudou o nome de exibição dela(e) de %@ para %@"; +"notice_display_name_removed" = "%@ removeu o nome de exibição dela(e)"; +"notice_topic_changed" = "%@ mudou o tópico para \"%@\"."; +"notice_room_name_changed" = "%@ mudou o nome da sala para %@."; +"notice_placed_voice_call" = "%@ começou uma chamada de voz"; +"notice_placed_video_call" = "%@ começou uma chamada de vídeo"; +"notice_answered_video_call" = "%@ atendeu a chamada"; +"notice_ended_video_call" = "%@ terminou a chamada"; +"notice_conference_call_request" = "%@ requisitou uma conferência de VoIP"; +"notice_conference_call_started" = "Conferência de VoIP começada"; +"notice_conference_call_finished" = "Conferência de VoIP terminada"; +// button names +"ok" = "OK"; +"send" = "Enviar"; +"copy_button_name" = "Copiar"; +"resend" = "Reenviar"; +"redact" = "Remover"; +"share" = "Compartilhar"; +"delete" = "Deletar"; +// actions +"action_logout" = "Fazer logout"; +"create_room" = "Criar Sala"; +"login" = "Fazer login"; +"create_account" = "Criar Conta"; +"membership_invite" = "Convidada(o)"; +"membership_leave" = "Saiu"; +"membership_ban" = "Banida(o)"; +"num_members_one" = "%@ usuária(o)"; +"num_members_other" = "%@ usuárias(os)"; +"kick" = "Expulsar"; +"ban" = "Banir"; +"unban" = "Des-banir"; +"message_unsaved_changes" = "Existem mudanças não-salvas. Sair vai descartá-las."; +// Login Screen +"login_error_already_logged_in" = "Login já feito"; +"login_error_must_start_http" = "URL deve começar com http[s]://"; +// room details dialog screen +"room_details_title" = "Detalhes de Sala"; +// contacts list screen +"invitation_message" = "Eu gostaria de conversar com você com matrix. Por favor, visite o website http://matrix.org para ter mais informação."; +// Settings screen +"settings_title_config" = "Configuração"; +"settings_title_notifications" = "Notificações"; +// Notification settings screen +"notification_settings_disable_all" = "Desabilitar todas as notificações"; +"notification_settings_enable_notifications" = "Habilitar notificações"; +"notification_settings_enable_notifications_warning" = "Todas as notificações estão atualmente desabilitadas para todos os dispositivos."; +"notification_settings_global_info" = "Configurações de notificação são salvas em sua conta de usuária(o) e são compartilhadas entre todos os clientes que as suportam (incluindo notificações de desktop).\n\nRegras são aplicadas em ordem; a primeira regra que corresponde define o resultado da mensagem.\nEntão: Notificações per-palavra são mais importantes que notificações per-sala que são mais importantes que notificações per-enviador(a).\nPara múltiplas regras do mesmo tipo, a primeira na lista que corresponde leva prioridade."; +"notification_settings_per_word_notifications" = "Notificações per-palavra"; +"notification_settings_per_word_info" = "Palavras correspondem insensivelmente a maiúsculas e minúsculas, e podem incluir um wildcard *. Então:\nfoo corresponde a string foo rodeado por delimitadores de palavras (e.g., pontuação e whitespace ou início/fim de linha).\nfoo* corresponde a qualquer palavra que começa foo.\n*foo* corresponde a qualquer palavra que inclui as 3 letras foo."; +"notification_settings_always_notify" = "Sempre notificar"; +"notification_settings_never_notify" = "Nunca notificar"; +"notification_settings_word_to_match" = "palavra para corresponder"; +"notification_settings_highlight" = "Destacar"; +"notification_settings_custom_sound" = "Som personalizado"; +"notification_settings_per_room_notifications" = "Notificações per-sala"; +"notification_settings_per_sender_notifications" = "Notificações per-enviador(a)"; +"notification_settings_sender_hint" = "@usuarix:dominio.com"; +"notification_settings_select_room" = "Selecionar uma sala"; +"notification_settings_other_alerts" = "Outros Alertas"; +"notification_settings_contain_my_user_name" = "Notificar-me com som sobre mensagens que contêm meu nome de usuária(o)"; +"notification_settings_contain_my_display_name" = "Notificar-me com som sobre mensagens que contêm meu nome de exibição"; +"notification_settings_just_sent_to_me" = "Notificar-me com som sobre mensagens enviadas apenas para mim"; +"notification_settings_invite_to_a_new_room" = "Notificar-me quando eu sou convidada(o) para uma nova sala"; +"notification_settings_people_join_leave_rooms" = "Notificar-me quando pessoas se juntam ou saem de salas"; +"notification_settings_receive_a_call" = "Notificar-me quando eu recebo uma chamada"; +"notification_settings_suppress_from_bots" = "Suprimir notificações de bots"; +"notification_settings_by_default" = "Por default..."; +"notification_settings_notify_all_other" = "Notificar para todas as outras mensagens/salas"; +// gcm section +"settings_config_home_server" = "Servidorcasa: %@"; +"settings_config_identity_server" = "Servidor de identidade: %@"; +"settings_config_user_id" = "ID de usuária(o): %@"; +// call string +"call_waiting" = "Aguardando..."; +"call_connecting" = "Conectando…"; +"call_ended" = "Chamada terminada"; +"call_ring" = "Chamando..."; +"incoming_video_call" = "Chamada de Vídeo Entrante"; +"incoming_voice_call" = "Chamada de Voz Entrante"; +"call_invite_expired" = "Convite de Chamada Expirado"; +// unrecognized SSL certificate +"ssl_trust" = "Confiar"; +"ssl_logout_account" = "Fazer logout"; +"ssl_remain_offline" = "Ignorar"; +"ssl_fingerprint_hash" = "Impressão digital (%@):"; +"ssl_could_not_verify" = "Não foi possível verificar identidade de servidor remoto."; +"ssl_cert_not_trust" = "Isto pode significar que alguém está interceptando maliciosamente seu tráfico, ou que seu telefone não confia no certificado provido pelo servidor remoto."; +"ssl_cert_new_account_expl" = "Se o/a administrador(a) do servidor tem dito que isto é esperado, assegure-se que a impressão digital abaixo bate com a impressão digital provida por ele(a)."; +"ssl_unexpected_existing_expl" = "O certificado tem mudado de um que esta confiado por seu telefone. Isto é ALTAMENTE INCOMUM. É recomendado que você NÃO ACEITE este novo certificado."; +"ssl_expected_existing_expl" = "O certificado tem sido mudado de um previamente confiado para um que não é confiado. O servidor pode ter renovado o certificado dele. Contacte o/a administrador(a) do servidor a impressão digital esperada."; +"ssl_only_accept" = "SOMENTE aceite o certificado se o/a administrador(a) do servidor tem publicado uma impressão digital que corresponde à acima."; +"notice_encryption_enabled_ok" = "%@ ativou encriptação ponta-a-ponta."; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ ativou encriptação ponta-a-ponta (algoritmo não-reconhecido %2$@)."; +"device_details_rename_prompt_title" = "Nome da Sessão"; +"account_error_push_not_allowed" = "Notificações não permitidas"; +"notice_room_third_party_revoked_invite" = "%@ revogou o convite para %@ para se juntar à sala"; +// Notice Events with "You" +"notice_room_invite_by_you" = "Você convidou %@"; +"notice_room_invite_you" = "%@ convidou você"; +"notice_room_third_party_invite_by_you" = "Você enviou um convite para %@ para se juntar à sala"; +"notice_room_third_party_registered_invite_by_you" = "Você aceitou o convite para %@"; +"notice_room_third_party_revoked_invite_by_you" = "Você revogou o convite para que %@ se junte à sala"; +"notice_room_join_by_you" = "Você juntou-se"; +"notice_room_leave_by_you" = "Você saiu"; +"notice_room_reject_by_you" = "Você rejeitou o convite"; +"notice_room_kick_by_you" = "Você expulsou %@"; +"notice_room_unban_by_you" = "Você desbaniu %@"; +"notice_room_ban_by_you" = "Você baniu %@"; +"notice_room_withdraw_by_you" = "Você retirou o convite de %@"; +"notice_avatar_url_changed_by_you" = "Você mudou seu avatar"; +"notice_display_name_set_by_you" = "Você definiu seu nome de exibição para %@"; +"notice_display_name_changed_from_by_you" = "Você mudou seu nome de exibição de %@ para %@"; +"notice_display_name_removed_by_you" = "Você removeu seu nome de exibição"; +"notice_topic_changed_by_you" = "Você mudou o tópico para \"%@\"."; +"notice_room_name_changed_by_you" = "Você mudou o nome da sala para %@."; +"notice_placed_voice_call_by_you" = "Você começou uma chamada de voz"; +"notice_placed_video_call_by_you" = "Você começou uma chamada de vídeo"; +"notice_answered_video_call_by_you" = "Você atendeu a chamada"; +"notice_ended_video_call_by_you" = "Você terminou a chamada"; +"notice_conference_call_request_by_you" = "Você requisitou uma conferência de VoIP"; +"notice_room_name_removed_by_you" = "Você removeu o nome da sala"; +"notice_room_topic_removed_by_you" = "Você removeu o tópico"; +"notice_event_redacted_by_you" = " por você"; +"notice_profile_change_redacted_by_you" = "Você atualizou seu perfil %@"; +"notice_room_created_by_you" = "Você criou e configurou a sala."; +"notice_encryption_enabled_ok_by_you" = "Você ativou encriptação ponta-a-ponta."; +"notice_encryption_enabled_unknown_algorithm_by_you" = "Você ativou a encriptação ponta-a-ponta (algoritmo irreconhecido %@)."; +"notice_redaction_by_you" = "Você redigiu um evento (id: %@)"; +"notice_room_history_visible_to_anyone_by_you" = "Você fez histórico da sala futuro visível para qualquer pessoa."; +"notice_room_history_visible_to_members_by_you" = "Você fez histórico da sala futuro visível para todos os membros da sala."; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "Você fez histórico de sala futuro visível para todos os membros da sala, do ponto que são convidados."; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "Você fez histórico da sala futuro visível para todos os membros da sala, do momento que se juntaram."; +"notice_room_name_removed_for_dm" = "%@ removeu o nome"; +"notice_room_created_for_dm" = "%@ juntou-se."; +// New +"notice_room_join_rule_invite" = "%@ fez a sala somente convite."; +"notice_room_join_rule_invite_for_dm" = "%@ fez isto somente convite."; +"notice_room_join_rule_invite_by_you" = "Você fez a sala somente convite."; +"notice_room_join_rule_invite_by_you_for_dm" = "Você fez isto somente convite."; +"notice_room_join_rule_public" = "%@ fez a sala pública."; +"notice_room_join_rule_public_for_dm" = "%@ fez isto público."; +"notice_room_join_rule_public_by_you" = "Você fez a sala pública."; +"notice_room_join_rule_public_by_you_for_dm" = "Você fez isto público."; +"notice_room_power_level_intro_for_dm" = "Os níveis de poder de membros são:"; +"notice_room_aliases_for_dm" = "Os aliases são: %@"; +"notice_room_history_visible_to_members_for_dm" = "%@ fez mensagens futuras visíveis para todos os membros da sala."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ fez mensagens futuras visíveis para todas as pessoas, do ponto que são convidadas."; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ fez mensagens futuras visíveis para todas as pessoas, do ponto que se juntaram."; +"room_left_for_dm" = "Você saiu"; +"notice_room_third_party_invite_for_dm" = "%@ convidou %@"; +"notice_room_third_party_revoked_invite_for_dm" = "%@ revogou o convite de %@"; +"notice_room_name_changed_for_dm" = "%@ mudou o nome para %@."; +"notice_room_third_party_invite_by_you_for_dm" = "Você convidou %@"; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "Você revogou o convite de %@"; +"notice_room_name_changed_by_you_for_dm" = "Você mudou o nome para %@."; +"notice_room_name_removed_by_you_for_dm" = "Você removeu o nome"; +"notice_room_created_by_you_for_dm" = "Você juntou-se."; +"notice_room_history_visible_to_members_by_you_for_dm" = "Você fez mensagens futuras visíveis para todos os membros da sala."; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "Você fez mensagens futuras visíveis para todas as pessoas, do ponto que são convidadas."; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "Você fez mensagens futuras visíveis para todas as pessoas, do ponto que se juntaram."; +"call_more_actions_dialpad" = "Pad de disco"; +"call_more_actions_unhold" = "Retomar"; +"call_more_actions_hold" = "Pôr em espera"; +"call_holded" = "Você pôs a chamada em espera"; +"call_remote_holded" = "%@ pôs a chamada em espera"; +"notice_declined_video_call_by_you" = "Você declinou a chamada"; +"notice_declined_video_call" = "%@ declinou a chamada"; +"resume_call" = "Retomar"; +"call_more_actions_transfer" = "Transferir"; +"call_more_actions_audio_use_device" = "Falante de Dispositivo"; +"call_more_actions_audio_use_headset" = "Usar Áudio de Auscultador"; +"call_more_actions_change_audio_device" = "Mudar Dispositivo de Áudio"; +"call_video_with_user" = "Chamada de vídeo com %@"; +"call_voice_with_user" = "Chamada de voz com %@"; +"call_ringing" = "Tocando…"; +"call_transfer_to_user" = "Transferir para %@"; +"call_consulting_with_user" = "Consultando com %@"; +"e2e_passphrase_too_short" = "Frasepasse curta demais (Ela deve ser a um mínimo %d caracteres em comprimento)"; +"microphone_access_not_granted_for_voice_message" = "Mensagens de voz requerem acesso ao Microfone mas %@ não tem permissão para usá-lo"; +"message_reply_to_sender_sent_a_voice_message" = "enviou uma mensagem de voz."; +"attachment_large_with_resolution" = "Grande %@ (~%@)"; +"attachment_medium_with_resolution" = "Médio %@ (~%@)"; +"attachment_small_with_resolution" = "Pequeno %@ (~%@)"; +"attachment_size_prompt_message" = "Você pode desligar isto em configurações."; +"attachment_size_prompt_title" = "Confirmar tamanho para enviar"; +"room_displayname_all_other_participants_left" = "%@ (Saiu)"; +"auth_reset_password_error_not_found" = "Não encontrado"; +"auth_reset_password_error_unauthorized" = "Não-autorizada(o)"; +"auth_username_in_use" = "Nome de usuária(o) em uso"; +"auth_invalid_user_name" = "Nome de usuária(o) inválido"; +"rename" = "Renomear"; +"room_displayname_all_other_members_left" = "%@ (Saiu)"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/ru.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/ru.lproj/MatrixKit.strings new file mode 100644 index 000000000..53403ae12 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/ru.lproj/MatrixKit.strings @@ -0,0 +1,478 @@ +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "Регистрация:"; +"login_server_url_placeholder" = "URL (например https://matrix.org)"; +"login_home_server_title" = "URL домашнего сервера:"; +"login_home_server_info" = "Ваш домашний сервер хранит все ваши разговоры и данные о аккаунте"; +"login_identity_server_title" = "Cервер идентификации URL:"; +"login_identity_server_info" = "Серверы идентификации Matrix обеспечивают определение соответствия между email и Matrix ID. В настоящее время существует только https://matrix.org."; +"login_user_id_placeholder" = "Matrix ID (например, @bob:matrix.org или bob)"; +"login_password_placeholder" = "Пароль"; +"login_optional_field" = "не обязательно"; +"login_display_name_placeholder" = "Отображаемое имя (например, Иван Петров)"; +"login_email_info" = "Укажите адрес электронной почты, чтобы другие пользователи могли легко находить вас в Matrix. Кроме того, вы сможете при необходимости восстановить свой пароль."; +"login_email_placeholder" = "Адрес электронной почты"; +"login_prompt_email_token" = "Введите токен подтверждения адреса электронной почты:"; +"login_error_title" = "Не удалось войти"; +"login_error_no_login_flow" = "Не удалось получить информацию для аутентификации с этого домашнего сервера"; +"login_error_registration_is_not_supported" = "Регистрация в настоящее время не поддерживается"; +"login_error_forbidden" = "Неверное имя пользователя или пароль"; +"login_error_unknown_token" = "Указанный токен доступа не распознан"; +"login_error_bad_json" = "Поврежденный JSON"; +"login_error_not_json" = "Не содержит допустимый JSON"; +"login_error_limit_exceeded" = "Отправлено слишком много запросов"; +"login_error_user_in_use" = "Это имя пользователя уже используется"; +"login_error_login_email_not_yet" = "Вы не перешли по высланной в email ссылке"; +"login_leave_fallback" = "Отмена"; +"login_invalid_param" = "Недопустимый параметр"; +"register_error_title" = "Регистрация не удалась"; +// Action +"no" = "Нет"; +"yes" = "Да"; +"abort" = "Отменить"; +"back" = "Назад"; +"close" = "Закрыть"; +"continue" = "Продолжить"; +"discard" = "Отказаться"; +"dismiss" = "Отклонить"; +"retry" = "Попробовать снова"; +"sign_up" = "Зарегистрироваться"; +"submit" = "Отправить"; +"submit_code" = "Отправить код"; +"set_default_power_level" = "Сбросить уровень доступа"; +"set_moderator" = "Сделать модератором"; +"set_admin" = "Сделать администратором"; +"start_chat" = "Начать чат"; +"start_voice_call" = "Начать голосовой вызов"; +"start_video_call" = "Начать видеовызов"; +"mention" = "Упоминание"; +"select_account" = "Выберите аккаунт"; +"attach_media" = "Прикрепить файл из библиотеки"; +"capture_media" = "Сделать фото/видео"; +"invite_user" = "Пригласить пользователя matrix"; +"reset_to_default" = "Восстановить по умолчанию"; +"resend_message" = "Отправить сообщение снова"; +"select_all" = "Выбрать все"; +"cancel_upload" = "Отменить отправку"; +"cancel_download" = "Отменить загрузку"; +"show_details" = "Показать детали"; +"answer_call" = "Ответить на вызов"; +"reject_call" = "Отклонить вызов"; +"end_call" = "Завершить вызов"; +"ignore" = "Игнорировать"; +"unignore" = "Перестать игнорировать"; +// Events formatter +"notice_avatar_changed_too" = "(аватар также изменился)"; +"notice_room_name_removed" = "%@ удалил(а) название комнаты"; +"notice_room_topic_removed" = "%@ удалил(а) тему"; +"notice_event_redacted_reason" = " [причина: %@]"; +"notice_profile_change_redacted" = "%@ обновил(а) свой профиль %@"; +"notice_room_created" = "%@ создал(а) и настроил(а) комнату."; +"notice_room_power_level_intro" = "Уровень доступа членов комнаты:"; +"notice_room_power_level_acting_requirement" = "Минимальные уровни доступа пользователя для совершения действия:"; +"notice_room_power_level_event_requirement" = "Минимальные уровни доступа, связанные с событиями:"; +"notice_encrypted_message" = "Зашифрованное сообщение"; +"notice_encryption_enabled" = "%@ включил(а) сквозное шифрование (алгоритм %@)"; +"notice_image_attachment" = "прикрепленное изображение"; +"notice_audio_attachment" = "прикрепленное аудио"; +"notice_video_attachment" = "прикрепленное видео"; +"notice_location_attachment" = "прикрепленное местоположение"; +"notice_file_attachment" = "прикрепленный файл"; +"notice_invalid_attachment" = "недопустимое вложение"; +"notice_unsupported_attachment" = "Неподдерживаемое вложение: %@"; +"notice_redaction" = "%@ отредактировал(а) событие (id: %@)"; +"notice_error_unsupported_event" = "Неподдерживаемое событие"; +"notice_error_unexpected_event" = "Непредвиденное событие"; +"notice_error_unknown_event_type" = "Неизвестный тип события"; +"notice_room_history_visible_to_anyone" = "%@ сделал(а) будущую историю комнату видимой всем."; +"notice_room_history_visible_to_members" = "%@ сделал(а) будущую историю комнаты видимой всем членам комнаты."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ сделал(а) будущую историю комнаты видимой всем членам комнаты с момента их приглашения."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ сделал(а) будущую историю комнаты видимой всем членам комнаты с момента их входа."; +"notice_crypto_unable_to_decrypt" = "** Не удалось расшифровать: %@ **"; +"notice_crypto_error_unknown_inbound_session_id" = "Сессия отправителя не отправила вам ключи для этого сообщения."; +// room display name +"room_displayname_empty_room" = "Пустая комната"; +"room_displayname_two_members" = "%@ и %@"; +"room_displayname_more_than_two_members" = "%@ и %u другие"; +// Settings +"settings" = "Настройки"; +"settings_enable_inapp_notifications" = "Включить уведомления в приложении"; +"settings_enable_push_notifications" = "Включить push-уведомления"; +"settings_enter_validation_token_for" = "Введите токен подтверждения для %@:"; +"notification_settings_room_rule_title" = "Комната: '%@'"; +// Devices +"device_details_title" = "Информация о сессии\n"; +"device_details_name" = "Публичное имя\n"; +"device_details_identifier" = "ID\n"; +"device_details_last_seen" = "Последняя активность\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_rename_prompt_message" = "Публичные имена сессий видны людям, с которыми вы общаетесь"; +"device_details_delete_prompt_title" = "Аутентификация"; +"room_event_encryption_info_event" = "Информация о событии\n"; +"room_event_encryption_info_event_user_id" = "ID пользователя\n"; +"room_event_encryption_info_event_algorithm" = "Алгоритм\n"; +"room_event_encryption_info_event_session_id" = "ID сессии\n"; +"room_event_encryption_info_event_decryption_error" = "Ошибка расшифровки\n"; +"room_event_encryption_info_event_unencrypted" = "незашифровано"; +"room_event_encryption_info_device" = "\nИнформация о сессии отправителя\n"; +"room_event_encryption_info_device_unknown" = "неизвестная сессия\n"; +"room_event_encryption_info_device_name" = "Публичное имя\n"; +"room_creation_participants_title" = "Участники:"; +// Room +"room_please_select" = "Пожалуйста, выберите комнату"; +"room_error_join_failed_title" = "Не удалось войти в комнату"; +"room_left" = "Вы покинули комнату"; +// Room members +"room_member_ignore_prompt" = "Вы уверены, что хотите скрыть все сообщения этого пользователя?"; +"room_member_power_level_prompt" = "Вы не сможете отменить это действие, поскольку пользователь получит такой же уровень доступа, как и у вас. \nВы уверены?"; +"power_level" = "Уровень доступа"; +// Country picker +"country_picker_title" = "Выберите страну"; +// Language picker +"language_picker_title" = "Выберите язык"; +"language_picker_default_language" = "По умолчанию (%@)"; +"notice_room_join" = "%@ вошел(ла)"; +"notice_room_leave" = "%@ вышел(ла)"; +"notice_room_reject" = "%@ отклонил(а) приглашение"; +"notice_room_kick" = "%@ выкинул(а) %@"; +"notice_room_unban" = "%@ разблокировал(а) %@"; +"notice_room_ban" = "%@ заблокировал(а) %@"; +"notice_room_withdraw" = "%@ отозвал(а) приглашение %@"; +"notice_room_reason" = ". Причина: %@"; +"notice_avatar_url_changed" = "%@ изменил(а) свой аватар"; +"notice_display_name_set" = "%@ сделал(а) своим отображаемым именем %@"; +"notice_display_name_changed_from" = "%@ изменил(а) свое отображаемое имя с %@ на %@"; +"notice_display_name_removed" = "%@ удалил(а) свое отображаемое имя"; +"notice_topic_changed" = "%@ изменил(а) тему на \"%@\"."; +"notice_room_name_changed" = "%@ изменил(а) название комнаты на %@."; +"notice_placed_voice_call" = "%@ совершил(а) голосовой вызов"; +"notice_placed_video_call" = "%@ совершил(а) видео вызов"; +"notice_answered_video_call" = "%@ ответил(а) на вызов"; +"notice_ended_video_call" = "%@ завершил(а) вызов"; +"notice_conference_call_request" = "%@ запросил(а) голосовую конференцию"; +"notice_conference_call_started" = "голосовая конференция началась"; +"notice_conference_call_finished" = "голосовая конференция завершилась"; +// button names +"ok" = "OK"; +"cancel" = "Отмена"; +"save" = "Сохранить"; +"leave" = "Покинуть"; +"send" = "Отправить"; +"copy_button_name" = "Скопировать"; +"resend" = "Переотправить"; +"redact" = "Удалить"; +"share" = "Поделиться"; +"set_power_level" = "Установить уровень мощности"; +"delete" = "Удалить"; +// actions +"action_logout" = "Выйти"; +"create_room" = "Создать комнату"; +"login" = "Войти"; +"create_account" = "Создать аккаунт"; +"membership_invite" = "Приглашен"; +"membership_leave" = "Покинул"; +"membership_ban" = "Заблокирован"; +"num_members_one" = "%@ пользователь"; +"num_members_other" = "%@ пользователей"; +"invite" = "Пригласить"; +"kick" = "Выкинуть"; +"ban" = "Заблокировать"; +"unban" = "Разблокировать"; +// Room creation +"room_creation_name_title" = "Название комнаты:"; +"login_use_fallback" = "Использовать резервную страницу"; +"login_error_do_not_support_login_flows" = "В настоящее время мы не поддерживаем потоки авторизации, определенных данным домашним сервером"; +"login_error_forgot_password_is_not_supported" = "\"Забытый пароль\" в настоящее время не поддерживается"; +"notice_event_redacted" = "<отредактировано%@>"; +"notice_event_redacted_by" = " от %@"; +"notice_room_join_rule" = "Правило присоединения: %@"; +"device_details_delete_prompt_message" = "Для этой операции требуется дополнительная аутентификация.\nЧтобы продолжить, введите свой пароль."; +// Encryption information +"room_event_encryption_info_title" = "Сведения о сквозном шифровании\n\n"; +"room_event_encryption_info_event_identity_key" = "Ключ идентификации Curve25519\n"; +"room_event_encryption_info_event_fingerprint_key" = "Требуемый ключ цифрового отпечатка Ed25519\n"; +"room_event_encryption_info_event_none" = "никого нет"; +"room_event_encryption_info_device_id" = "Идентификатор устройства\n"; +"room_event_encryption_info_device_verification" = "Верификация\n"; +"room_event_encryption_info_device_fingerprint" = "Отпечаток Ed25519\n"; +"room_event_encryption_info_device_verified" = "Подтверждено"; +"room_event_encryption_info_device_not_verified" = "НЕ подтверждено"; +"room_event_encryption_info_device_blocked" = "В черном списке"; +"room_event_encryption_info_verify" = "Проверка..."; +"room_event_encryption_info_unverify" = "Отменить верификацию"; +"room_event_encryption_info_block" = "Черный список"; +"room_event_encryption_info_unblock" = "Удалить из черного списка"; +"room_event_encryption_verify_title" = "Проверить сессию\n\n"; +"room_event_encryption_verify_message" = "Для верификации сессии, пожалуйста, свяжитесь с владельцем используя другие методы коммуникации (например, лично или по телефону) и попросите его подтвердить, что он видит такой же ключ как написанный ниже:\n\n\tИмя сессии: %@\n\tИдентификатор сессии: %@\n\tКлюч сессии: %@\n\nЕсли совпадают, то нажмите кнопку верификации ниже. Если нет, значит кто-то перехватил эту сессию и вы, скорее всего, захотите внести его в черный список.\n\nВ будущем процесс верификации будет усложнен."; +"room_event_encryption_verify_ok" = "Подтвердить"; +// Account +"account_save_changes" = "Сохранить изменения"; +"account_linked_emails" = "Связанные адреса электронной почты"; +"account_link_email" = "Связанный адрес электронной почты"; +"account_email_validation_title" = "Ожидание проверки"; +"account_email_validation_message" = "Проверьте свою электронную почту и нажмите на содержащуюся ссылку. После этого нажмите кнопку Продолжить."; +"account_email_validation_error" = "Не удалось проверить адрес электронной почты. Проверьте свою электронную почту и нажмите на содержащуюся ссылку. Когда это будет сделано, нажмите Продолжить"; +"account_msisdn_validation_title" = "Ожидание проверки"; +"account_msisdn_validation_message" = "Мы отправили SMS с кодом активации. Введите этот код в поле ниже."; +"account_msisdn_validation_error" = "Не удалось проверить номер телефона."; +"account_error_display_name_change_failed" = "Не удалось изменить отображаемое имя"; +"account_error_picture_change_failed" = "Не удалось изменить аватар"; +"account_error_matrix_session_is_not_opened" = "Сессия Matrix не открыта"; +"account_error_email_wrong_title" = "Неверный адрес электронной почты"; +"account_error_email_wrong_description" = "Похоже это недействительный адрес электронной почты"; +"account_error_msisdn_wrong_title" = "Некорректный номер телефона"; +"account_error_msisdn_wrong_description" = "Это недействительный номер телефона"; +"room_creation_name_placeholder" = "(напр. lunchGroup)"; +"room_creation_alias_title" = "Псевдоним комнаты:"; +"room_creation_alias_placeholder" = "(напр. #foo:primer.ru)"; +"room_creation_alias_placeholder_with_homeserver" = "(напр. #foo%@)"; +"room_creation_participants_placeholder" = "(напр. @boris:homeserver1; @ivan:homeserver2...)"; +"room_error_join_failed_empty_room" = "В настоящее время невозможно присоединиться в пустую комнату."; +"room_error_name_edition_not_authorized" = "У вас нет прав на редактирование названия этой комнаты"; +"room_error_topic_edition_not_authorized" = "У вас нет прав редактировать тему этой комнаты"; +"room_error_cannot_load_timeline" = "Не удалось загрузить хронологию"; +"room_error_timeline_event_not_found_title" = "Не удалось загрузить метку из хронологии"; +"room_error_timeline_event_not_found" = "Приложение пыталось загрузить конкретную позицию хронологии этой комнаты, но не смогло ее найти"; +"room_no_power_to_create_conference_call" = "Вы должны иметь право выдачи приглашений, чтобы начать конференцию в этой комнате"; +"room_no_conference_call_in_encrypted_rooms" = "Групповые вызовы не поддерживаются в зашифрованных комнатах"; +// Attachment +"attachment_size_prompt" = "Вы хотите отправить как:"; +"attachment_original" = "Фактический размер (%@)"; +"attachment_small" = "Маленький (%@)"; +"attachment_medium" = "Средний (%@)"; +"attachment_large" = "Большой (%@)"; +"attachment_cancel_download" = "Отменить загрузку?"; +"attachment_cancel_upload" = "Отменить отправку?"; +"attachment_multiselection_size_prompt" = "Вы хотите отправить изображения как:"; +"attachment_multiselection_original" = "Фактический размер"; +"attachment_e2e_keys_file_prompt" = "Этот файл содержит ключи шифрования, экспортированные из клиента Matrix.\nВы хотите просмотреть содержимое файла или импортировать содержащиеся в нем ключи?"; +"attachment_e2e_keys_import" = "Импорт..."; +// Contacts +"contact_mx_users" = "Пользователи Matrix"; +"contact_local_contacts" = "Локальные контакты"; +// Search +"search_no_results" = "Нет результатов"; +// Time +"format_time_s" = "с"; +"format_time_m" = "м"; +"format_time_h" = "ч"; +"format_time_d" = "д"; +// E2E import +"e2e_import_room_keys" = "Импорт ключей комнаты"; +"e2e_import_prompt" = "Этот процесс позволит вам импортировать ключи шифрования, которые вы экспортировали ранее из клиента Matrix. Это позволит вам расшифровать историю чата.\nФайл защищен парольной фразой. Введите ее для расшифровки файла."; +"e2e_import" = "Импорт"; +"e2e_passphrase_enter" = "Введите парольную фразу"; +// E2E export +"e2e_export_room_keys" = "Экспорт ключей комнаты"; +"notice_feedback" = "Обратная связь (id: %@): %@"; +"e2e_export_prompt" = "Этот процесс позволяет вам экспортировать ключи для сообщений, которые вы получили в комнатах с шифрованием, в локальный файл. Вы сможете импортировать эти ключи в другой клиент Matrix чтобы расшифровать эти сообщения.\nЭкспортированный файл позволит любому пользователю расшифровать и зашифровать сообщения, которые вы видите, поэтому вы должны быть крайне осторожны и держать файл в надежном месте."; +"e2e_export" = "Экспорт"; +"e2e_passphrase_confirm" = "Подтвердить парольную фразу"; +"e2e_passphrase_empty" = "Парольная фраза не может быть пустой"; +"e2e_passphrase_not_match" = "Парольные фразы должны совпадать"; +// Others +"user_id_title" = "Идентификатор пользователя:"; +"offline" = "не в сети"; +"unsent" = "Не отправлено"; +"error" = "Ошибка"; +"not_supported_yet" = "Пока не поддерживается"; +"default" = "по умолчанию"; +"network_error_not_reachable" = "Проверьте подключение к сети"; +"user_id_placeholder" = "напр.: @boris:homeserver"; +"ssl_homeserver_url" = "URL-адрес домашнего сервера: %@"; +// Permissions +"camera_access_not_granted_for_call" = "Для видеозвонков требуется доступ к камере, но %@ не имеет разрешения на ее использование"; +"microphone_access_not_granted_for_call" = "Для звонков требуется доступ к микрофону, но %@ не имеет разрешения на его использование"; +"local_contacts_access_not_granted" = "Поиск пользователей из локальных контактов требует доступа к вашим контактам, но %@ не имеет разрешения на их использование"; +"local_contacts_access_discovery_warning_title" = "Обнаружение пользователей"; +"local_contacts_access_discovery_warning" = "Чтобы обнаружить контакты, уже использующие Matrix, %@ может отправлять адреса электронной почты и номера телефонов в вашей адресной книге на выбранный вами сервер идентификации Matrix. Там, где это поддерживается, личные данные перед отправкой хэшируются - пожалуйста, ознакомьтесь с политикой конфиденциальности вашего сервера идентификации для более подробной информации."; +"notice_room_invite" = "%@ пригласил(а) %@"; +"notice_room_third_party_invite" = "%@ отправил(а) приглашение для %@ войти в комнату"; +"notice_room_third_party_registered_invite" = "%@ принял(а) приглашение от %@"; +"notice_room_aliases" = "Псевдонимы комнаты: %@"; +"private" = "Приватный"; +"public" = "Публичный"; +"view" = "Просмотр"; +"message_unsaved_changes" = "Имеются несохраненные изменения. Они будут потеряны."; +// Login Screen +"login_error_already_logged_in" = "Уже вошли"; +"login_error_must_start_http" = "URL-адрес должен начинаться с http[s]://"; +// room details dialog screen +"room_details_title" = "Информация о комнате"; +// contacts list screen +"invitation_message" = "Я бы хотел поговорить с вами в Matrix. Пожалуйста, посетите веб-сайт https://matrix.org для получения дополнительной информации."; +// Settings screen +"settings_title_config" = "Конфигурация"; +"settings_title_notifications" = "Уведомления"; +// Notification settings screen +"notification_settings_disable_all" = "Отключить все уведомления"; +"notification_settings_enable_notifications" = "Включить уведомления"; +"notification_settings_enable_notifications_warning" = "Все уведомления отключены для всех устройств."; +"notification_settings_global_info" = "Настройки уведомлений сохраняются в учетной записи и распространяются на все клиенты, которые их поддерживают (включая настольный компьютер).\n\nПравила применяются по порядку; будет использовано первое подходящее.\nТаким образом: уведомления для сообщений приоритетней уведомлений для комнат, которые, в свою очередь, приоритетней уведомлений для отправителей.\nДля нескольких правил одинакового типа будет использован первый по счету."; +"notification_settings_always_notify" = "Всегда уведомлять"; +"notification_settings_never_notify" = "Никогда не уведомлять"; +"notification_settings_word_to_match" = "соответствие слов"; +"notification_settings_custom_sound" = "Пользовательский звук"; +"notification_settings_sender_hint" = "@user:domain.com"; +"notification_settings_select_room" = "Выберите комнату"; +"notification_settings_other_alerts" = "Другие предупреждения"; +"notification_settings_contain_my_user_name" = "Уведомлять звуком о сообщениях, содержащих мое имя пользователя"; +"notification_settings_contain_my_display_name" = "Уведомлять звуком о сообщениях, содержащих мое отображаемое имя"; +"notification_settings_just_sent_to_me" = "Уведомлять звуком о сообщениях, отправленных только мне"; +"notification_settings_invite_to_a_new_room" = "Уведомлять меня о приглашении в новую комнату"; +"notification_settings_people_join_leave_rooms" = "Уведомлять, когда пользователи заходят или выходят из комнат"; +"notification_settings_receive_a_call" = "Уведомлять о получении звонка"; +"notification_settings_suppress_from_bots" = "Подавлять уведомления от ботов"; +"notification_settings_by_default" = "По умолчанию..."; +"notification_settings_notify_all_other" = "Уведомлять о всех других сообщениях/комнатах"; +// gcm section +"settings_config_home_server" = "Домашний сервер: %@"; +"settings_config_identity_server" = "Сервер идентификации: %@"; +"settings_config_user_id" = "Идентификатор пользователя: %@"; +// call string +"call_waiting" = "Ожидание..."; +"call_connecting" = "Соединение…"; +"call_ended" = "Вызов завершен"; +"call_ring" = "Вызов..."; +"incoming_video_call" = "Входящий видеовызов"; +"incoming_voice_call" = "Входящий голосовой вызов"; +"call_invite_expired" = "Срок действия приглашения на звонок истек"; +// unrecognized SSL certificate +"ssl_trust" = "Доверять"; +"ssl_logout_account" = "Выход"; +"ssl_remain_offline" = "Игнорировать"; +"ssl_could_not_verify" = "Не удалось проверить подлинность удаленного сервера."; +"ssl_cert_not_trust" = "Это может означать, что кто-то злонамеренно перехватывает ваш трафик или что ваш телефон не доверяет сертификату, предоставленному удаленным сервером."; +"ssl_unexpected_existing_expl" = "Сертификат изменился у пользователя, который был доверенным для вашего устройства. Это ОЧЕНЬ НЕОБЫЧНО. Рекомендуется НЕ ПРИНИМАТЬ новый сертификат."; +"ssl_only_accept" = "Примите сертификат ТОЛЬКО в том случае, если администратор сервера опубликовал отпечаток, соответствующий приведенному выше."; +"ssl_fingerprint_hash" = "Отпечаток (%@):"; +"notification_settings_per_word_notifications" = "Уведомления по одному слову"; +"notification_settings_per_word_info" = "Слова нечувствительны к регистру и могут содержать * спецсимвол. Так:\nFoo соответствует строке foo, окруженной разделителями слов (например, пунктуацией и пробелом или началом/концом строки).\nFoo * соответствует любому такому слову, которое начинается с foo.\n* Foo * соответствует любому такому слову, которое включает в себя 3 буквы foo."; +"notification_settings_highlight" = "Подсветка"; +"notification_settings_per_room_notifications" = "Уведомления для каждой комнаты"; +"notification_settings_per_sender_notifications" = "Уведомления для отдельного собеседника"; +"ssl_cert_new_account_expl" = "Если администратор сервера сказал, что это ожидается, убедитесь, что отпечаток ниже соответствует предоставленному им отпечатку."; +"ssl_expected_existing_expl" = "Сертификат изменился с ранее доверенного на один, которому не доверяют. Возможно, сервер обновил свой сертификат. Обратитесь к администратору сервера за ожидаемым отпечатком."; +"search_searching" = "Выполняется поиск..."; +"login_mobile_device" = "Мобильное устройство"; +"login_tablet_device" = "Планшет"; +"login_desktop_device" = "Компьютер"; +"notice_room_related_groups" = "Группы, связанные с этой комнатой: %@"; +// Groups +"group_invite_section" = "Приглашения"; +"group_section" = "Группы"; +"notice_sticker" = "стикер"; +"notice_in_reply_to" = "В ответ на"; +"error_common_message" = "Произошла ошибка. Пожалуйста, повторите попытку позже."; +// Reply to message +"message_reply_to_sender_sent_an_image" = "отправил(а) изображение."; +"message_reply_to_sender_sent_a_video" = "отправил(а) видео."; +"message_reply_to_sender_sent_an_audio_file" = "отправил(а) аудиофайл."; +"message_reply_to_sender_sent_a_file" = "отправил(а) файл."; +"message_reply_to_message_to_reply_to_prefix" = "В ответ на"; +"login_error_resource_limit_exceeded_title" = "Превышен лимит ресурса"; +"login_error_resource_limit_exceeded_contact_button" = "Связаться с администратором"; +"login_error_resource_limit_exceeded_message_default" = "Этот сервер превысил один из лимитов ресурсов."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Этот сервер достиг ежемесячного лимита активных пользователей."; +"login_error_resource_limit_exceeded_message_contact" = "\n\nПожалуйста свяжитесь с вашим администратором что бы продолжить пользоваться этим сервисом."; +"e2e_passphrase_create" = "Создать парольную фразу"; +"account_error_push_not_allowed" = "Уведомления не разрешены"; +"notice_room_third_party_revoked_invite" = "%@ отозвал приглашение для %@ на вступление в комнату"; +"device_details_rename_prompt_title" = "Имя Сессии"; +"notice_encryption_enabled_ok" = "%@ включил сквозное шифрование."; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ включил сквозное шифрование (нераспознанный алгоритм %2$@)."; +// Notice Events with "You" +"notice_room_invite_by_you" = "Вы пригласили %@"; +"notice_room_invite_you" = "%@ пригласил Вас"; +"notice_room_third_party_invite_by_you" = "Вы отправили приглашение %@ вступить в комнату"; +"notice_room_third_party_registered_invite_by_you" = "Вы приняли приглашение для @%"; +"notice_room_third_party_revoked_invite_by_you" = "Вы отозвали приглашение для %@ вступить в комнату"; +"notice_room_join_by_you" = "Вы вошли"; +"notice_room_leave_by_you" = "Вы вышли"; +"notice_room_reject_by_you" = "Вы отклонили приглашение"; +"notice_room_kick_by_you" = "Вы исключили %@"; +"notice_room_unban_by_you" = "Вы разбанили %@"; +"notice_room_ban_by_you" = "Вы забанили %@"; +"notice_avatar_url_changed_by_you" = "Вы сменили свой аватар"; +"notice_display_name_set_by_you" = "Вы установили своё отображаемое имя как %@"; +"notice_display_name_changed_from_by_you" = "Вы сменили своё отображаемое имя с %@ на %@"; +"notice_display_name_removed_by_you" = "Вы удалили своё отображаемое имя"; +"notice_topic_changed_by_you" = "Вы сменили тему на \"%@\"."; +"notice_room_name_changed_by_you" = "Вы сменили имя комнаты на %@."; +"notice_room_withdraw_by_you" = "Вы отозвали приглашение %@"; +"notice_placed_voice_call_by_you" = "Вы начали звонок"; +"notice_placed_video_call_by_you" = "Вы начали видеозвонок"; +"notice_answered_video_call_by_you" = "Вы ответили на звонок"; +"notice_ended_video_call_by_you" = "Вы закончили звонок"; +"notice_conference_call_request_by_you" = "Вы запросили VoIP конференцию"; +"notice_room_name_removed_by_you" = "Вы удалили название комнаты"; +"notice_room_topic_removed_by_you" = "Вы удалили эту тему"; +"notice_event_redacted_by_you" = " вами"; +"notice_profile_change_redacted_by_you" = "Вы обновили свой профиль %@"; +"notice_room_created_by_you" = "Вы создали и настроили комнату."; +"notice_encryption_enabled_ok_by_you" = "Вы активировали сквозное шифрование."; +"notice_encryption_enabled_unknown_algorithm_by_you" = "Вы активировали сквозное шифрование (неопознанный алгоритм %@)."; +"notice_redaction_by_you" = "Вы отредактировали событие (id: %@)"; +"notice_room_history_visible_to_anyone_by_you" = "Вы сделали будущую историю комнаты видимой для всех."; +"notice_room_history_visible_to_members_by_you" = "Вы сделали будущую историю комнаты видимой для всех членов комнаты."; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "Вы сделали будущую историю комнаты видимой для всех членов комнаты, с того момента, как они приглашены."; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "Вы сделали будущую историю комнаты видимой для всех членов комнаты, с того момента, как они присоединились."; +// New +"notice_room_join_rule_invite" = "%@ сделал(а) комнату доступной только по приглашению."; +"notice_room_join_rule_invite_by_you" = "Вы сделали комнату только по приглашению."; +"notice_room_join_rule_public" = "%@ сделал(а) комнату публичной."; +"notice_room_join_rule_public_by_you" = "Вы сделали комнату публичной."; +"notice_room_name_removed_for_dm" = "%@ удалил(а) название"; +"notice_room_created_for_dm" = "%@ вошёл(ла)."; +"notice_room_join_rule_invite_for_dm" = "%@ сделал(а) доступ только по приглашению."; +"notice_room_join_rule_invite_by_you_for_dm" = "Вы сделали доступ только по приглашению."; +"notice_room_join_rule_public_for_dm" = "%@ сделал(а) комнату публичной."; +"notice_room_join_rule_public_by_you_for_dm" = "Вы сделали комнату публичной."; +"notice_room_power_level_intro_for_dm" = "Уровень доступа членов комнаты:"; +"notice_room_aliases_for_dm" = "Псевдонимы: %@"; +"notice_room_history_visible_to_members_for_dm" = "%@ сделал(а) будущую историю сообщений видимой всем членам комнаты."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ сделал(а) будущие сообщения видимыми для всех с момента их приглашения."; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ сделал(а) будущие сообщения видимыми для всех с момента их присоединения."; +"room_left_for_dm" = "Вы вышли"; +"notice_room_third_party_invite_for_dm" = "%@ пригласил(а) %@"; +"notice_room_third_party_revoked_invite_for_dm" = "%@ отозвал(а) приглашение %@"; +"notice_room_name_changed_for_dm" = "%@ изменил(а) название на %@."; +"notice_room_third_party_invite_by_you_for_dm" = "Вы пригласили %@"; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "Вы отозвали приглашение %@"; +"notice_room_name_changed_by_you_for_dm" = "Вы сменили название на %@."; +"notice_room_name_removed_by_you_for_dm" = "Вы удалили название"; +"notice_room_created_by_you_for_dm" = "Вы вошли."; +"notice_room_history_visible_to_members_by_you_for_dm" = "Вы сделали будущие сообщения видимыми для всех участников комнаты."; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "Вы сделали будущие сообщения видимыми для всех с момента их приглашения."; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "Вы сделали будущие сообщения видимыми для всех с момента их присоединения."; +"call_more_actions_dialpad" = "Панель набора номера"; +"call_more_actions_transfer" = "Перевод"; +"call_more_actions_audio_use_device" = "Динамик устройства"; +"call_more_actions_audio_use_headset" = "Использовать звук гарнитуры"; +"call_more_actions_change_audio_device" = "Сменить аудиоустройство"; +"call_more_actions_unhold" = "Возобновить"; +"call_more_actions_hold" = "Удерживать"; +"call_holded" = "Вы поставили вызов на удержание"; +"call_remote_holded" = "%@ поставил(а) вызов на удержание"; +"notice_declined_video_call_by_you" = "Вы отменили вызов"; +"notice_declined_video_call" = "%@ отменил(а) этот вызов"; +"resume_call" = "Возобновить"; +"call_transfer_to_user" = "Передача с %@"; +"call_consulting_with_user" = "Консультация с %@"; +"call_video_with_user" = "Видеовызов с %@"; +"call_voice_with_user" = "Голосовой вызов с %@"; +"call_ringing" = "Звонок…"; +"microphone_access_not_granted_for_voice_message" = "Голосовые сообщения требуют доступа к микрофону, но у %@ нет разрешения на его использование"; +"e2e_passphrase_too_short" = "Слишком короткая парольная фраза (Длина парольной фразы должна быть не менее %d символов)"; +"message_reply_to_sender_sent_a_voice_message" = "отправил(а) голосовое сообщение."; +"attachment_large_with_resolution" = "Большой %@ (~%@)"; +"attachment_medium_with_resolution" = "Средний %@ (~%@)"; +"attachment_small_with_resolution" = "Маленький %@ (~%@)"; +"attachment_size_prompt_message" = "Это можно отключить в настройках."; +"attachment_size_prompt_title" = "Подтвердите размер для отправки"; +"auth_reset_password_error_not_found" = "Не найдено"; +"auth_reset_password_error_unauthorized" = "Неавторизованный"; +"auth_invalid_user_name" = "Недопустимое имя пользователя"; +"auth_username_in_use" = "Имя пользователя занято"; +"rename" = "Переименовать"; +"room_displayname_all_other_members_left" = "%@ (Вышел)"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/si.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/si.lproj/MatrixKit.strings new file mode 100644 index 000000000..5efc3e3d5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/si.lproj/MatrixKit.strings @@ -0,0 +1,25 @@ + + +"invite" = "ආරාධනා"; +"settings_title_notifications" = "දැනුම්දීම්"; +"notification_settings_sender_hint" = "@පරිශීලක:වසම.ලංකා"; +"login_password_placeholder" = "මුරපදය"; +"login_leave_fallback" = "අවලංගු කරන්න"; +"login_email_placeholder" = "වි-තැපැල් ලිපිනය"; +"login_identity_server_title" = "අනන්‍යතා සේවාදායකයේ ඒ.ස.නි.:"; +"login_home_server_info" = "ඔබගේ මූලික සේවාදායකය ඔබගේ සියලු සංවාද සහ ගිණුමේ දත්ත ගබඩා කරයි"; +"login_home_server_title" = "මූලික සේවාදායකයේ ඒ.ස.නි.:"; +"login_server_url_placeholder" = "ඒ.ස.නි.(URL) (උදා. https://matrix.org)"; + +// Login Screen +"login_create_account" = "ගිණුම සාදන්න:"; +/* *********************** */ +/* iOS specific */ +/* *********************** */ + +"matrix" = "මැට්‍රික්ස්"; +"save" = "සුරකින්න"; +"cancel" = "අවලංගු කරන්න"; +"leave" = "හැරයන්න"; +"continue" = "ඉදිරියට"; +"back" = "ආපසු"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/sq.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/sq.lproj/MatrixKit.strings new file mode 100644 index 000000000..48323eea3 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/sq.lproj/MatrixKit.strings @@ -0,0 +1,477 @@ +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "Krijo llogari:"; +"login_server_url_placeholder" = "URL (p.sh. https://matrix.org)"; +"login_home_server_title" = "URL Shërbyesi Home:"; +"login_identity_server_title" = "URL Shërbyesi Identitetesh:"; +"login_password_placeholder" = "Fjalëkalim"; +"login_optional_field" = "opsionale"; +"login_email_placeholder" = "Adresë email"; +"login_prompt_email_token" = "Ju lutemi, jepni token-in tuaj të vleftësimit të email-it:"; +"login_error_title" = "Dështoi Hyrja"; +"login_error_registration_is_not_supported" = "Hëpërhë regjistrimet nuk mbulohen"; +"login_error_forbidden" = "Emër përdoruesi/fjalëkalim i pavlefshëm"; +"login_error_unknown_token" = "Token-i i hyrjeve i dhënë nuk u njoh"; +"login_error_bad_json" = "JSON e keqformuar"; +"login_error_not_json" = "S’përmbante JSON të vlefshëm"; +"login_error_user_in_use" = "Ky emër përdoruesi është i përdorur tashmë"; +"login_use_fallback" = "Përdor faqe fallback"; +"login_leave_fallback" = "Anuloje"; +"login_invalid_param" = "Parametër i pavlefshëm"; +"register_error_title" = "Regjistrimi Dështoi"; +"login_error_forgot_password_is_not_supported" = "Harrimi i fjalëkalimeve hëpërhë s’mbulohet"; +"login_mobile_device" = "Celular"; +"login_tablet_device" = "Tablet"; +"login_desktop_device" = "Desktop"; +// Action +"no" = "Jo"; +"yes" = "Po"; +"abort" = "Ndërprite"; +"back" = "Mbrapsht"; +"close" = "Mbylle"; +"continue" = "Vazhdo"; +"discard" = "Hidhe Tej"; +"dismiss" = "Mos e merr parasysh"; +"retry" = "Riprovo"; +"sign_up" = "Regjistrohuni"; +"submit" = "Parashtroje"; +"set_default_power_level" = "Rikthe Shkallët e Pushtetit Te Parazgjedhja"; +"set_moderator" = "Caktojeni Moderator"; +"set_admin" = "Caktojeni Përgjegjës"; +"start_chat" = "Filloni Fjalosje"; +"start_voice_call" = "Nis Thirrje Audio"; +"start_video_call" = "Nis Thirrje Video"; +"mention" = "Përmendje"; +"select_account" = "Përzgjidhni një llogari"; +"attach_media" = "Bashkëngjitni media nga Mediateka"; +"capture_media" = "Bëni Foto/Video"; +"invite_user" = "Ftoni Përdorues matrix"; +"reset_to_default" = "Riktheje te parazgjedhjet"; +"resend_message" = "Ridërgoje mesazhin"; +"select_all" = "Përzgjidhe Krejt"; +"cancel_upload" = "Anuloje Ngarkimin"; +"cancel_download" = "Anuloje Shkarkimin"; +"show_details" = "Shfaq Hollësi"; +"answer_call" = "Përgjigjuni Thirrjes"; +"reject_call" = "Hidhe poshtë Thirrjen"; +"end_call" = "Përfundoje Thirrjen"; +"ignore" = "Shpërfille"; +"unignore" = "Hiqja shpërfilljen"; +// Events formatter +"notice_avatar_changed_too" = "(u ndryshua edhe avatari)"; +"notice_room_name_removed" = "%@ hoqi emrin e dhomës"; +"notice_room_topic_removed" = "%@ hoqi temën"; +"notice_event_redacted" = ""; +"notice_event_redacted_by" = " nga %@"; +"notice_event_redacted_reason" = " [arsye: %@]"; +"notice_profile_change_redacted" = "%@ përditësoi profilin e vet %@"; +"notice_room_created" = "%@ krijoi dhe formësoi dhomën."; +"notice_room_join_rule" = "Rregulli për pjesëmarrje është: %@"; +"notice_room_power_level_intro" = "Shkallët e pushtetit të anëtarëve të dhomës janë:"; +"notice_room_aliases" = "Aliaset e dhomës janë: %@"; +"notice_room_related_groups" = "Grupet përshoqëruar kësaj dhome janë: %@"; +"notice_encrypted_message" = "Mesazhi i fshehtëzuar"; +"notice_encryption_enabled" = "%@ aktivizoi fshehtëzimin skaj-më-skaj (algoritëm %@)"; +"notice_image_attachment" = "bashkëngjitje figurash"; +"notice_audio_attachment" = "bashkëngjitje audio"; +"notice_video_attachment" = "bashkëngjitje videosh"; +"notice_file_attachment" = "bashkëngjitje kartelash"; +"notice_invalid_attachment" = "bashkëngjitje e pavlefshme"; +"notice_unsupported_attachment" = "Bashkëngjitje e pambuluar: %@"; +"notice_error_unsupported_event" = "Akt i pambuluar"; +"notice_error_unexpected_event" = "Akt i papritur"; +"notice_error_unknown_event_type" = "Lloj i panjohur akti"; +"notice_crypto_unable_to_decrypt" = "** S’arrihet të shfshehtëzohet: %@ **"; +// room display name +"room_displayname_empty_room" = "Dhomë e zbrazët"; +"room_displayname_more_than_two_members" = "%@ dhe %@ të tjerë"; +// Settings +"settings" = "Rregullime"; +"settings_enable_inapp_notifications" = "Aktivizoni njoftime Aplikacioni"; +"settings_enable_push_notifications" = "Aktivizoni njoftime push"; +"settings_enter_validation_token_for" = "Jepni token vleftësimi për %@:"; +"notification_settings_room_rule_title" = "Dhomë: '%@'"; +// Devices +"device_details_title" = "Të dhëna sesioni\n"; +"device_details_name" = "Emër Publik\n"; +"device_details_identifier" = "ID\n"; +"device_details_last_seen" = "Parë së fundi më\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_rename_prompt_message" = "Emri publik i një sesioni është i dukshëm për persona me të cilët komunikoni"; +"device_details_delete_prompt_title" = "Mirëfilltësim"; +// Encryption information +"room_event_encryption_info_title" = "Të dhëna fshehtëzimi skaj-më-skaj\n\n"; +"room_event_encryption_info_event" = "Të dhëna akti\n"; +"room_event_encryption_info_event_user_id" = "ID Përdoruesi\n"; +"room_event_encryption_info_event_identity_key" = "Kyç identiteti Curve25519\n"; +"room_event_encryption_info_event_algorithm" = "Algoritëm\n"; +"room_event_encryption_info_event_session_id" = "ID sesioni\n"; +"room_event_encryption_info_event_decryption_error" = "Gabim shfshehtëzimi\n"; +"room_event_encryption_info_event_unencrypted" = "të pafshehtëzuara"; +"room_event_encryption_info_event_none" = "asnjë"; +"room_event_encryption_info_device" = "\nTë dhëna sesioni dërguesi\n"; +"room_event_encryption_info_device_unknown" = "sesion i panjohur\n"; +"room_event_encryption_info_device_name" = "Emër Publik\n"; +"room_event_encryption_info_device_id" = "ID\n"; +"room_event_encryption_info_device_verification" = "Verifikim\n"; +"room_event_encryption_info_device_fingerprint" = "Shenja gishtash Ed25519\n"; +"room_event_encryption_info_device_verified" = "E verifikuar"; +"room_event_encryption_info_device_not_verified" = "JO e verifikuar"; +"room_event_encryption_info_device_blocked" = "Në Listë të Zezë"; +"room_event_encryption_info_verify" = "Verifikoni…"; +"room_event_encryption_info_block" = "Listë e zezë"; +"room_event_encryption_verify_title" = "Verifiko sesionin\n\n"; +"room_event_encryption_verify_ok" = "Verifikoje"; +// Account +"account_save_changes" = "Ruaji ndryshimet"; +"account_link_email" = "Lidhni Email"; +"account_linked_emails" = "Email-e të lidhur"; +"account_email_validation_title" = "Verifikim Në Pritje të Miratimit"; +"account_msisdn_validation_title" = "Verifikim Në Pritje të Miratimit"; +"account_msisdn_validation_error" = "S’arrihet të verifikohet numër telefoni."; +"account_error_display_name_change_failed" = "Ndryshimi i emrit të shfaqjes dështoi"; +"account_error_picture_change_failed" = "Ndryshimi i fotos dështoi"; +"account_error_matrix_session_is_not_opened" = "Sesioni Matrix s’është hapur"; +"account_error_email_wrong_title" = "Adresë Email e Pavlefshme"; +"account_error_email_wrong_description" = "Kjo s’duket se është adresë email e vlefshme"; +"account_error_msisdn_wrong_title" = "Numër Telefoni i Pavlefshëm"; +"account_error_msisdn_wrong_description" = "Ky s’duket të jetë numër telefoni i vlefshëm"; +// Room creation +"room_creation_name_title" = "Emër dhome:"; +"room_creation_name_placeholder" = "(p.sh., Grupiiçajit)"; +"room_creation_alias_title" = "Alias dhome:"; +"room_creation_alias_placeholder" = "(p.sh. #kot:shembull.org)"; +"room_creation_alias_placeholder_with_homeserver" = "(p.sh. #kot%@)"; +"room_creation_participants_title" = "Pjesëmarrës:"; +// Room +"room_please_select" = "Ju lutemi, përzgjidhni një dhomë"; +"room_error_join_failed_title" = "S’u arrit të hyhej në dhomë"; +"room_error_name_edition_not_authorized" = "S’jeni i autorizuar të përpunoni emrin e kësaj dhome"; +"room_error_topic_edition_not_authorized" = "S’jeni i autorizuar ta përpunoni temën e kësaj dhome"; +"room_error_cannot_load_timeline" = "S’u arrit të ngarkohej rrjedha kohore"; +"room_error_timeline_event_not_found_title" = "S’u arrit të ngarkohej pozicion rrjedhe kohore"; +"room_left" = "Dolët prej dhomës"; +// Attachment +"attachment_size_prompt" = "Doni të dërgohet si:"; +"attachment_original" = "Madhësi Faktike (%@)"; +"attachment_small" = "E vogël (~ %@)"; +"attachment_medium" = "Mesatare (~%@)"; +"attachment_large" = "E madhe (~ %@)"; +"attachment_cancel_download" = "Të anulohet shkarkimi?"; +"attachment_cancel_upload" = "Të anulohet ngarkimin?"; +"attachment_multiselection_size_prompt" = "Doni të dërgoni figura si:"; +"attachment_multiselection_original" = "Madhësi Faktike"; +"attachment_e2e_keys_import" = "Importoni…"; +// Contacts +"contact_mx_users" = "Përdorues të Matrix-it"; +"contact_local_contacts" = "Kontakte Vendore"; +// Groups +"group_invite_section" = "Ftesa"; +"group_section" = "Grupe"; +// Search +"search_no_results" = "S’ka Përfundime"; +"search_searching" = "Kërkim në ecuri e sipër…"; +// Time +"format_time_s" = "s"; +"format_time_m" = "m"; +"format_time_h" = "h"; +"format_time_d" = "d"; +// E2E import +"e2e_import_room_keys" = "Importo kyçe dhome"; +"e2e_import" = "Importo"; +"e2e_passphrase_enter" = "Jepni frazëkalimin"; +// E2E export +"e2e_export_room_keys" = "Eksporto kyçe dhome"; +"e2e_export" = "Eksporto"; +"e2e_passphrase_confirm" = "Ripohoni frazëkalimin"; +"e2e_passphrase_empty" = "Frazëkalimi s’duhet të jetë i zbrazët"; +"e2e_passphrase_not_match" = "Frazëkalimet duhet të përputhen"; +// Others +"user_id_title" = "ID Përdoruesi:"; +"offline" = "jo në linjë"; +"error" = "Gabim"; +"not_supported_yet" = "S’mbulohen ende"; +"default" = "parazgjedhje"; +"private" = "Private"; +"public" = "Publike"; +"power_level" = "Shkallë Pushteti"; +"user_id_placeholder" = "p.sh.: @beni:homeserver"; +"ssl_homeserver_url" = "URL Shërbyesi Home: %@"; +"local_contacts_access_discovery_warning_title" = "Zbulim përdoruesish"; +// Country picker +"country_picker_title" = "Zgjidhni një vend"; +// Language picker +"language_picker_title" = "Zgjidhni gjuhë"; +"language_picker_default_language" = "Parazgjedhje (%@)"; +"notice_room_invite" = "%@ ftoi %@"; +"notice_room_third_party_invite" = "%@ dërgoi një ftesë për %@ që të vijë te dhoma"; +"notice_room_third_party_registered_invite" = "%@ e pranoi ftesën për %@"; +"notice_room_join" = "%@ u bë pjesë"; +"notice_room_leave" = "%@ doli"; +"notice_room_reject" = "%@ hodhi tej ftesën"; +"notice_room_kick" = "%@ përzuri %@"; +"notice_room_ban" = "%@ dëboi %@"; +"notice_room_withdraw" = "%@ e tërhoqi ftesën për %@"; +"notice_room_reason" = ". Arsye: %@"; +"notice_avatar_url_changed" = "%@ ndryshoi avatarin e vet"; +"notice_display_name_set" = "%@ caktoi emrin e vet të ekranit si %@"; +"notice_display_name_changed_from" = "%@ ndryshoi emrin e vet të ekranit nga %@ në %@"; +"notice_display_name_removed" = "%@ hoqi emrin e vet të ekranit"; +"notice_topic_changed" = "%@ ndryshoi temën në \"%@\"."; +"notice_room_name_changed" = "%@ ndryshoi emrin e dhomës në %@."; +"notice_placed_voice_call" = "%@ bëri një thirrje zanore"; +"notice_placed_video_call" = "%@ bëri një thirrje video"; +"notice_answered_video_call" = "%@ iu përgjigj thirrjes"; +"notice_ended_video_call" = "%@ e përfundoi thirrjen"; +"notice_conference_call_request" = "%@ kërkoi një konferencë VoIP"; +"notice_conference_call_started" = "Konferenca VoIP filloi"; +"notice_conference_call_finished" = "Konferenca VoIP përfundoi"; +// button names +"ok" = "OK"; +"cancel" = "Anuloje"; +"save" = "Ruaje"; +"leave" = "Dilni"; +"send" = "Dërgoje"; +"copy_button_name" = "Kopjoje"; +"resend" = "Ridërgoje"; +"set_power_level" = "Caktoni Shkallë Pushteti"; +"delete" = "Fshije"; +"view" = "Shiheni"; +// actions +"action_logout" = "Dalje"; +"create_room" = "Krijo Dhomë"; +"login" = "Hyrje"; +"create_account" = "Krijo Llogari"; +"membership_invite" = "I ftuar"; +"membership_leave" = "I ikur"; +"membership_ban" = "I dëbuar"; +"num_members_one" = "%@ përdorues"; +"num_members_other" = "%@ përdorues"; +"invite" = "Ftoje"; +"kick" = "Përzëre"; +"ban" = "Dëboje"; +"unban" = "Hiqja dëbimin"; +"message_unsaved_changes" = "Ka ndryshime të paruajtura. Ikja do të shkaktojë hedhjen tej të tyre."; +// Login Screen +"login_error_already_logged_in" = "Tashmë i futur"; +"login_error_must_start_http" = "URL-ja duhet të fillojë me http[s]://"; +// room details dialog screen +"room_details_title" = "Hollësi Dhome"; +// Settings screen +"settings_title_config" = "Formësim"; +"settings_title_notifications" = "Njoftime"; +// Notification settings screen +"notification_settings_disable_all" = "Çaktivizoji krejt njoftimet"; +"notification_settings_enable_notifications" = "Aktivizo njoftimet"; +"notification_settings_enable_notifications_warning" = "Krejt njoftimet hëpërhë janë çaktivizuar për krejt pajisjet."; +"notification_settings_always_notify" = "Njoftomë përherë"; +"notification_settings_never_notify" = "Mos njofto kurrë"; +"notification_settings_word_to_match" = "fjalë për përputhje"; +"notification_settings_highlight" = "Theksoje"; +"notification_settings_custom_sound" = "Tingull vetjak"; +"notification_settings_sender_hint" = "@përdorues:përkatësi.com"; +"notification_settings_select_room" = "Përzgjidhni një dhomë"; +"notification_settings_other_alerts" = "Sinjalizime të Tjera"; +"notification_settings_contain_my_user_name" = "Njoftomë me tingull mbi mesazhe që përmbajnë emrin tim"; +"notification_settings_contain_my_display_name" = "Njoftomë me tingull mbi mesazhe që përmbajnë emrin tim të shfaqjes"; +"notification_settings_just_sent_to_me" = "Njoftomë me tingull mbi mesazhe dërguar vetëm për mua"; +"notification_settings_suppress_from_bots" = "Ndaloji njoftimet nga robotë"; +"notification_settings_by_default" = "Si parazgjedhje…"; +"notification_settings_notify_all_other" = "Njoftim për krejt mesazhet/dhomat e tjera"; +// gcm section +"settings_config_home_server" = "Shërbyes home: %@"; +"settings_config_identity_server" = "Shërbyes identitetesh: %@"; +"settings_config_user_id" = "ID Përdoruesi: %@"; +// call string +"call_waiting" = "Po pritet…"; +"call_ended" = "Thirrja përfundoi"; +"call_ring" = "Po thirret…"; +"incoming_video_call" = "Thirrje Video Ardhëse"; +"incoming_voice_call" = "Thirrje Audio Ardhëse"; +"call_invite_expired" = "Ftesa Për Thirrje Skadoi"; +// unrecognized SSL certificate +"ssl_trust" = "Besoje"; +"ssl_logout_account" = "Dalje"; +"ssl_remain_offline" = "Shpërfille"; +"ssl_fingerprint_hash" = "Shenja Gishtash (%@):"; +"ssl_could_not_verify" = "S’u verifikua dot identiteti i shërbyesit të largët."; +"submit_code" = "Parashtroni kod"; +"notice_location_attachment" = "bashkëngjitje vendndodhjeje"; +"notice_redaction" = "%@ përpunoi një veprimtari (id: %@)"; +"notice_sticker" = "ngjitës"; +"unsent" = "Të padërguar"; +"network_error_not_reachable" = "Ju lutemi, kontrolloni aftësinë e lidhjes në rrjetin tuaj"; +"share" = "Ndajeni me të tjerët"; +"login_home_server_info" = "Shërbyesi juaj home depoziton krejt të dhënat e bisedave dhe llogarive tuaja"; +"login_identity_server_info" = "Matrix ofron shërbyes identiteti për të ndjekur se cilat email-e, etj, u përkasin ID-ve Matrix IDs. Hëpërhë ekziston vetëm https://matrix.org."; +"login_user_id_placeholder" = "ID Matrix (p.sh. @poku:matrix.org ose poku)"; +"login_display_name_placeholder" = "Emër në ekran (p.sh. Mane Trimi)"; +"login_email_info" = "Përcaktimi i një adrese email i lejon përdoruesit e tjerë t’ju gjejnë më lehtë në Matrix, dhe do t’ju japë një rrugë për ricaktimin e fjalëkalimit tuaj në të ardhmen."; +"login_error_no_login_flow" = "Dështuam në marrje të dhënash mirëfilltësimi nga ky Shërbyes Home"; +"login_error_do_not_support_login_flows" = "Hëpërhë nuk mbulojmë ndonjë ose krejt rrjedhat e hyrjeve të përkufizuara nga ky Shërbyes Home"; +"login_error_limit_exceeded" = "Janë dërguar shumë kërkesa"; +"login_error_login_email_not_yet" = "Lidhja email që s’është klikuar ende"; +"notice_room_power_level_acting_requirement" = "Shkallët minimum të pushtetit që duhet të ketë një përdorues përpara se të veprojë, janë:"; +"notice_room_power_level_event_requirement" = "Shkallët minimum të pushtetit që lidhen me aktet janë:"; +"notice_room_history_visible_to_anyone" = "%@ e bëri historikun e ardhshëm të dhomës të dukshëm për këdo."; +"notice_room_history_visible_to_members" = "%@ e bëri historikun e ardhshëm të dhomës të dukshëm për krejt anëtarët e dhomës."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ e bëri historikun e ardhshëm të dhomës të dukshëm për krejt anëtarët e dhomës, prej çastit kur janë ftuar."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ e bëri historikun e ardhshëm të dhomës të dukshëm për krejt anëtarët e dhomës, prej çastit kur morën pjesë."; +"notice_crypto_error_unknown_inbound_session_id" = "Sesioni i dërguesit nuk na ka dërguar kyçet për këtë mesazh."; +"notice_in_reply_to" = "Në përgjigje të"; +"device_details_delete_prompt_message" = "Ky veprim lyp mirëfilltësim shtesë.\nQë të vazhdohet, ju lutemi, jepni fjalëkalimin tuaj."; +"room_event_encryption_info_unverify" = "Hiqi verifikimin"; +"room_event_encryption_info_unblock" = "Hiqe nga listë e zezë"; +"room_event_encryption_verify_message" = "Që të verifikohet se ky sesion mund të besohet, ju lutemi, lidhuni me të zotin e tij duke përdorur ndonjë rrugë tjetër (p.sh., personalisht apo përmes një thirrjeje telefonike) dhe kërkojini nëse kyçi që sheh te Rregullimet e veta të Përdoruesit për këtë sesion përputhet me kyçin më poshtë:\n\n\tEmër sesioni: %@\n\tID sesioni: %@\n\tKyç sesioni: %@\n\nNëse përputhet, shtypni më poshtë butonin e verifikimit. Nëse jo, atëherë dikush tjetër është duke përgjuar këtë pajisje dhe do të donit më mirë të shtypnit butonin e kalimit në listë të zezë.\n\nNë të ardhmen ky proces verifikimi do të jetë më i sofistikuar."; +"account_email_validation_message" = "Ju lutemi, kontrolloni email-in tuaj dhe klikoni mbi lidhjen që përmban. Pasi të jetë bërë kjo, klikoni që të vazhdohet."; +"account_email_validation_error" = "S’arrihet të verifikohet adresë email. Ju lutemi, kontrolloni email-in tuaj dhe klikoni mbi lidhjen që përmban. Pasi të jetë bërë kjo, klikoni që të vazhdohet"; +"account_msisdn_validation_message" = "Kemi dërguar një SMS me një kod aktivizimi. Ju lutemi, jepeni këtë kod më poshtë."; +"room_creation_participants_placeholder" = "(p.sh. @mane:homeserver1; @taku:homeserver2...)"; +"room_error_join_failed_empty_room" = "Hëpërhë s’është e mundur të hyhet në një dhomë të zbrazët."; +"room_error_timeline_event_not_found" = "Aplikacioni u rrek të ngarkonte një pikë të dhënë prej rrjedhës kohore në këtë dhomë, por s’qe në gjendje ta gjente"; +"room_no_power_to_create_conference_call" = "Ju duhen leje për ftesa, që të nisni një konferencë në këtë dhomë"; +"room_no_conference_call_in_encrypted_rooms" = "Thirrjet konferencë nuk mbulohen në dhoma të fshehtëzuara"; +// Room members +"room_member_ignore_prompt" = "Doni të fshihen krejt mesazhet nga ky përdorues?"; +"room_member_power_level_prompt" = "S’do të jeni në gjendje ta zhbëni këtë ndryshim, ngaqë po e promovoni përdoruesin të ketë të njëjtën shkallë pushteti si ju vetë.\nJeni i sigurt?"; +"attachment_e2e_keys_file_prompt" = "Kjo kartelë përmban kyçe fshehtëzimi të eksportur nga një klient Matrix.\nDoni të shihni lëndën e kartelës apo të importoni kyçet që ajo përmban?"; +"e2e_import_prompt" = "Ky proces ju lejon të importoni kyçe fshehtëzimi që keni eksportuar më parë nga një tjetër klient Matrix. Mandej do të jeni në gjendje të shfshehtëzoni çfarëdo mesazhesh që mund të shfshehtëzojë ai klient tjetër.\nKartela e eksportit është e mbrojtur me një frazëkalim. Që të shfshehtëzoni kartelën, duhet ta jepni frazëkalimin këtu."; +"e2e_export_prompt" = "Ky proces ju lejon të eksportoni te një kartelë vendore kyçet për mesazhe që keni marrë në dhoma të fshehtëzuara. Mandej do të jeni në gjendje ta importoni kartelën te një tjetër klient Matrix në të ardhmen, që kështu ai klient të jetë në gjendje t’i fshehtëzojë këto mesazhe.\nKartela e eksportuar do t’i lejojë, cilitdo që mund ta lexojë, të shfshehtëzojë çfarëdo mesazhesh të fshehtëzuar që mund të shihni ju, ndaj duhet të bëni kujdes ta mbani të parrezikuar."; +"error_common_message" = "Ndodhi një gabim. Ju lutemi, riprovoni më vonë."; +// Permissions +"camera_access_not_granted_for_call" = "Thirrjet video lypin përdorim të Kamerës, por %@ s’ka leje për ta përdorur"; +"microphone_access_not_granted_for_call" = "Thirrjet lypin përdorim të Mikrofonit, por %@ s’ka leje ta përdorë atë"; +"local_contacts_access_not_granted" = "Zbulimi i përdoruesve nga kontaktet vendore lyp hyrje te kontaktet tuaja, por %@ s’ka leje t’i përdorë ato"; +"local_contacts_access_discovery_warning" = "Që të mund të zbulojë kontakte që përdorin tashmë Matrix-in, %@ mund të dërgojë adresa email dhe numra telefonash nga libri juaj i adresave te shërbyesi juaj i zgjedhur i identiteteve Matrix. Kur kjo mbulohet, të dhënat personale fshehtëzohen, përpara dërgimit - për më tepër hollësi, ju lutemi kontrolloni rregulla privatësie të shërbyesit tuaj të identiteteve."; +"notice_room_unban" = "%@ i hoqi dëbimin %@"; +"redact" = "Hiqe"; +// contacts list screen +"invitation_message" = "Do të doja të bisedoja me ju me Matrix. Për të pasur më tepër itë dhëna, ju lutem, vizitoni sajtin http://matrix.org."; +"notification_settings_global_info" = "Rregullimet mbi njoftimet ruhen te llogaria juaj e përdoruesit dhe ndahen me krejt klientët që i mbulojnë ato (përfshi njoftimet në desktop).\n\nRregullat zbatohen sipas një radhe; rregulli i parë që ka përputhje përcakton lëndën për mesazhin.\nKështu: njoftimet sipas fjalësh janë më të rëndësishme se njoftimet sipas dhomash të cilat janë më të rëndësishme se njoftimet sipas dërguesish.\nFor multiple rules of the same kind, the first one in the list that matches takes priority."; +"notification_settings_per_word_notifications" = "Njoftime sipas fjale"; +"notification_settings_per_word_info" = "Për fjalët përputhjet gjenden pa marrë parasysh shkrimin me të madhe apo të vogël, dhe mund të përfshijnë një shenjë të gjithëpushtetshme *. Kështu:\nkot përputhet me vargun kot të rrethuar nga përkufizues fjalësh (p.sh. shenja pikësimi apo hapësira, ose fillim/fund rreshti).\nkot* përputhet me çfarëdo fjale që fillon me kot.\n*kot* përputhet me çfarëdo fjale që përfshin 3 shkronjat kot."; +"notification_settings_per_room_notifications" = "Njoftime sipas dhome"; +"notification_settings_per_sender_notifications" = "Njoftime sipas dërguesi"; +"notification_settings_invite_to_a_new_room" = "Njoftomë kur ftohem në një dhomë të re"; +"notification_settings_people_join_leave_rooms" = "Njoftomë kur vijnë ose ikin persona nga dhoma"; +"notification_settings_receive_a_call" = "Njoftomë kur marr një thirrje"; +"call_connecting" = "Po lidhet…"; +"ssl_cert_not_trust" = "Kjo mund të ishte shenjë se dikush po përgjon me dashakeqësi trafikun tuaj, ose se telefoni juaj nuk i beson dëshmisë së furnizuar nga shërbyesi i largët."; +"ssl_cert_new_account_expl" = "Nëse përgjegjësi i shërbyesit ka thënë se kjo është e pritshme, sigurohuni që shenjat e gishtave më poshtë përputhen me shenjat e gishtave të furnizuara prej tyre."; +"ssl_unexpected_existing_expl" = "Dëshmia ka ndryshuar nga ajo që qe besuar nga telefoni juaj. Kjo është SHUMË E PAZAKONTË. Këshillohet që TË MOS E PRANONI këtë dëshmi të re."; +"ssl_expected_existing_expl" = "Dëshmia ka ndryshuar nga një e besueshme dikur në një që nuk besohet. Shërbyesi mund të ketë rinovuar dëshminë e tij. Lidhuni me përgjegjësin e shërbyesit për shenjat e pritshme të gishtave."; +"ssl_only_accept" = "Pranojeni dëshminë VETËM nëse përgjegjësi i shërbyesit ka publikuar shenja gishtash që përputhen me ato më sipër."; +"login_error_resource_limit_exceeded_title" = "U tejkalua Kufi Burimesh"; +"login_error_resource_limit_exceeded_message_default" = "Ky shërbyes home ka tejkaluar një nga kufijtë mbi burimet."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Ky shërbyes home ka tejkaluar kufirin Përdorues Aktivë Mujorë."; +"login_error_resource_limit_exceeded_message_contact" = "\n\nJu lutemi, që të vazhdoni të përdorni këtë shërbim, lidhuni me përgjegjësin e shërbimit tuaj."; +"login_error_resource_limit_exceeded_contact_button" = "Lidhuni Me Përgjegjësin"; +"room_displayname_two_members" = "%@ dhe %@"; +// Reply to message +"message_reply_to_sender_sent_an_image" = "dërgoi një figurë."; +"message_reply_to_sender_sent_a_video" = "dërgoi një video."; +"message_reply_to_sender_sent_an_audio_file" = "dërgoi një kartelë audio."; +"message_reply_to_sender_sent_a_file" = "dërgoi një kartelë."; +"message_reply_to_message_to_reply_to_prefix" = "Në përgjigje të"; +"e2e_passphrase_create" = "Krijoni frazëkalim"; +"room_event_encryption_info_event_fingerprint_key" = "U pretendua për kyç Ed25519 shenjash gishtash\n"; +"notice_feedback" = "Akt dhënieje përshtypjesh (id: %@): %@"; +"account_error_push_not_allowed" = "Nuk lejohen njoftime"; +"notice_room_third_party_revoked_invite" = "%@ shfuqizoi ftesën për pjesëmarrje në dhomë për %@"; +"device_details_rename_prompt_title" = "Emër Sesioni"; +"notice_encryption_enabled_ok" = "%@ aktivizoi fshehtëzimin skaj-më-skaj."; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ aktivizoi fshehtëzimin skaj-më-skaj (algoritëm që s’është njohur %2$@)."; +// Notice Events with "You" +"notice_room_invite_by_you" = "Ftuat %@"; +"notice_room_invite_you" = "Ju ftoi %@"; +"notice_room_third_party_invite_by_you" = "I dërguat %@ një ftesë të marrë pjesë te dhoma"; +"notice_room_third_party_registered_invite_by_you" = "Pranuat ftesën nga %@"; +"notice_room_third_party_revoked_invite_by_you" = "Shfuqizuat ftesën për ardhje në dhomë për %@"; +"notice_room_join_by_you" = "Erdhët"; +"notice_room_leave_by_you" = "Ikët"; +"notice_room_reject_by_you" = "Hodhët poshtë ftesën"; +"notice_room_kick_by_you" = "Përzutë %@"; +"notice_room_unban_by_you" = "Hoqët dëbimin për %@"; +"notice_room_ban_by_you" = "Dëbuat %@"; +"notice_room_withdraw_by_you" = "Tërhoqët mbrapsht ftesën për %@"; +"notice_avatar_url_changed_by_you" = "Ndryshuat avatarin tuaj"; +"notice_display_name_set_by_you" = "Caktuat si emrin tuaj në ekran %@"; +"notice_display_name_changed_from_by_you" = "Ndryshuat emrin tuaj në ekran nga %@ në %@"; +"notice_display_name_removed_by_you" = "Hoqët emrin tuaj në ekran"; +"notice_topic_changed_by_you" = "E ndryshuat temën në \"%@\"."; +"notice_room_name_changed_by_you" = "E ndryshuat emrin e dhomës në \"%@\"."; +"notice_placed_voice_call_by_you" = "Bëtë një thirrje zanore"; +"notice_placed_video_call_by_you" = "Bëtë një thirrje video"; +"notice_answered_video_call_by_you" = "Iu përgjigjët thirrjes"; +"notice_ended_video_call_by_you" = "E përfunduat thirrjen"; +"notice_conference_call_request_by_you" = "Kërkuat një konferencë VoIP"; +"notice_room_name_removed_by_you" = "Hoqët emrin e dhomës"; +"notice_room_topic_removed_by_you" = "Hoqët temën"; +"notice_event_redacted_by_you" = " nga ju"; +"notice_profile_change_redacted_by_you" = "Përditësuat profilin tuaj %@"; +"notice_room_created_by_you" = "Krijuat dhe formësuat dhomën."; +"notice_encryption_enabled_ok_by_you" = "Aktivizuat fshehtëzimin skaj-më-skaj."; +"notice_encryption_enabled_unknown_algorithm_by_you" = "Aktivizuat fshehtëzim skaj-më-skaj (algoritëm %@ i papranuar)."; +"notice_redaction_by_you" = "Redaktuat një akt (id: %@)"; +"notice_room_history_visible_to_anyone_by_you" = "E bëtë historikun e ardhshëm të dhomës të dukshëm për këdo."; +"notice_room_history_visible_to_members_by_you" = "E bëtë historikun e ardhshëm të dhomës të dukshëm për krejt anëtarët e dhomës."; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "E bëtë historikun e ardhshëm të dhomës të dukshëm për krejt anëtarët e dhomës, nga çasti që janë ftuar."; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "E bëtë historikun e ardhshëm të dhomës të dukshëm për krejt anëtarët e dhomës, që nga çasti që bëhen pjesë e dhomës."; +// New +"notice_room_join_rule_invite" = "%@ e bëri dhomën vetëm me ftesa."; +"notice_room_join_rule_invite_by_you" = "E bëtë dhomën vetëm me ftesa."; +"notice_room_join_rule_public" = "%@ e bëri dhomën publike."; +"notice_room_join_rule_public_by_you" = "E bëtë dhomën publike."; +"notice_room_name_removed_for_dm" = "%@ hoqi emrin"; +"notice_room_created_for_dm" = "%@ hyri."; +"notice_room_join_rule_invite_for_dm" = "%@ e bëri këtë “vetëm me ftesa”."; +"notice_room_join_rule_invite_by_you_for_dm" = "E bëtë këtë “vetëm me ftesa”."; +"notice_room_join_rule_public_for_dm" = "%@ e bëri këtë publike."; +"notice_room_join_rule_public_by_you_for_dm" = "E bëtë këtë publike."; +"notice_room_power_level_intro_for_dm" = "Shkallë pushteti që kanë anëtarët:"; +"notice_room_aliases_for_dm" = "Aliaset janë: %@"; +"notice_room_history_visible_to_members_for_dm" = "%@ i bëri mesazhet e ardhshëm të dukshëm për krejt anëtarët e dhomës."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ i bëri mesazhet e ardhshëm të dukshëm për këdo, që nga çasti që ftohen."; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ i bëri mesazhet e ardhshëm të dukshëm për këdo, që nga çasti bëhen pjesë e bisedës."; +"room_left_for_dm" = "Dolët"; +"notice_room_third_party_invite_for_dm" = "%@ ftoi %@"; +"notice_room_third_party_revoked_invite_for_dm" = "%@ shfuqizoi ftesën për %@"; +"notice_room_name_changed_for_dm" = "%@ ndryshoi emrin në %@."; +"notice_room_third_party_invite_by_you_for_dm" = "Ftuat %@"; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "Shfuqizuar ftesën për %@"; +"notice_room_name_changed_by_you_for_dm" = "Ndryshuat emrin në %@."; +"notice_room_name_removed_by_you_for_dm" = "Hoqët emrin"; +"notice_room_created_by_you_for_dm" = "Hytë."; +"notice_room_history_visible_to_members_by_you_for_dm" = "I bëtë mesazhet e ardhshëm të dukshëm për krejt anëtarët e dhomës."; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "I bëtë mesazhet e ardhshëm të dukshëm për këdo, nga çasti që ftohen."; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "I bëtë mesazhet e ardhshëm të dukshëm për këdo, nga çasti që hyjnë në bisedë."; +"resume_call" = "Rimerre"; +"call_holded" = "E mbajtët pezull thirrjen"; +"notice_declined_video_call" = "%@ e hodhi poshtë thirrjen"; +"notice_declined_video_call_by_you" = "E hodhët poshtë thirrjen"; +"call_remote_holded" = "%@ e mbajti pezull thirrjen"; +"call_more_actions_hold" = "Mbaje pezull"; +"call_more_actions_unhold" = "Rimerre"; +"call_more_actions_change_audio_device" = "Ndryshoni Pajisje Audio"; +"call_more_actions_audio_use_headset" = "Përdorni Kufje dhe Mikrofon"; +"call_more_actions_audio_use_device" = "Altoparlant Pajisjeje"; +"call_more_actions_transfer" = "Shpërngule"; +"call_more_actions_dialpad" = "Tastierë numerike"; +"call_transfer_to_user" = "Shpërngulje te %@"; +"call_consulting_with_user" = "Konsultim me %@"; +"call_video_with_user" = "Thirrje me video me %@"; +"call_voice_with_user" = "Thirrje me zë me %@"; +"call_ringing" = "Po i bihet ziles…"; +"e2e_passphrase_too_short" = "Frazëkalim shumë i shkurtër (Duhet të jetë e pakta %d shenja i gjatë)"; +"microphone_access_not_granted_for_voice_message" = "Mesazhet zanorë lypin përdorim të Mikrofonit, por %@ s’ka leje përdorimi të tij"; +"message_reply_to_sender_sent_a_voice_message" = "dërgoi një mesazh zanor."; +"attachment_large_with_resolution" = "E madhe %@ (~%@)"; +"attachment_medium_with_resolution" = "Mesatare %@ (~%@)"; +"attachment_small_with_resolution" = "E vogël %@ (~%@)"; +"attachment_size_prompt_message" = "Këtë mund ta çaktivizoni te rregullimet."; +"attachment_size_prompt_title" = "Ripohoni madhësi për dërgim"; +"auth_reset_password_error_not_found" = "S’u gjet"; +"auth_reset_password_error_unauthorized" = "I paautorizuar"; +"auth_username_in_use" = "Emër përdoruesi i përdorur"; +"auth_invalid_user_name" = "Emër i pavlefshëm përdoruesi"; +"rename" = "Riemërtojeni"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/sv.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/sv.lproj/MatrixKit.strings new file mode 100644 index 000000000..317d36810 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/sv.lproj/MatrixKit.strings @@ -0,0 +1,472 @@ +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "Skapa konto:"; +"login_server_url_placeholder" = "URL (t.ex. https://matrix.org)"; +"login_home_server_title" = "Hemserver-URL:"; +"login_home_server_info" = "Din hemserver lagrar alla dina konversationer och din kontodata"; +"login_identity_server_title" = "Identitetsserver-URL:"; +"login_identity_server_info" = "Matrix tillhandahåller identitetsservrar för att spåra vilka e-postadresser o.s.v. som tillhör vilka Matrix-ID:n. Endast https://matrix.org finns för närvarande."; +"login_user_id_placeholder" = "Matrix-ID (t.ex. @bob:matrix.org eller bob)"; +"login_password_placeholder" = "Lösenord"; +"login_optional_field" = "valfritt"; +"login_display_name_placeholder" = "Visningsnamn (t.ex. Sven Svensson)"; +"login_email_info" = "Genom att ange en e-postadress kan andra användare hitta dig på Matrix lättare, och det ger dig ett sätt att återställa ditt lösenord i framtiden."; +"login_email_placeholder" = "E-postadress"; +"login_prompt_email_token" = "Ange din e-postvalideringstoken:"; +"login_error_title" = "Inloggning misslyckades"; +"login_error_no_login_flow" = "Vi misslyckades att hämta autentiseringsinformation från den här hemservern"; +"login_error_do_not_support_login_flows" = "För närvarande stöder vi inte några eller alla inloggningsflöden som har definierats av den här hemservern"; +"login_error_registration_is_not_supported" = "Registrering stöds inte för närvarande"; +"login_error_forbidden" = "Ogiltigt användarnamn eller lösenord"; +"login_error_unknown_token" = "Den åtkomsttoken som specificerades kändes inte igen"; +"login_error_bad_json" = "Felformaterad JSON"; +"login_error_not_json" = "Innehöll inte giltig JSON"; +"login_error_limit_exceeded" = "För många förfrågningar har skickats"; +"login_error_user_in_use" = "Det här användarnamnet har redan använts"; +"login_error_login_email_not_yet" = "E-postlänken har inte klickats än"; +"login_use_fallback" = "Använd reservsida"; +"login_leave_fallback" = "Avbryt"; +"login_invalid_param" = "Ogiltig parameter"; +"register_error_title" = "Registrering misslyckades"; +"login_error_forgot_password_is_not_supported" = "Lösenordsåterställning stöds ännu inte"; +"login_mobile_device" = "Mobil"; +"login_tablet_device" = "Surfplatta"; +"login_desktop_device" = "Skrivbord"; +"login_error_resource_limit_exceeded_title" = "Resursgräns överskriden"; +"login_error_resource_limit_exceeded_message_default" = "Hemservern har överskridit en av sina resursgränser."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Den här hemservern har nått sin gräns för aktiva användare per månad."; +"login_error_resource_limit_exceeded_message_contact" = "\n\nVänligen kontakta din tjänstadministratör för att fortsätta använda den här tjänsten."; +"login_error_resource_limit_exceeded_contact_button" = "Kontakta administratör"; +// Action +"no" = "Nej"; +"yes" = "Ja"; +"abort" = "Avbryt"; +"back" = "Tillbaka"; +"close" = "Stäng"; +"continue" = "Fortsätt"; +"discard" = "Släng"; +"dismiss" = "Avvisa"; +"retry" = "Försök igen"; +"sign_up" = "Bli medlem"; +"submit" = "Skicka"; +"submit_code" = "Skicka kod"; +"set_power_level" = "Sätt behörighetsnivå"; +"set_default_power_level" = "Återställ behörighetsnivå"; +"set_moderator" = "Sätt till moderator"; +"set_admin" = "Sätt till administratör"; +"start_chat" = "Starta chatt"; +"start_voice_call" = "Starta röstsamtal"; +"start_video_call" = "Starta videosamtal"; +"mention" = "Nämn"; +"select_account" = "Välj ett konto"; +"attach_media" = "Bifoga media från biblioteket"; +"capture_media" = "Ta bild/video"; +"invite_user" = "Bjud in Matrixanvändare"; +"reset_to_default" = "Återställ till standard"; +"resend_message" = "Skicka meddelandet igen"; +"select_all" = "Välj allt"; +"cancel_upload" = "Avbryt uppladdning"; +"cancel_download" = "Avbryt nedladdning"; +"show_details" = "Visa detaljer"; +"answer_call" = "Svara på samtal"; +"reject_call" = "Avvisa samtal"; +"end_call" = "Avsluta samtal"; +"ignore" = "Ignorera"; +"unignore" = "Avignorera"; +// Events formatter +"notice_avatar_changed_too" = "(avataren ändrades också)"; +"notice_room_name_removed" = "%@ tog bort rumsnamnet"; +"notice_room_topic_removed" = "%@ tog bort ämnet"; +"notice_event_redacted" = ""; +"notice_event_redacted_by" = " av %@"; +"notice_event_redacted_reason" = " [anledning: %@]"; +"notice_profile_change_redacted" = "%@ uppdaterade sin profil %@"; +"notice_room_created" = "%@ skapade och konfigurerade rummet."; +"notice_room_join_rule" = "Regeln för att gå med är: %@"; +"notice_room_power_level_intro" = "Behörighetsnivå för rumsmedlemmar är:"; +"notice_room_power_level_acting_requirement" = "Den minimala behörighetsnivån en användare behöver har innan den kan göra något är:"; +"notice_room_power_level_event_requirement" = "Den minimala behörighetsnivån relaterad till händelser är:"; +"notice_room_aliases" = "Rumsaliasen är: %@"; +"notice_room_related_groups" = "Grupperna associerade med det här rummet är: %@"; +"notice_encrypted_message" = "Krypterat meddelande"; +"notice_encryption_enabled_ok" = "%@ aktiverade totalsträckskryptering."; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ aktiverade totalsträckskryptering (okänd algoritm %2$@)."; +"notice_image_attachment" = "bildbilaga"; +"notice_audio_attachment" = "ljudbilaga"; +"notice_video_attachment" = "videobilaga"; +"notice_location_attachment" = "positionsbilaga"; +"notice_file_attachment" = "filbilaga"; +"notice_invalid_attachment" = "ogiltig bilaga"; +"notice_unsupported_attachment" = "Ostödd bilaga: %@"; +"notice_feedback" = "Återkopplingshändelse (id: %@): %@"; +"notice_redaction" = "%@ dolde en händelse (id: %@)"; +"notice_error_unsupported_event" = "Ostödd händelse"; +"notice_error_unexpected_event" = "Oväntad händelse"; +"notice_error_unknown_event_type" = "Okänd händelsetyp"; +"notice_room_history_visible_to_anyone" = "%@ gjorde framtida rumshistorik synlig för alla."; +"notice_room_history_visible_to_members" = "%@ gjorde framtida rumshistorik synlig för alla rumsmedlemmar."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ gjorde framtida rumshistorik synlig för alla rumsmedlemmar från när de bjöds in."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ gjorde framtida rumshistorik synlig för alla rumsmedlemmar från när de gick med."; +"notice_crypto_unable_to_decrypt" = "** Kunde inte avkryptera: %@ **"; +"notice_crypto_error_unknown_inbound_session_id" = "Avsändarens session har inte gett oss nycklarna för det här meddelandet."; +"notice_sticker" = "dekal"; +"notice_in_reply_to" = "Svar på"; +// room display name +"room_displayname_empty_room" = "Tomt rum"; +"room_displayname_two_members" = "%@ och %@"; +"room_displayname_more_than_two_members" = "%@ och %@ till"; +// Settings +"settings" = "Inställningar"; +"settings_enable_inapp_notifications" = "Aktivera aviseringar i appen"; +"settings_enable_push_notifications" = "Aktivera pushnotiser"; +"settings_enter_validation_token_for" = "Ange valideringstoken för &@:"; +"notification_settings_room_rule_title" = "Rum: '%@'"; +// Devices +"device_details_title" = "Sessionsinformation\n"; +"device_details_name" = "Offentligt namn\n"; +"device_details_identifier" = "ID\n"; +"device_details_last_seen" = "Senast sedd\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_rename_prompt_title" = "Sessionsnamn"; +"device_details_rename_prompt_message" = "En sessions offentliga namn är synligt för personer du kommunicerar med"; +"device_details_delete_prompt_title" = "Autentisering"; +"device_details_delete_prompt_message" = "Den här handlingen kräver ytterligare autentisering.\nFör att fortsätta, vänligen ange ditt lösenord."; +// Encryption information +"room_event_encryption_info_title" = "Totalsträckskrypteringsinformation\n\n"; +"room_event_encryption_info_event" = "Händelseinformation\n"; +"room_event_encryption_info_event_user_id" = "Användar-ID\n"; +"room_event_encryption_info_event_identity_key" = "Curve25519-identitetsnyckel\n"; +"room_event_encryption_info_event_fingerprint_key" = "Hävdad Ed25519-fingeravtrycksnyckel\n"; +"room_event_encryption_info_event_algorithm" = "Algoritm\n"; +"room_event_encryption_info_event_session_id" = "Sessions-ID\n"; +"room_event_encryption_info_event_decryption_error" = "Avkrypteringsfel\n"; +"room_event_encryption_info_event_unencrypted" = "okrypterad"; +"room_event_encryption_info_event_none" = "ingen"; +"room_event_encryption_info_device" = "\nAvsändarens sessionsinformation\n"; +"room_event_encryption_info_device_unknown" = "Okänd session\n"; +"room_event_encryption_info_device_name" = "Offentligt namn\n"; +"room_event_encryption_info_device_id" = "ID\n"; +"room_event_encryption_info_device_verification" = "Verifiering\n"; +"room_event_encryption_info_device_fingerprint" = "Ed25519-fingeravtryck\n"; +"room_event_encryption_info_device_verified" = "Verifierad"; +"room_event_encryption_info_device_not_verified" = "INTE verifierad"; +"room_event_encryption_info_device_blocked" = "Svartlistad"; +"room_event_encryption_info_verify" = "Verifiera…"; +"room_event_encryption_info_unverify" = "Avverifiera"; +"room_event_encryption_info_block" = "Svartlista"; +"room_event_encryption_info_unblock" = "Avsvartlista"; +"room_event_encryption_verify_title" = "Verifiera session\n\n"; +"room_event_encryption_verify_message" = "För att verifiera att denna session går att lita på, vänligen kontakta ägaren på annat sätt (t.ex. personligen eller med ett telefonsamtal) och fråga dem om nyckeln de ser i sina användarinställningar för den här sessionen matchar nyckeln nedan:\n\n\tSessionsnamn: %@\n\tSessions-ID: %@\n\tSessionsnyckel: %@\n\nOm de matchar, tryck på verifieringsknappen nedan. Om de inte gör det så betyder det att någon annan snappar upp denna session och du vill antagligen trycka på svartlistknappen istället.\n\nI framtiden kommer denna verifieringsprocess att bli mer sofistikerad."; +"room_event_encryption_verify_ok" = "Verifiera"; +// Account +"account_save_changes" = "Spara ändringar"; +"account_link_email" = "Länka e-post"; +"account_linked_emails" = "Länkade e-postadresser"; +"account_email_validation_title" = "Avvaktar verifiering"; +"account_email_validation_message" = "Vänligen kolla din e-post och klicka på länken den innehåller. När detta är klart, klicka fortsätt."; +"account_email_validation_error" = "Kunde inte verifiera e-postadressen. Kontrollera din e-post och klicka på länken den innehåller. När detta är klart, klicka på fortsätt"; +"account_msisdn_validation_title" = "Avvaktar verifiering"; +"account_msisdn_validation_message" = "Vi har skickat ett SMS med en aktiveringskod. Ange den koden nedan."; +"account_msisdn_validation_error" = "Kunde inte verifiera telefonnummer."; +"account_error_display_name_change_failed" = "Byte av visningsnamn misslyckades"; +"account_error_picture_change_failed" = "Byte av bild misslyckades"; +"account_error_matrix_session_is_not_opened" = "Matrixsession har inte öppnats"; +"account_error_email_wrong_title" = "Ogiltig e-postadress"; +"account_error_email_wrong_description" = "Det här ser inte ut som en giltig e-postadress"; +"account_error_msisdn_wrong_title" = "Ogiltigt telefonnummer"; +"account_error_msisdn_wrong_description" = "Det här ser inte ut som ett giltigt telefonnummer"; +"account_error_push_not_allowed" = "Aviseringar tillåts inte"; +// Room creation +"room_creation_name_title" = "Rumsnamn:"; +"room_creation_name_placeholder" = "(t.ex. lunchgrupp)"; +"room_creation_alias_title" = "Rumsalias:"; +"room_creation_alias_placeholder" = "(t.ex. #foo:exempel.org)"; +"room_creation_alias_placeholder_with_homeserver" = "(t.ex. #foo%@)"; +"room_creation_participants_title" = "Deltagare:"; +"room_creation_participants_placeholder" = "(t.ex. @sven:hemserver1; @anna:hemserver2…)"; +// Room +"room_please_select" = "Vänligen välj ett rum"; +"room_error_join_failed_title" = "Misslyckades att gå med i rum"; +"room_error_join_failed_empty_room" = "Det går för närvarande inte att gå med i ett tomt rum."; +"room_error_name_edition_not_authorized" = "Du är inte auktoriserad att redigera det här rummets namn"; +"room_error_topic_edition_not_authorized" = "Du är inte auktoriserad att ändra det här rummets ämne"; +"room_error_cannot_load_timeline" = "Misslyckades att ladda tidslinjen"; +"room_error_timeline_event_not_found_title" = "Misslyckades att ladda position på tidslinjen"; +"room_error_timeline_event_not_found" = "Appen försökte ladda en viss punkt i detta rums tidslinje men kunde inte hitta den"; +"room_left" = "Du lämnade rummet"; +"room_no_power_to_create_conference_call" = "Du behöver behörighet att bjuda in personer för att starta ett gruppsamtal i det här rummet"; +"room_no_conference_call_in_encrypted_rooms" = "Gruppsamtal stöds inte i krypterade rum"; +// Reply to message +"message_reply_to_sender_sent_an_image" = "skickade en bild."; +"message_reply_to_sender_sent_a_video" = "skickade en video."; +"message_reply_to_sender_sent_an_audio_file" = "skickade en ljudfil."; +"message_reply_to_sender_sent_a_file" = "skickade en fil."; +"message_reply_to_message_to_reply_to_prefix" = "Svar på"; +// Room members +"room_member_ignore_prompt" = "Är du säker på att du vill dölja alla meddelande från den här användaren?"; +"room_member_power_level_prompt" = "Du kommer inte att kunna ångra denna ändring eftersom du befordrar användaren till samma behörighetsnivå som dig själv.\nÄr du säker?"; +// Attachment +"attachment_size_prompt" = "Vill du skicka som:"; +"attachment_original" = "Faktisk storlek (%@)"; +"attachment_small" = "Liten (%@)"; +"attachment_medium" = "Mellan (%@)"; +"attachment_large" = "Stor (%@)"; +"attachment_cancel_download" = "Avbryt nedladdningen?"; +"attachment_cancel_upload" = "Avbryt uppladdningen?"; +"attachment_multiselection_size_prompt" = "Vill du skicka bilder som:"; +"attachment_multiselection_original" = "Faktisk storlek"; +"attachment_e2e_keys_file_prompt" = "Den här filen innehåller krypteringsnycklar som har exporteras från en Matrixklient.\nVill du visa filinnehållet eller importera nycklarna som den innehåller?"; +"attachment_e2e_keys_import" = "Importera…"; +// Contacts +"contact_mx_users" = "Matrixanvändare"; +"contact_local_contacts" = "Lokala kontakter"; +// Groups +"group_invite_section" = "Inbjudningar"; +"group_section" = "Grupper"; +// Search +"search_no_results" = "Inga resultat"; +"search_searching" = "Sökning pågår…"; +// Time +"format_time_s" = "s"; +"format_time_m" = "m"; +"format_time_h" = "t"; +"format_time_d" = "d"; +// E2E import +"e2e_import_room_keys" = "Importera rumsnycklar"; +"e2e_import_prompt" = "Denna process låter dig importera krypteringsnycklar som du tidigare har exporterat från en annan Matrixklient. Du kommer då kunna avkryptera alla meddelanden som den andra klienten kan avkryptera.\nExportfilen är skyddad med en lösenfras. Du bör ange lösenfrasen här för att avkryptera filen."; +"e2e_import" = "Importera"; +"e2e_passphrase_enter" = "Ange lösenfras"; +// E2E export +"e2e_export_room_keys" = "Exportera rumsnycklar"; +"e2e_export_prompt" = "Denna process låter dig exportera nycklarna för meddelanden som du har fått i krypterade rum till en lokal fil. Du kommer då att kunna importera filen till en annan Matrixklient i framtiden, så att klienten också kan avkryptera dessa meddelanden.\nDen exporterade filen tillåter alla som kan läsa den att avkryptera alla krypterade meddelanden som du kan se, så du bör vara noga med att hålla den säker."; +"e2e_export" = "Exportera"; +"e2e_passphrase_confirm" = "Bekräfta lösenfras"; +"e2e_passphrase_empty" = "Lösenfrasen får inte var tom"; +"e2e_passphrase_not_match" = "Lösenfraserna måste matcha"; +"e2e_passphrase_create" = "Skapa lösenfras"; +// Others +"user_id_title" = "Användar-ID:"; +"offline" = "offline"; +"unsent" = "Oskickad"; +"error" = "Fel"; +"error_common_message" = "Ett fel inträffade. Försök igen senare."; +"not_supported_yet" = "Stöds inte än"; +"default" = "förval"; +"private" = "Privat"; +"public" = "Offentlig"; +"power_level" = "Behörighetsnivå"; +"network_error_not_reachable" = "Vänligen kolla din nätverksuppkoppling"; +"user_id_placeholder" = "t.ex.: @sven:hemserver"; +"ssl_homeserver_url" = "Hemserver-URL: %@"; +// Permissions +"camera_access_not_granted_for_call" = "Videosamtal kräver åtkomst till kameran men %@ har inte behörighet att använda den"; +"microphone_access_not_granted_for_call" = "Samtal kräver åtkomst till mikrofonen men %@ har inte behörighet att använda den"; +"local_contacts_access_not_granted" = "Upptäckt av användare från lokala kontakter kräver åtkomst till dina kontakter men %@ har inte behörighet att komma åt dem"; +"local_contacts_access_discovery_warning_title" = "Användarupptäckt"; +"local_contacts_access_discovery_warning" = "För att upptäcka kontakter som redan använder Matrix kan %@ skicka e-postadresser och telefonnummer i din adressbok till din valda Matrixidentitetsserver. Där det stöds hashas personuppgifter innan de skickas - kontrollera din identitetsservers integritetspolicy för mer information."; +// Country picker +"country_picker_title" = "Välj ett land"; +// Language picker +"language_picker_title" = "Välj ett språk"; +"language_picker_default_language" = "Förval (%@)"; +"notice_room_invite" = "%@ bjöd in %@"; +"notice_room_third_party_invite" = "%@ skickade bjöd in %@ att gå med i rummet"; +"notice_room_third_party_registered_invite" = "%@ accepterade inbjudan för %@"; +"notice_room_third_party_revoked_invite" = "%@ drog tillbaka inbjudan för %@ att gå med i rummet"; +"notice_room_join" = "%@ gick med"; +"notice_room_leave" = "%@ lämnade"; +"notice_room_reject" = "%@ avvisade inbjudan"; +"notice_room_kick" = "%@ kickade %@"; +"notice_room_unban" = "%@ avbannade %@"; +"notice_room_ban" = "%@ bannade %@"; +"notice_room_withdraw" = "%@ drog tillbaka inbjudan för %@"; +"notice_room_reason" = ". Anledning: %@"; +"notice_avatar_url_changed" = "%@ bytte sin avatar"; +"notice_display_name_set" = "%@ satte sitt visningsnamn till %@"; +"notice_display_name_changed_from" = "%@ bytte sitt visningsnamn från %@ till %@"; +"notice_display_name_removed" = "%@ tog bort sitt visningsnamn"; +"notice_topic_changed" = "%@ bytte ämnet till \"%@\"."; +"notice_room_name_changed" = "%@ bytte rummets namn till %@."; +"notice_placed_voice_call" = "%@ startade ett röstsamtal"; +"notice_placed_video_call" = "%@ startade ett videosamtal"; +"notice_answered_video_call" = "%@ svarade på samtalet"; +"notice_ended_video_call" = "%@ avslutade samtalet"; +"notice_conference_call_request" = "%@ begärde ett VoIP-gruppsamtal"; +"notice_conference_call_started" = "VoIP-gruppsamtal startat"; +"notice_conference_call_finished" = "VoIP-gruppsamtal avslutat"; +// Notice Events with "You" +"notice_room_invite_by_you" = "Du bjöd in %@"; +"notice_room_invite_you" = "%@ bjöd in dig"; +"notice_room_third_party_invite_by_you" = "Du bjöd in %@ att gå med i rummet"; +"notice_room_third_party_registered_invite_by_you" = "Du accepterade inbjudan för %@"; +"notice_room_third_party_revoked_invite_by_you" = "Du drog tillbaka inbjudan för %@ att gå med i rummet"; +"notice_room_join_by_you" = "Du gick med"; +"notice_room_leave_by_you" = "Du lämnade"; +"notice_room_reject_by_you" = "Du avvisade inbjudan"; +"notice_room_kick_by_you" = "Du kickade %@"; +"notice_room_unban_by_you" = "Du avbannade %@"; +"notice_room_ban_by_you" = "Du bannade %@"; +"notice_room_withdraw_by_you" = "Du drog tillbaka inbjudan för %@"; +"notice_avatar_url_changed_by_you" = "Du ändrade din avatar"; +"notice_display_name_set_by_you" = "Du bytte ditt visningsnamn till %@"; +"notice_display_name_changed_from_by_you" = "Du bytte ditt visningsnamn från %@ till %@"; +"notice_display_name_removed_by_you" = "Du tog bort ditt visningsnamn"; +"notice_topic_changed_by_you" = "Du bytte ämnet till \"%@\"."; +"notice_room_name_changed_by_you" = "Du bytte rummets namn till %@."; +"notice_placed_voice_call_by_you" = "Du startade ett röstsamtal"; +"notice_placed_video_call_by_you" = "Du startade ett videosamtal"; +"notice_answered_video_call_by_you" = "Du svarade på samtalet"; +"notice_ended_video_call_by_you" = "Du avslutade samtalet"; +"notice_conference_call_request_by_you" = "Du begärde ett VoIP-gruppsamtal"; +"notice_room_name_removed_by_you" = "Du tog bort rummets namn"; +"notice_room_topic_removed_by_you" = "Du tog bort ämnet"; +"notice_event_redacted_by_you" = " av dig"; +"notice_profile_change_redacted_by_you" = "Du uppdaterade din profil %@"; +"notice_room_created_by_you" = "Du skapade och konfigurerade rummet."; +"notice_encryption_enabled_ok_by_you" = "Du aktiverade totalsträckskryptering."; +"notice_encryption_enabled_unknown_algorithm_by_you" = "Du aktiverade totalsträckskryptering (okänd algoritm %@)."; +"notice_redaction_by_you" = "Du dolde en händelse (id: %@)"; +"notice_room_history_visible_to_anyone_by_you" = "Du gjorde framtida rumshistorik synlig för alla."; +"notice_room_history_visible_to_members_by_you" = "Du gjorde framtida rumshistorik synlig för alla rumsmedlemmar."; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "Du gjorde framtida rumshistorik synlig för alla rumsmedlemmar från när de bjöds in."; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "Du gjorde framtida rumshistorik synlig för alla rumsmedlemmar från när de gick med."; +// button names +"ok" = "OK"; +"cancel" = "Avbryt"; +"save" = "Spara"; +"leave" = "Lämna"; +"send" = "Skicka"; +"copy_button_name" = "Kopiera"; +"resend" = "Skicka igen"; +"redact" = "Ta bort"; +"share" = "Dela"; +"delete" = "Radera"; +"view" = "Visa"; +// actions +"action_logout" = "Logga ut"; +"create_room" = "Skapa rum"; +"login" = "Logga in"; +"create_account" = "Skapa konto"; +"membership_invite" = "Inbjuden"; +"membership_leave" = "Lämnade"; +"membership_ban" = "Bannade"; +"num_members_one" = "%@ användare"; +"num_members_other" = "%@ användare"; +"invite" = "Bjud in"; +"kick" = "Kicka"; +"ban" = "Banna"; +"unban" = "Avbanna"; +"message_unsaved_changes" = "Det finns osparade ändringar. Att lämna kommer att slänga dem."; +// Login Screen +"login_error_already_logged_in" = "Redan inloggad"; +"login_error_must_start_http" = "URL:en måste börja på http[s]://"; +// room details dialog screen +"room_details_title" = "Rumsdetaljer"; +// contacts list screen +"invitation_message" = "Jag vill chatta med dig på Matrix. Besök sidan https://matrix.org för mer information."; +// Settings screen +"settings_title_config" = "Konfiguration"; +"settings_title_notifications" = "Aviseringar"; +// Notification settings screen +"notification_settings_disable_all" = "Inaktivera alla aviseringar"; +"notification_settings_enable_notifications" = "Aktivera aviseringar"; +"notification_settings_enable_notifications_warning" = "Alla aviseringar är för närvarande inaktiverade för alla enheter."; +"notification_settings_global_info" = "Aviseringsinställningar sparas i ditt användarkonto och delas mellan alla klienter som stöder dem (inklusive skrivbordsaviseringar).\n\nRegler tillämpas i ordning; den första regeln som matchar definierar resultatet för meddelandet.\nSå: Aviseringar per ord är viktigare än aviseringar per rum som är viktigare än aviseringar per avsändare.\nFör flera regler av samma slag prioriteras den första i listan som matchar."; +"notification_settings_per_word_notifications" = "Aviseringar per ord"; +"notification_settings_per_word_info" = "Matchning av ord är inte skiftlägeskänsligt, och kan innehålla ett jokertecken (*). Så:\nfoo matchar strängen foo omgiven av ordavgränsare (t.ex. skiljetecken och mellanslag eller start/slut på rad).\nfoo* matchar alla ord som börjar foo.\n*foo* matchar alla ord som innehåller de tre bokstäverna foo."; +"notification_settings_always_notify" = "Avisera alltid"; +"notification_settings_never_notify" = "Avisera aldrig"; +"notification_settings_word_to_match" = "ord att matcha"; +"notification_settings_highlight" = "Markera"; +"notification_settings_custom_sound" = "Anpassade ljud"; +"notification_settings_per_room_notifications" = "Aviseringar per rum"; +"notification_settings_per_sender_notifications" = "Aviseringar per avsändare"; +"notification_settings_sender_hint" = "@användare:domän.com"; +"notification_settings_select_room" = "Välj ett rum"; +"notification_settings_other_alerts" = "Andra larm"; +"notification_settings_contain_my_user_name" = "Avisera mig med ett ljud om meddelande som innehåller mitt användarnamn"; +"notification_settings_contain_my_display_name" = "Avisera mig med ett ljud om meddelande som innehåller mitt visningsnamn"; +"notification_settings_just_sent_to_me" = "Avisera med ett ljud om meddelanden skickade till bara mig"; +"notification_settings_invite_to_a_new_room" = "Avisera mig när jag bjuds in till ett nytt rum"; +"notification_settings_people_join_leave_rooms" = "Avisera mig när personer går med i eller lämnar rum"; +"notification_settings_receive_a_call" = "Avisera mig när jag får ett samtal"; +"notification_settings_suppress_from_bots" = "Dämpa aviseringar från bottar"; +"notification_settings_by_default" = "Som förval…"; +"notification_settings_notify_all_other" = "Avisera för alla andra meddelanden/rum"; +// gcm section +"settings_config_home_server" = "Hemserver: %@"; +"settings_config_identity_server" = "Identitetsserver: %@"; +"settings_config_user_id" = "Användar-ID: %@"; +// call string +"call_waiting" = "Väntar…"; +"call_connecting" = "Ansluter…"; +"call_ended" = "Samtal avslutat"; +"call_ring" = "Ringer…"; +"incoming_video_call" = "Inkommande videosamtal"; +"incoming_voice_call" = "Inkommande röstsamtal"; +"call_invite_expired" = "Samtalsinbjudan har löpt ut"; +// unrecognized SSL certificate +"ssl_trust" = "Lita"; +"ssl_logout_account" = "Logga ut"; +"ssl_remain_offline" = "Ignorera"; +"ssl_fingerprint_hash" = "Fingeravtryck (%@):"; +"ssl_could_not_verify" = "Kunde inte verifiera fjärrserverns identitet."; +"ssl_cert_not_trust" = "Det kan betyda att någon genskjuter din trafik eller att din telefon inte litar på certifikatet från fjärrservern."; +"ssl_cert_new_account_expl" = "Om serveradministratören har sagt att detta förväntas, kolla att fingeravtrycket nedan matchar det fingeravtryck som de tillhandahåller."; +"ssl_unexpected_existing_expl" = "Certifikatet har ändrats från ett som din telefon litade på. Detta är MYCKET OVANLIGT. Det rekommenderas att du INTE ACCEPTERAR detta nya certifikat."; +"ssl_expected_existing_expl" = "Certifikatet har ändrats från ett som din telefon litade på till ett som inte är betrott. Servern kan ha förnyat sitt certifikat. Kontakta serveradministratören för det förväntade fingeravtrycket."; +"ssl_only_accept" = "Acceptera ENDAST certifikatet om serveradministratören har publicerat ett fingeravtryck som matchar ovanstående."; +"notice_room_name_removed_for_dm" = "%@ tog bort namnet"; +"notice_room_created_for_dm" = "%@ gick med."; +// New +"notice_room_join_rule_invite" = "%@ ändrade rummet till endast inbjudna."; +"notice_room_join_rule_invite_for_dm" = "%@ ändrade detta till endast inbjudna."; +"notice_room_join_rule_invite_by_you" = "Du ändrade rummet till endast inbjudna."; +"notice_room_join_rule_invite_by_you_for_dm" = "Du ändrade detta till endast inbjudna."; +"notice_room_join_rule_public" = "%@ gjorde rummet offentligt."; +"notice_room_join_rule_public_for_dm" = "%@ gjorde detta offentligt."; +"notice_room_join_rule_public_by_you" = "Du gjorde rummet offentligt."; +"notice_room_join_rule_public_by_you_for_dm" = "Du gjorde detta offentligt."; +"notice_room_power_level_intro_for_dm" = "Behörighetsnivå för medlemmar är:"; +"notice_room_aliases_for_dm" = "Aliasen är: %@"; +"notice_room_history_visible_to_members_for_dm" = "%@ gjorde framtida meddelanden synliga för alla rumsmedlemmar."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ gjorde framtida meddelanden synliga för alla från när de bjöds in."; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ gjorde framtida meddelanden synliga för alla från när de gick med."; +"room_left_for_dm" = "Du lämnade"; +"notice_room_third_party_invite_for_dm" = "%@ bjöd in %@"; +"notice_room_third_party_revoked_invite_for_dm" = "%@ drog tillbaka inbjudan för %@"; +"notice_room_name_changed_for_dm" = "%@ bytte namnet till %@."; +"notice_room_third_party_invite_by_you_for_dm" = "Du bjöd in %@"; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "Du drog tillbaka inbjudan för %@"; +"notice_room_name_changed_by_you_for_dm" = "Du bytte namnet till %@."; +"notice_room_name_removed_by_you_for_dm" = "Du tog bort namnet"; +"notice_room_created_by_you_for_dm" = "Du gick med."; +"notice_room_history_visible_to_members_by_you_for_dm" = "Du gjorde framtida meddelanden synliga för alla rumsmedlemmar."; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "Du gjorde framtida meddelanden synliga för alla från när de bjöds in."; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "Du gjorde framtida meddelanden synliga för alla från när de gick med."; +"call_more_actions_dialpad" = "Knappsats"; +"call_more_actions_transfer" = "Överför"; +"call_more_actions_audio_use_device" = "Enhetshögtalare"; +"call_more_actions_audio_use_headset" = "Använd headsetljud"; +"call_more_actions_change_audio_device" = "Byt ljudenhet"; +"call_more_actions_unhold" = "Återuppta"; +"call_more_actions_hold" = "Parkera"; +"call_holded" = "Du parkerade samtalet"; +"call_remote_holded" = "%@ parkerade samtalet"; +"notice_declined_video_call_by_you" = "Du avslog samtalet"; +"notice_declined_video_call" = "%@ avslog samtalet"; +"resume_call" = "Återuppta"; +"call_transfer_to_user" = "Överför till %@"; +"call_consulting_with_user" = "Rådfrågar %@"; +"call_video_with_user" = "Videosamtal med %@"; +"call_voice_with_user" = "Röstsamtal med %@"; +"call_ringing" = "Ringer…"; +"e2e_passphrase_too_short" = "Lösenfras för kort (den måste vara minst %d tecken långt)"; +"microphone_access_not_granted_for_voice_message" = "Röstmeddelanden kräver åtkomst till mikrofonen, men %@ har inte behörighet att använda den"; +"message_reply_to_sender_sent_a_voice_message" = "skickade ett röstmeddelande."; +"attachment_large_with_resolution" = "Stor %@ (~%@)"; +"attachment_medium_with_resolution" = "Mellan %@ (~%@)"; +"attachment_small_with_resolution" = "Liten %@ (~%@)"; +"attachment_size_prompt_message" = "Du kan stänga av detta i inställningarna."; +"attachment_size_prompt_title" = "Bekräfta storlek att skicka"; +"room_displayname_all_other_participants_left" = "%@ (Kvar)"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/szl.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/szl.lproj/MatrixKit.strings new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/szl.lproj/MatrixKit.strings @@ -0,0 +1 @@ + diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/tzm.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/tzm.lproj/MatrixKit.strings new file mode 100644 index 000000000..f1a0a2a8f --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/tzm.lproj/MatrixKit.strings @@ -0,0 +1,14 @@ + + + +"notice_event_redacted_by" = " Sɣur %@"; +"close" = "Rgel"; +"yes" = "Yah"; + +// Action +"no" = "Uhu"; +/* *********************** */ +/* iOS specific */ +/* *********************** */ + +"matrix" = "Matrix"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/uk.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/uk.lproj/MatrixKit.strings new file mode 100644 index 000000000..28bb81299 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/uk.lproj/MatrixKit.strings @@ -0,0 +1,521 @@ + + +"resend" = "Повторно надіслати"; +"delete" = "Видалити"; +"share" = "Поділитися"; +"redact" = "Вилучити"; +"copy_button_name" = "Копіювати"; +"send" = "Надіслати"; +"register_error_title" = "Поле реєстрації"; +"login_invalid_param" = "Недійсний параметр"; +"save" = "Зберегти"; +"login_leave_fallback" = "Скасувати"; +"cancel" = "Скасувати"; +"submit" = "Надіслати"; +"sign_up" = "Зареєструватися"; +"retry" = "Повторити"; +"unban" = "Розблокувати"; +"ban" = "Заблокувати"; +"kick" = "Викинути"; +"invite" = "Запросити"; +"leave" = "Вийти"; +"discard" = "Відхилити"; +"continue" = "Продовжити"; +"close" = "Закрити"; +"back" = "Назад"; +"view" = "Вигляд"; +"login_server_url_placeholder" = "URL (наприклад, https://matrix.org)"; + +// Login Screen +"login_create_account" = "Створіть обліковий запис:"; +/* *********************** */ +/* iOS specific */ +/* *********************** */ + +"matrix" = "Matrix"; + +// Events formatter +"notice_avatar_changed_too" = "(аватар теж змінено)"; +"unignore" = "Не нехтувати"; +"ignore" = "Нехтувати"; +"resume_call" = "Продовжити"; +"end_call" = "Завершити виклик"; +"reject_call" = "Відхилити виклик"; +"answer_call" = "Відповісти на виклик"; +"show_details" = "Показати подробиці"; +"cancel_download" = "Скасувати завантаження"; +"cancel_upload" = "Скасувати вивантаження"; +"select_all" = "Вибрати всі"; +"resend_message" = "Повторити надсилання повідомлення"; +"reset_to_default" = "Скинути до типових"; +"invite_user" = "Запросити користувача matrix"; +"capture_media" = "Зробити знімок/зафільмувати"; +"attach_media" = "Долучити медіа з бібліотеки"; +"select_account" = "Вибрати обліковий запис"; +"mention" = "Згадати"; +"start_video_call" = "Розпочати відеовиклик"; +"start_voice_call" = "Розпочати голосовий виклик"; +"start_chat" = "Почати бесіду"; +"set_admin" = "Призначити адміністратора"; +"set_moderator" = "Призначити модератора"; +"set_default_power_level" = "Скинути рівень повноважень"; +"set_power_level" = "Визначити рівень повноважень"; +"submit_code" = "Надіслати код"; +"dismiss" = "Відхилити"; +"abort" = "Перервати"; +"yes" = "Так"; + +// Action +"no" = "Ні"; +"login_error_resource_limit_exceeded_contact_button" = "Зв'язатися з адміністратором"; +"login_error_resource_limit_exceeded_message_contact" = "\n\nЗверніться до адміністратора своєї служби, щоб продовжувати користуватися нею."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Цей домашній сервер досяг свого місячного обмеження активних користувачів."; +"login_error_resource_limit_exceeded_message_default" = "Цей домашній сервер перевищив одне з обмежень ресурсів."; +"login_error_resource_limit_exceeded_title" = "Обмеження ресурсів перевищено"; +"login_desktop_device" = "Комп'ютер"; +"login_tablet_device" = "Планшет"; +"login_mobile_device" = "Мобільний"; +"login_error_forgot_password_is_not_supported" = "Відновлення пароля зараз не підтримується"; +"login_use_fallback" = "Застосувати запасну сторінку"; +"login_error_login_email_not_yet" = "Посилання на електронну адресу, на яке ще не клацнули"; +"login_error_user_in_use" = "Це ім'я користувача вже використовується"; +"login_error_limit_exceeded" = "Було надіслано забагато запитів"; +"login_error_not_json" = "Не містить дійсного JSON"; +"login_error_bad_json" = "Неправильний синтаксис JSON"; +"login_error_unknown_token" = "Вказаний ключ доступу не було розпізнано"; +"login_error_forbidden" = "Не правильне ім'я користувача/пароль"; +"login_error_registration_is_not_supported" = "На цей час реєстрація не підтримується"; +"login_error_no_login_flow" = "Не вдалося отримати дані автентифікації з цього домашнього сервера"; +"login_error_title" = "Не вдалося увійти"; +"login_prompt_email_token" = "Введіть ключ підтвердження електронної пошти:"; +"login_email_placeholder" = "Адреса е-пошти"; +"login_email_info" = "Вкажіть адресу електронної пошти, щоб інші користувачі могли легше знаходити вас на Matrix і надання вам можливості відновити пароль у майбутньому."; +"login_display_name_placeholder" = "Видиме ім'я (наприклад Bob Obson)"; +"login_optional_field" = "необов'язково"; +"login_password_placeholder" = "Пароль"; +"login_user_id_placeholder" = "Ідентифікатор Matrix (наприклад @bob:matrix.org або bob)"; +"login_identity_server_info" = "Matrix забезпечує сервери ідентифікації для відстеження, до яких ідентифікаторів Matrix, які електронні листи належать. Наразі доступно лише для https://matrix.org."; +"login_identity_server_title" = "URL-адреса сервера ідентифікації:"; +"login_home_server_info" = "Ваш домашній сервер зберігає всі ваші розмови та дані облікового запису"; +"login_home_server_title" = "URL-адреса домашнього сервера:"; +"notice_room_power_level_intro_for_dm" = "Рівні повноважень учасників:"; +"notice_room_power_level_intro" = "Рівні повноважень учасників кімнати:"; +"notice_room_join_rule_public_by_you_for_dm" = "Ви дозволяєте доступ всім."; +"notice_room_join_rule_public_by_you" = "Ви дозволяєте доступ до кімнати всім."; +"notice_room_join_rule_public_for_dm" = "%@ дозволяє доступ всім."; +"notice_room_join_rule_public" = "%@ дозволяє доступ до кімнати всім."; +"notice_room_join_rule_invite_by_you_for_dm" = "Ви забороняєте доступ всім, окрім запрошених."; +"notice_room_join_rule_invite_by_you" = "Ви забороняєте доступ до кімнати всім, окрім запрошених."; +// New +"notice_room_join_rule_invite" = "%@ забороняє доступ до кімнати всім, окрім запрошених."; +"notice_room_join_rule_invite_for_dm" = "%@ забороняє доступ всім, окрім запрошених."; +// Old +"notice_room_join_rule" = "Правило приєднання: %@"; +"notice_room_created_for_dm" = "%@ приєднується."; +"notice_room_created" = "%@ створює і налаштовує кімнату."; +"notice_profile_change_redacted" = "%@ оновлює свій профіль %@"; +"notice_event_redacted_reason" = " [причина: %@]"; +"notice_event_redacted_by" = " від %@"; +"notice_event_redacted" = "<змінено%@>"; +"notice_room_topic_removed" = "%@ вилучає тему"; +"notice_room_name_removed_for_dm" = "%@ вилучає назву"; +"notice_room_name_removed" = "%@ вилучає назву кімнати"; +"login_error_do_not_support_login_flows" = "Наразі ми не підтримуємо один або кілька потоків авторизації, визначених цим домашнім сервером"; +"notification_settings_never_notify" = "Ніколи не сповіщати"; +"notification_settings_always_notify" = "Завжди сповіщати"; + +// members list Screen + +// accounts list Screen + +// image size selection + +// invitation members list Screen + +// room creation dialog Screen + +// room info dialog Screen + +// room details dialog screen +"room_details_title" = "Подробиці про кімнату"; +"login_error_must_start_http" = "URL-адреса повинна починатися з http[s]://"; +"num_members_other" = "%@ користувачів"; +"num_members_one" = "%@ користувач"; +"membership_invite" = "Запрошено"; +"create_account" = "Створити обліковий запис"; +"login" = "Увійти"; +"create_room" = "Створити кімнату"; +"call_video_with_user" = "Відеовиклик з %@"; +"call_voice_with_user" = "Голосовий виклик з %@"; +"ssl_fingerprint_hash" = "Відбиток (%@):"; +"ssl_remain_offline" = "Нехтувати"; +"ssl_logout_account" = "Вийти"; + +// actions +"action_logout" = "Вийти"; + +// Room Screen + +// general errors + +// Home Screen + +// Last seen time + +// call events + +/* -*- + Automatic localization for en + + The following key/value pairs were extracted from the android i18n file: + /console/src/main/res/values/strings.xml. +*/ + + +// titles + +// button names +"ok" = "Гаразд"; +"notice_declined_video_call_by_you" = "Ви відхилили виклик"; +"notice_ended_video_call_by_you" = "Ви завершили виклик"; +"notice_avatar_url_changed_by_you" = "Ви змінили свій аватар"; +"notice_room_kick_by_you" = "Ви викинули %@"; +"notice_room_invite_you" = "%@ запрошує вас"; + +// Notice Events with "You" +"notice_room_invite_by_you" = "Ви запросили %@"; +"notice_room_third_party_invite_by_you_for_dm" = "Ви запросили %@"; +"notice_room_reason" = ". Причина: %@"; +"notice_room_third_party_registered_invite" = "%@ приймає запрошення від %@"; +"notice_room_third_party_invite_for_dm" = "%@ запрошує %@"; + +/* -*- + Automatic localization for en + + The following key/value pairs were extracted from the android i18n file: + /matrix-sdk/src/main/res/values/strings.xml. +*/ + +"notice_room_invite" = "%@ запрошує %@"; +"ssl_homeserver_url" = "URL-адреса домашнього сервера: %@"; +"user_id_placeholder" = "наприклад: @bob:homeserver"; +"network_error_not_reachable" = "Перевірте під'єднання до мережі"; +"power_level" = "Рівень повноважень"; +"public" = "Загальнодоступний"; +"private" = "Приватний"; +"default" = "типово"; +"not_supported_yet" = "Поки що не підтримується"; +"error_common_message" = "Сталася помилка. Повторіть спробу пізніше."; +"error" = "Помилка"; +"membership_ban" = "Заблоковано"; +"notice_room_ban_by_you" = "Ви заблокували %@"; +"notice_room_unban_by_you" = "Ви розблокували %@"; +"notice_room_ban" = "%@ блокує %@"; +"notice_room_unban" = "%@ розблоковує %@"; +"call_invite_expired" = "Запрошення на виклик не чинне"; +"incoming_voice_call" = "Вхідний голосовий виклик"; +"incoming_video_call" = "Вхідний відеовиклик"; +"call_ended" = "Виклик завершено"; +"call_ringing" = "Виклик…"; + +// Settings keys + +// call string +"call_connecting" = "З'єднання…"; +"settings_config_user_id" = "ID користувача: %@"; +"settings_config_identity_server" = "Сервер ідентифікації: %@"; + +// gcm section +"settings_config_home_server" = "Домашній сервер: %@"; +"notification_settings_other_alerts" = "Інші попередження"; +"notification_settings_select_room" = "Вибрати кімнату"; +"notification_settings_sender_hint" = "@user:domain.com"; +"notification_settings_enable_notifications_warning" = "Наразі всі сповіщення вимкнено для всіх пристроїв."; +"notification_settings_enable_notifications" = "Увімкнути сповіщення"; + +// Notification settings screen +"notification_settings_disable_all" = "Вимкнути сповіщення"; +"settings_title_notifications" = "Сповіщення"; + +// Settings +"settings" = "Налаштування"; +"room_displayname_more_than_two_members" = "%@ і %@ інших"; +"room_displayname_two_members" = "%@ і %@"; + +// room display name +"room_displayname_empty_room" = "Порожня кімната"; +"notice_sticker" = "наліпка"; +"notice_unsupported_attachment" = "Непідтримуване вкладення: %@"; +"notice_file_attachment" = "прикріплений файл"; +"notice_location_attachment" = "прикріплене місцеперебування"; +"notice_video_attachment" = "прикріплене відео"; +"notice_audio_attachment" = "прикріплене аудіо"; +"notice_image_attachment" = "прикріплене зображення"; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ вмикає наскрізне шифрування (нерозпізнаний алгоритм %2$@)."; +"notice_encryption_enabled_ok" = "%@ вмикає наскрізне шифрування."; +"notice_encrypted_message" = "Зашифроване повідомлення"; +"notice_avatar_url_changed" = "%@ змінює свій аватар"; + +// Settings screen +"settings_title_config" = "Конфігурація"; + +// Others +"user_id_title" = "ID користувача:"; +"e2e_export" = "Експорт"; +"e2e_import" = "Імпорт"; +"search_searching" = "Триває пошук..."; + +// Search +"search_no_results" = "Немає результатів"; +"group_section" = "Групи"; + +// Groups +"group_invite_section" = "Запрошення"; +"contact_local_contacts" = "Локальні контакти"; + +// Contacts +"contact_mx_users" = "Користувачі Matrix"; +"attachment_e2e_keys_import" = "Імпорт..."; +"attachment_multiselection_original" = "Справжній розмір"; +"attachment_multiselection_size_prompt" = "Хочете надіслати зображення як:"; +"attachment_cancel_upload" = "Скасувати вивантаження?"; +"attachment_cancel_download" = "Скасувати завантаження?"; +"attachment_large" = "Великий (~%@)"; +"attachment_medium" = "Середній (~%@)"; +"attachment_small" = "Маленький (~%@)"; +"attachment_original" = "Справжній розмір (%@)"; + +// Attachment +"attachment_size_prompt" = "Бажаєте надіслати:"; +"message_reply_to_sender_sent_a_file" = "надсилає файл."; +"message_reply_to_sender_sent_an_audio_file" = "надсилає звуковий файл."; +"message_reply_to_sender_sent_a_voice_message" = "надсилає голосове повідомлення."; +"message_reply_to_sender_sent_a_video" = "надсилає відео."; + +// Reply to message +"message_reply_to_sender_sent_an_image" = "надсилає зображення."; + +// E2E import +"e2e_import_room_keys" = "Імпорт ключів кімнати"; +"format_time_d" = "д"; +"format_time_h" = "год"; +"format_time_m" = "хв"; + +// Time +"format_time_s" = "с"; +"room_error_join_failed_title" = "Не вдалося приєднатися до кімнати"; + +// Room +"room_please_select" = "Виберіть кімнату"; +"room_creation_participants_placeholder" = "(наприклад @bob:homeserver1; @john:homeserver2...)"; +"room_creation_participants_title" = "Учасники:"; +"room_creation_name_placeholder" = "(наприклад lunchGroup)"; + +// Room creation +"room_creation_name_title" = "Назва кімнати:"; +"room_event_encryption_info_device_fingerprint" = "Відбиток Ed25519\n"; +"room_event_encryption_info_event_unencrypted" = "незашифровано"; +"room_event_encryption_info_event_decryption_error" = "Помилка розшифрування\n"; +"room_event_encryption_info_event_session_id" = "ID сеансу\n"; +"room_event_encryption_info_event_algorithm" = "Алгоритм\n"; +"room_event_encryption_info_event_identity_key" = "Ключ ідентифікації Curve25519\n"; +"room_event_encryption_info_event_user_id" = "ID користувача\n"; +"room_event_encryption_info_event" = "Відомості про подію\n"; + +// Encryption information +"room_event_encryption_info_title" = "Відомості про наскрізне шифрування\n\n"; +"device_details_rename_prompt_title" = "Назва сеансу"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_identifier" = "ID\n"; +"room_event_encryption_info_device_id" = "ID\n"; +"room_event_encryption_info_device_name" = "Загальнодоступна назва\n"; +"device_details_name" = "Загальнодоступна назва\n"; + +// Devices +"device_details_title" = "Відомості про сеанс\n"; +"notification_settings_room_rule_title" = "Кімната: '%@'"; +"notice_in_reply_to" = "У відповідь на"; +"message_reply_to_message_to_reply_to_prefix" = "У відповідь на"; +"notice_crypto_unable_to_decrypt" = "** Не вдалося розшифрувати: %@ **"; +"notice_error_unknown_event_type" = "Невідомий тип події"; +"notice_error_unexpected_event" = "Неочікувана подія"; +"notice_error_unsupported_event" = "Непідтримувана подія"; +"notice_invalid_attachment" = "неприпустиме вкладення"; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ робить майбутню історію повідомлень видимою всім від часу їхнього приєднання."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ робить майбутню історію кімнати видимою всім учасникам кімнати від часу їхнього приєднання."; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ робить майбутню історію повідомлень видимою всім від часу їхнього запрошення."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ робить майбутню історію кімнати видимою усім учасникам кімнати від часу їхнього запрошення."; +"notice_room_history_visible_to_members_for_dm" = "%@ робить майбутню історію повідомлень видимою усім учасникам кімнати."; +"notice_room_history_visible_to_members" = "%@ робить майбутню історію кімнати видимою усім учасникам кімнати."; +"notice_room_history_visible_to_anyone" = "%@ робить майбутню історію кімнати видимою усім."; +"notice_redaction" = "%@ редагує подію (id: %@)"; +"notice_feedback" = "Подія відгуку (id: %@): %@"; +"notice_room_related_groups" = "Групи пов'язані з цією кімнатою: %@"; +"notice_room_power_level_acting_requirement" = "Мінімальний рівень повноважень користувача для виконання дії:"; +"notification_settings_by_default" = "Типово..."; +"membership_leave" = "Виходить"; +"notice_redaction_by_you" = "Ви відредагували подію (id: %@)"; +"notice_encryption_enabled_unknown_algorithm_by_you" = "Ви ввімкнули наскрізне шифрування (нерозпізнаний алгоритм %@)."; +"notice_encryption_enabled_ok_by_you" = "Ви ввімкнули наскрізне шифрування."; +"notice_room_created_by_you_for_dm" = "Ви приєдналися."; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "Ви відкликали запрошення для %@"; +"notice_room_third_party_revoked_invite_by_you" = "Ви відкликали запрошення приєднатися до кімнати для %@"; +"notice_room_third_party_registered_invite_by_you" = "Ви прийняли запрошення для %@"; +"notice_room_withdraw_by_you" = "Ви анулювали запрошення для %@"; +"notice_room_reject_by_you" = "Ви відхилили запрошення"; +"notice_room_third_party_invite_by_you" = "Ви надіслали запрошення приєднатися до кімнати для %@"; +"notice_room_name_changed_for_dm" = "%@ змінює назву на %@."; +"notice_room_name_changed" = "%@ змінює назву кімнати на %@."; +"notice_topic_changed" = "%@ змінює тему на «%@»."; +"notice_display_name_removed" = "%@ вилучає своє показуване ім'я"; +"notice_display_name_changed_from" = "%@ змінює своє показуване ім'я з %@ на %@"; +"notice_display_name_set" = "%@ встановлює своїм показуваним іменем %@"; +"notice_room_withdraw" = "%@ анульовує запрошення для %@"; +"notice_room_kick" = "%@ викидає %@"; +"notice_room_reject" = "%@ відхиляє запрошення"; +"notice_room_leave" = "%@ виходить"; +"notice_room_join" = "%@ приєднується"; +"notice_room_third_party_revoked_invite_for_dm" = "%@ відкликає запрошення для %@"; +"notice_room_third_party_revoked_invite" = "%@ відкликає запрошення приєднатися до кімнати для %@"; +"notice_room_third_party_invite" = "%@ надсилає запрошення приєднатися до кімнати для %@"; +"microphone_access_not_granted_for_voice_message" = "Для голосових повідомлень потрібен доступ до мікрофона, але %@ не має дозволу на його використання"; +"microphone_access_not_granted_for_call" = "Для викликів потрібен доступ до мікрофона, але %@ не має дозволу на його використання"; + +// Permissions +"camera_access_not_granted_for_call" = "Для відеовикликів потрібен доступ до камери, але %@ не має дозволу на її використання"; +"language_picker_default_language" = "Типово (%@)"; + +// Language picker +"language_picker_title" = "Виберіть мову"; + +// Country picker +"country_picker_title" = "Виберіть країну"; +"notice_room_join_by_you" = "Ви приєдналися"; +"notice_room_leave_by_you" = "Ви вийшли"; +"room_left_for_dm" = "Ви вийшли"; +"room_left" = "Ви вийшли з кімнати"; +"call_more_actions_unhold" = "Продовжити"; +"call_more_actions_change_audio_device" = "Змінити звуковий пристрій"; +"call_more_actions_audio_use_device" = "Гучномовець пристрою"; +"call_more_actions_transfer" = "Переведення"; +"call_more_actions_dialpad" = "Номеронабирач"; +"call_transfer_to_user" = "Передавання до %@"; + +// unrecognized SSL certificate +"ssl_trust" = "Довіряти"; + +// Account +"account_save_changes" = "Зберегти зміни"; +"room_event_encryption_info_unblock" = "Видалити з чорного списку"; +"room_event_encryption_info_block" = "Чорний список"; +"room_event_encryption_info_device_blocked" = "У чорному списку"; +"room_event_encryption_info_device_unknown" = "невідомий сеанс\n"; +"room_event_encryption_info_device" = "\nВідомості про сеанс відправника\n"; +"device_details_delete_prompt_title" = "Автентифікація"; +"settings_enter_validation_token_for" = "Введіть токен підтвердження для %@:"; +"settings_enable_push_notifications" = "Увімкнути push-сповіщення"; +"settings_enable_inapp_notifications" = "Увімкнути сповіщення в застосунку"; +"room_displayname_all_other_participants_left" = "%@ (виходить)"; +"notice_crypto_error_unknown_inbound_session_id" = "Сеанс відправника не надіслав нам ключі для цього повідомлення."; +"notice_room_power_level_event_requirement" = "Найнижчий рівень повноважень пов'язаний з подією:"; +"notification_settings_global_info" = "Налаштування сповіщень зберігаються у вашому обліковому записі й спільні для всіх клієнтів, які їх підтримують (включно зі сповіщеннями стільниці).\n\nПравила застосовуються по черзі; спочатку надсилається повідомлення першого збігу з правилом.\nОтже: сповіщення для кожного слова важливіші за сповіщення для кожної кімнати, які важливіші за сповіщення від кожного відправника.\nДля кількох правил одного виду важливіше перше у списку."; +"notification_settings_per_word_notifications" = "Сповіщення для кожного слова"; +"notification_settings_per_word_info" = "Слова збігу не чутливі до регістру й можуть містити символ *. Так:\nfoo збігається з рядком foo, оточеним роздільниками слів (наприклад, розділовими знаками та пробілами або початком/кінцем рядка).\nfoo* збігається з будь-яким таким словом, яке починається з foo.\n*foo* збігається з будь-яким таким словом, яке містить три букви foo."; +"notification_settings_word_to_match" = "слово збігу"; +"notification_settings_highlight" = "Підсвічування"; +"notification_settings_per_room_notifications" = "Сповіщення від кожної кімнати"; +"notification_settings_per_sender_notifications" = "Сповіщення про кожного відправника"; +"notification_settings_contain_my_user_name" = "Сповіщати звуком про повідомлення, що містять моє ім'я користиувача"; +"notification_settings_contain_my_display_name" = "Сповіщати звуком про повідомлення, що містять моє показуване ім'я"; +"notification_settings_just_sent_to_me" = "Сповіщати звуком про надіслані лише мені повідомлення"; +"notification_settings_invite_to_a_new_room" = "Сповіщати про запрошення до нових кімнат"; +"notification_settings_people_join_leave_rooms" = "Сповіщати, коли люди приєднуються чи виходять з кімнат"; +"notification_settings_receive_a_call" = "Сповіщати про виклики"; +"notification_settings_suppress_from_bots" = "Приховувати сповіщень від ботів"; +"notification_settings_notify_all_other" = "Сповіщати про всі інші повідомлення/кімнати"; +"notification_settings_custom_sound" = "Власний звук"; +"account_error_push_not_allowed" = "Сповіщення не дозволені"; +"account_error_msisdn_wrong_description" = "Це не схоже на правильний номер телефону"; +"account_error_msisdn_wrong_title" = "Неправильний номер телефону"; +"account_error_email_wrong_description" = "Це не схоже на правильну адресу е-пошти"; +"account_error_email_wrong_title" = "Неправильна адреса е-пошти"; +"account_error_matrix_session_is_not_opened" = "Сеанс Matrix не відкрито"; +"account_error_picture_change_failed" = "Не вдалося змінити зображення"; +"account_error_display_name_change_failed" = "Не вдалося змінити показуване ім'я"; +"account_msisdn_validation_error" = "Не вдалося перевірити номер телефону."; +"account_email_validation_title" = "Очікування перевірки"; +"account_msisdn_validation_title" = "Очікування перевірки"; +"account_msisdn_validation_message" = "Ми надіслали СМС із кодом активації. Введіть цей код унизу."; +"account_linked_emails" = "Пов'язані адреси е-пошти"; +"account_link_email" = "Пов'язати е-пошту"; +"room_event_encryption_verify_ok" = "Звірити"; +"room_event_encryption_info_device_not_verified" = "НЕ звірений"; +"room_event_encryption_info_device_verified" = "Звірений"; +"room_event_encryption_verify_title" = "Звірити сеанс\n\n"; +"room_event_encryption_info_unverify" = "Скасувати перевірку"; +"room_event_encryption_info_verify" = "Звірити..."; +"room_event_encryption_info_device_verification" = "Перевірка\n"; +"room_event_encryption_info_event_none" = "нічого"; +"room_event_encryption_info_event_fingerprint_key" = "Потрібен ключ цифрового відбитка Ed25519\n"; +"device_details_delete_prompt_message" = "Виконання цієї дії вимагає додаткової автентифікації.\nЩоб продовжити, введіть свій пароль."; +"device_details_rename_prompt_message" = "Загальнодоступну назву сеансу бачать люди, з якими ви спілкуєтесь"; +"device_details_last_seen" = "Останні відвідини\n"; +"notice_room_aliases_for_dm" = "Псевдоніми: %@"; +"notice_room_aliases" = "Псевдо кімнати: %@"; +"unsent" = "Не надіслано"; +"offline" = "не в мережі"; +"e2e_passphrase_create" = "Створити парольну фразу"; +"e2e_passphrase_not_match" = "Парольні фрази повинні збігатися"; +"e2e_passphrase_too_short" = "Парольна фраза закоротка (Її довжина повинна складати принаймні %d символів)"; + +// E2E export +"e2e_export_room_keys" = "Експорт ключів кімнати"; +"e2e_passphrase_enter" = "Введіть парольну фразу"; +"e2e_passphrase_empty" = "Парольна фраза не повинна бути порожньою"; +"e2e_passphrase_confirm" = "Підтвердити парольну фразу"; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "Ви зробили майбутні повідомлення кімнати видимими будь-кому від часу їхнього приєднання."; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "Ви зробили майбутню історію кімнати видимою усім учасникам кімнати від часу їхнього приєднання."; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "Ви зробили майбутні повідомлення кімнати видимими будь-кому від часу запрошення їх."; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "Ви зробили майбутню історію кімнати видимою усім учасникам кімнати від часу запрошення їх."; +"notice_room_history_visible_to_members_by_you_for_dm" = "Ви зробили майбутні повідомлення кімнати видимими усім учасникам кімнати."; +"notice_room_history_visible_to_members_by_you" = "Ви зробили майбутню історію кімнати видимою усім учасникам кімнати."; +"notice_room_history_visible_to_anyone_by_you" = "Ви зробили майбутню історію кімнати видимою будь-кому."; +"notice_room_created_by_you" = "Ви створили й сконфігурували кімнату."; +"notice_profile_change_redacted_by_you" = "Ви оновили свій профіль %@"; +"notice_event_redacted_by_you" = " вами"; +"notice_room_topic_removed_by_you" = "Ви вилучили тему"; +"notice_room_name_removed_by_you_for_dm" = "Ви вилучили назву"; +"notice_room_name_removed_by_you" = "Ви вилучили назву кімнати"; +"notice_conference_call_request_by_you" = "Ви запитали VoIP-конференцію"; +"notice_answered_video_call_by_you" = "Ви відповіли на виклик"; +"notice_placed_video_call_by_you" = "Ви розпочали відеовиклик"; +"notice_placed_voice_call_by_you" = "Ви розпочали голосовий виклик"; +"notice_room_name_changed_by_you_for_dm" = "Ви змінили назву на %@."; +"notice_room_name_changed_by_you" = "Ви змінили назву кімнати на %@."; +"notice_topic_changed_by_you" = "Ви змінили тему на «%@»."; +"notice_display_name_removed_by_you" = "Ви вилучили показуване ім'я"; +"notice_display_name_changed_from_by_you" = "Ви змінили показуване ім'я з %@ на %@"; +"notice_display_name_set_by_you" = "Ви вказали показуваним іменем %@"; +"notice_conference_call_finished" = "VoIP-конференція завершилася"; +"notice_conference_call_started" = "VoIP-конференція розпочалася"; +"notice_conference_call_request" = "%@ запитує VoIP-конференцію"; +"notice_declined_video_call" = "%@ відхиляє виклик"; +"notice_ended_video_call" = "%@ завершує виклик"; +"notice_answered_video_call" = "%@ відповідає на виклик"; +"notice_placed_video_call" = "%@ здійснює відеовиклик"; +"notice_placed_voice_call" = "%@ здійснює голосовий виклик"; + +// Room members +"room_member_ignore_prompt" = "Ви впевнені, що хочете сховати всі повідомлення від цього користувача?"; +"room_no_conference_call_in_encrypted_rooms" = "Кімнати з шифруванням не підтримують конференцвиклики"; +"room_error_topic_edition_not_authorized" = "Ви не маєте повноважень змінювати тему цієї кімнати"; +"room_error_name_edition_not_authorized" = "Ви не маєте повноважень змінювати назву цієї кімнати"; +"room_error_join_failed_empty_room" = "На цю мить неможливо приєднатися до порожньої кімнати."; +"room_creation_alias_placeholder_with_homeserver" = "(напр. #foo%@)"; +"room_creation_alias_placeholder" = "(напр. #foo:example.org)"; +"account_email_validation_message" = "Перевірте свою електронну пошту та натисніть на посилання у ній. Після цього натисніть кнопку Продовжити."; +"room_displayname_all_other_members_left" = "%@ (виходять)"; +"auth_username_in_use" = "Ім'я користувача зайняте"; +"rename" = "Перейменувати"; +"room_creation_alias_title" = "Псевдоніми кімнати:"; +"account_email_validation_error" = "Не вдалося перевірити адресу електронної пошти. Перевірте свою електронну пошту та натисніть на посилання в ній. Після цього натисніть продовжити"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/vi.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/vi.lproj/MatrixKit.strings new file mode 100644 index 000000000..6f7fbb694 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/vi.lproj/MatrixKit.strings @@ -0,0 +1,359 @@ +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "Tạo tài khoản:"; +"login_server_url_placeholder" = "URL (e.g. https://matrix.org)"; +"login_home_server_title" = "Máy chủ nhà:"; +"login_home_server_info" = "Máy chủ nhà của bạn lưu trữ tất cả dữ liệu cần giữ gìn và tài khoản"; +"login_identity_server_title" = "Máy chủ xác thực:"; +"login_identity_server_info" = "Matrix cung cấp máy chủ xác thực để theo dõi email nào thuộc về Matrix IDs nào. Chỉ https://matrix.org hiện đang tồn tại."; +"login_user_id_placeholder" = "Matrix ID (ví dụ: @bob:matrix.org hoặc bob)"; +"login_password_placeholder" = "Mật khẩu"; +"login_optional_field" = "không bắt buộc"; +"login_display_name_placeholder" = "Tên hiển thị (ví dụ: Bob Obson)"; +"login_email_info" = "Định nghĩa địa chỉ email cho phép người dùng khác tìm thấy bạn trên Matrix dễ dàng hơn và giúp bạn đặt lại mật khẩu của bạn sau này."; +"login_email_placeholder" = "Địa chỉ email"; +"login_prompt_email_token" = "Vui lòng nhập mã xác nhận hợp lệ email của bạn:"; +"login_error_title" = "Đăng nhập thất bại"; +"login_error_no_login_flow" = "Chúng tôi không thể truy xuất thông tin xác thực từ Home Server này"; +"login_error_do_not_support_login_flows" = "Hiện tại chúng tôi không hỗ trợ bất cứ luồng đăng nhập được định nghĩa nào từ Home Server này"; +"login_error_registration_is_not_supported" = "Đăng kí hiện đang không được hỗ trợ"; +"login_error_forbidden" = "Sai tên đăng nhập/mật khẩu"; +"login_error_unknown_token" = "Mã truy cập được chỉ định không được công nhận"; +"login_error_bad_json" = "Sai định dạng JSON"; +"login_error_not_json" = "Không chứa một JSON hợp lệ"; +"login_error_limit_exceeded" = "Quá nhiều yêu cầu đã được gửi"; +"login_error_user_in_use" = "Tên đăng nhập này đã được sử dụng"; +"login_error_login_email_not_yet" = "Liên kết email chưa được nhấp vào"; +"login_use_fallback" = "Sử dụng dự phòng"; +"login_leave_fallback" = "Huỷ"; +"login_invalid_param" = "Tham số không hợp lệ"; +"register_error_title" = "Đăng kí thất bại"; +"login_error_forgot_password_is_not_supported" = "Chức năng quên mật khẩu chưa hỗ trợ"; +"login_mobile_device" = "Di động"; +"login_tablet_device" = "Máy tính bảng"; +"login_desktop_device" = "Máy tính bàn"; +// Action +"no" = "Không"; +"yes" = "Có"; +"abort" = "Huỷ bỏ"; +"back" = "Trở về"; +"close" = "Đóng"; +"continue" = "Tiếp tục"; +"discard" = "Huỷ bỏ"; +"dismiss" = "Bỏ qua"; +"retry" = "Thử lại"; +"sign_up" = "Đăng kí"; +"submit" = "Gửi đi"; +"submit_code" = "Gửi mã lên"; +"set_default_power_level" = "Thiết lập lại cấp độ"; +"set_moderator" = "Đặt người kiểm duyệt"; +"set_admin" = "Đặt người quản trị"; +"start_chat" = "Bắt đầu chat"; +"start_voice_call" = "Bắt đầu cuộc gọi thoại"; +"start_video_call" = "Bắt đầu cuộc gọi video"; +"mention" = "Đề cập đến"; +"select_account" = "Chọn một tài khoản"; +"attach_media" = "Đính kèm Media từ thư viện"; +"capture_media" = "Chụp ảnh/quay video"; +"invite_user" = "Mời người dùng Matrix"; +"reset_to_default" = "Thiết lập lại về mặc định"; +"resend_message" = "Gửi lại tin nhắn"; +"select_all" = "Chọn tất cả"; +"cancel_upload" = "Huỷ tải lên"; +"cancel_download" = "Huỷ tải xuống"; +"show_details" = "Xem chi tiết"; +"answer_call" = "Trả lời cuộc gọi"; +"reject_call" = "Từ chối cuộc gọi"; +"end_call" = "Kết thúc cuộc gọi"; +"ignore" = "Bỏ qua"; +"unignore" = "Huỷ bỏ qua"; +// Events formatter +"notice_avatar_changed_too" = "(hình đại diện cũng đã được thay đổi)"; +"notice_room_name_removed" = "%@ bị gỡ bỏ khỏi tên của phòng"; +"notice_room_topic_removed" = "%@ bị gỡ bỏ khỏi chủ đề của phòng"; +"notice_event_redacted" = ""; +"notice_event_redacted_by" = " bởi %@"; +"notice_event_redacted_reason" = " [lí do: %@]"; +"notice_profile_change_redacted" = "%@ cập nhật hồ sơ của họ %@"; +"notice_room_created" = "%@ đã tạo phòng"; +"notice_room_join_rule" = "Quy tắc tham gia là: %@"; +"notice_room_power_level_intro" = "Công suất của thành viên trong phòng:"; +"notice_room_power_level_acting_requirement" = "Mức công suất tối thiểu mà người dùng phải có trước khi hoạt động là:"; +"notice_room_power_level_event_requirement" = "Mức công suất tối thiểu liên quan đến sự kiện là:"; +"notice_room_aliases" = "Các bí danh phòng là:% @"; +"notice_encrypted_message" = "Tin nhắn đã được mã hoá"; +"notice_encryption_enabled" = "%@ đã bật mã hoá end-to-end (thuật toán %@)"; +"notice_image_attachment" = "hình ảnh đính kèm"; +"notice_audio_attachment" = "âm thanh đính kèm"; +"notice_video_attachment" = "video đính kèm"; +"notice_location_attachment" = "địa điểm đính kèm"; +"notice_file_attachment" = "tập tin đính kèm"; +"notice_invalid_attachment" = "đính kèm không hợp lệ"; +"notice_unsupported_attachment" = "Đính kèm chưa được hỗ trợ: %@"; +"notice_feedback" = "Sự kiện phản hồi (id: %@): %@"; +"notice_redaction" = "%@ đã biên soạn một sự kiện (id: %@)"; +"notice_error_unsupported_event" = "Sự kiện không được hỗ trợ"; +"notice_error_unexpected_event" = "Sự kiện bất ngờ"; +"notice_error_unknown_event_type" = "Loại sự kiện không xác định"; +"notice_room_history_visible_to_anyone" = "%@ làm cho mọi người có thể thấy lịch sử phòng."; +"notice_room_history_visible_to_members" = "%@ làm cho thành viên trong phòng có thể thấy được lịch sử phòng."; +"notice_room_history_visible_to_members_from_invited_point" = "%@ làm cho mọi thành viên có thể thấy được lịch sử phòng từ thời điểm được mời."; +"notice_room_history_visible_to_members_from_joined_point" = "%@ làm cho mọi người có thể thấy lịch sử phòng từ thời điểm họ tham gia."; +"notice_crypto_unable_to_decrypt" = "** Không thể giải mã: %@ **"; +"notice_crypto_error_unknown_inbound_session_id" = "Thiết bị gửi đi đã không gửi cho chúng ta các khoá giải mã tin nhắn."; +// room display name +"room_displayname_empty_room" = "Phòng trống"; +"room_displayname_two_members" = "%@ và %@"; +"room_displayname_more_than_two_members" = "%@ và %u người khác"; +// Settings +"settings" = "Cài đặt"; +"settings_enable_inapp_notifications" = "Bật thông báo bên trong ứng dụng"; +"settings_enable_push_notifications" = "Bật thông báo"; +"settings_enter_validation_token_for" = "Nhập mã thông báo xác nhận cho %@:"; +"notification_settings_room_rule_title" = "Phòng chat: '%@'"; +// Devices +"device_details_title" = "Thông tin thiết bị\n"; +"device_details_name" = "Tên\n"; +"device_details_identifier" = "ID Thiết bị\n"; +"device_details_last_seen" = "Lần cuối nhìn thấy\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_rename_prompt_message" = "Tên thiết bị:"; +"device_details_delete_prompt_title" = "Xác thực"; +"device_details_delete_prompt_message" = "Thao tác này yêu cầu xác thực bổ sung.\nĐể tiếp tục, vui lòng nhập mật khẩu của bạn."; +// Encryption information +"room_event_encryption_info_title" = "Thông tin mã hoá end-to-end\n\n"; +"room_event_encryption_info_event" = "Thông tin sự kiện\n"; +"room_event_encryption_info_event_user_id" = "ID Người dùng\n"; +"room_event_encryption_info_event_identity_key" = "Khoá xác thực Curve25519\n"; +"room_event_encryption_info_event_fingerprint_key" = "Chìa khoá vân tay Claimed Ed25519\n"; +"room_event_encryption_info_event_algorithm" = "Thuật toán\n"; +"room_event_encryption_info_event_session_id" = "ID Phiên làm việc\n"; +"room_event_encryption_info_event_decryption_error" = "Lỗi giải mã\n"; +"room_event_encryption_info_event_unencrypted" = "đã giải mã"; +"room_event_encryption_info_event_none" = "không"; +"room_event_encryption_info_device" = "\nThông tin thiết bị gửi đi\n"; +"room_event_encryption_info_device_unknown" = "thiết bị không xác định\n"; +"room_event_encryption_info_device_name" = "Tên\n"; +"room_event_encryption_info_device_id" = "ID Thiết bị\n"; +"room_event_encryption_info_device_verification" = "Xác minh\n"; +"room_event_encryption_info_device_fingerprint" = "Dấu vân tay Claimed Ed25519\n"; +"room_event_encryption_info_device_verified" = "Đã xác thực"; +"room_event_encryption_info_device_not_verified" = "CHƯA xác thực"; +"room_event_encryption_info_device_blocked" = "Danh sách đen"; +"room_event_encryption_info_verify" = "Xác thực..."; +"room_event_encryption_info_unverify" = "Huỷ xác thực"; +"room_event_encryption_info_block" = "Danh sách đen"; +"room_event_encryption_info_unblock" = "Huỷ danh sách đen"; +"room_event_encryption_verify_title" = "Xác minh thiết bị\n\n"; +"room_event_encryption_verify_message" = "Để xác minh rằng thiết bị này có thể tin cậy, vui lòng liên hệ chủ sở hữu bằng cách sử dụng một số phương tiện khác (ví dụ: trực tiếp hoặc qua điện thoại) và yêu cầu họ xem khoá mà họ nhìn thấy trong \"Cài đặt người dùng\" cho thiết bị này có trùng khớp với khoá dưới đây:\n\n\tTên thiết bị: %@\n\tID thiết bị: %@\n\tKhóa thiết bị: %@\n\nNếu nó trùng khớp, nhấn nút xác minh dưới đây. Nếu không, thì ai đó đang chặn thiết bị này và bạn có thể muốn nhấn nút danh sách đen thay vì nhấn nút xác minh.\n\nTrong tương lai quá trình xác minh này sẽ phức tạp hơn."; +"room_event_encryption_verify_ok" = "Xác thực"; +// Account +"account_save_changes" = "Lưu thay đổi"; +"account_link_email" = "Liên kết email"; +"account_linked_emails" = "Email đã được liên kết"; +"account_email_validation_title" = "Xác minh đang chờ xử lí"; +"account_email_validation_message" = "Vui lòng check email của bạn và nhấn vào đường dẫn đính kèm trong đó. Sau khi việc đó hoàn thành, nhấn tiếp tục."; +"account_email_validation_error" = "Không thể xác minh địa chỉ email. Vui lòng kiểm tra email của bạn và nhấn vào liên kết mà nó được đính kèm. Sau khi việc đó hoàn thành, nhất tiếp tục"; +"account_msisdn_validation_title" = "Xác minh đang chờ xử lí"; +"account_msisdn_validation_message" = "Chúng tôi đã gửi mã kích hoạt qua SMS. Vui lòng nhập mã kích hoạt bên dưới."; +"account_msisdn_validation_error" = "Không thể xác thực số điện thoại."; +"account_error_display_name_change_failed" = "Thay đổi tên hiển thị thất bại"; +"account_error_picture_change_failed" = "Thay đổi hình ảnh thất bại"; +"account_error_matrix_session_is_not_opened" = "Phiên làm việc matrix chưa được mở"; +"account_error_email_wrong_title" = "Địa chỉ email không hợp lệ"; +"account_error_email_wrong_description" = "Nó có vẻ không phải là một địa chỉ email hợp lệ"; +"account_error_msisdn_wrong_title" = "Số điện thoại không hợp lệ"; +"account_error_msisdn_wrong_description" = "Nó có vẻ không phải là một số điện thoại hợp lệ"; +// Room creation +"room_creation_name_title" = "Tên phòng:"; +"room_creation_name_placeholder" = "(ví dụ: lunchGroup)"; +"room_creation_alias_title" = "Bí danh phòng:"; +"room_creation_alias_placeholder" = "(ví dụ: #foo:example.org)"; +"room_creation_alias_placeholder_with_homeserver" = "(ví dụ: #foo%@)"; +"room_creation_participants_title" = "Người tham gia:"; +"room_creation_participants_placeholder" = "(ví dụ: @bob:homeserver1; @john:homeserver2...)"; +// Room +"room_please_select" = "Vui lòng chọn một phòng"; +"room_error_join_failed_title" = "Tham gia phòng thất bại"; +"room_error_join_failed_empty_room" = "Hiện tại không thể tham gia lại phòng trống."; +"room_error_name_edition_not_authorized" = "Bạn chưa được uỷ quyền để sửa tên phòng"; +"room_error_topic_edition_not_authorized" = "Bạn chưa được uỷ quyền để sửa chủ đề phòng"; +"room_error_cannot_load_timeline" = "Không tải được dòng thời gian"; +"room_error_timeline_event_not_found_title" = "Không tải được vị trí dòng thời gian"; +"room_error_timeline_event_not_found" = "Ứng dụng đã cố gắng tải một điểm cụ thể trong thời gian của phòng này nhưng không thể tìm thấy nó"; +"room_left" = "Bạn đã rời phòng"; +"room_no_power_to_create_conference_call" = "Bạn cần cấp quyền để mời bắt đầu cuộc gọi hội họp trong phòng này"; +"room_no_conference_call_in_encrypted_rooms" = "Gọi hội họp chưa được hỗ trợ trong các phòng đã mã hoá"; +// Room members +"room_member_ignore_prompt" = "Bạn có chắc chắn muốn ẩn tất cả tin nhắn từ người dùng này?"; +"room_member_power_level_prompt" = "Bạn sẽ không thể hoàn tác thay đổi này khi bạn đang quảng cáo cho người dùng có mức năng lượng như chính bạn.\nBạn có chắc không?"; +// Attachment +"attachment_size_prompt" = "Bạn có muốn gửi dưới dạng:"; +"attachment_original" = "Kích thước thực sự: %@"; +"attachment_small" = "Nhỏ: %@"; +"attachment_medium" = "Trung bình: %@"; +"attachment_large" = "Lớn: %@"; +"attachment_cancel_download" = "Huỷ bỏ tải xuống?"; +"attachment_cancel_upload" = "Huỷ bỏ tải lên?"; +"attachment_multiselection_size_prompt" = "Bạn có muốn gửi hình ảnh dưới dạng:"; +"attachment_multiselection_original" = "Kích thước thực sự"; +"attachment_e2e_keys_file_prompt" = "Tập tin này chứa các khoá mã hoá được xuất từ một máy khách Matrix.\nBạn có muốn xem nội dung tập tin hoặc nhập khẩu các khóa nó chứa?"; +"attachment_e2e_keys_import" = "Nhập..."; +// Contacts +"contact_mx_users" = "Người dùng của Matrix"; +"contact_local_contacts" = "Danh bạ"; +// Search +"search_no_results" = "Không có kết quả"; +"search_searching" = "Đang tìm kiếm..."; +// Time +"format_time_s" = "giây"; +"format_time_m" = "phút"; +"format_time_h" = "giờ"; +"format_time_d" = "ngày"; +// E2E import +"e2e_import_room_keys" = "Nhập khóa phòng"; +"e2e_import_prompt" = "Quá trình này cho phép bạn nhập các khoá mã hoá mà trước đây bạn đã xuất từ một máy khách Matrix khác. Sau đó, bạn sẽ có thể giải mã bất kỳ thư nào mà khách hàng khác có thể giải mã.\nTệp xuất được bảo vệ bằng cụm mật khẩu. Bạn nên nhập cụm từ mật khẩu ở đây, để giải mã tệp."; +"e2e_import" = "Nhập"; +"e2e_passphrase_enter" = "Nhập cụm mật khẩu"; +// E2E export +"e2e_export_room_keys" = "Xuất các khoá của phòng"; +"e2e_export_prompt" = "Quá trình này cho phép bạn xuất khẩu các khoá cho tin nhắn bạn đã nhận được trong các phòng được mã hoá tới một tập tin cục bộ. Sau đó, bạn sẽ có thể nhập tập tin vào máy khách Matrix khác trong tương lai, để khách hàng cũng có thể giải mã các thư này.\nTập tin được xuất sẽ cho phép bất kỳ ai có thể đọc nó để giải mã bất kỳ tin nhắn nào được mã hóa mà bạn có thể thấy, do đó bạn nên cẩn thận để giữ an toàn."; +"e2e_export" = "Xuất"; +"e2e_passphrase_confirm" = "Xác nhận cụm mật khẩu"; +"e2e_passphrase_empty" = "Cụm mật khẩu không được để trống"; +"e2e_passphrase_not_match" = "Cụm mật khẩu phải trùng khớp"; +// Others +"user_id_title" = "ID Người dùng:"; +"offline" = "ngoại tuyến"; +"unsent" = "Chưa được gửi"; +"error" = "Lỗi"; +"not_supported_yet" = "Chưa hỗ trợ"; +"default" = "mặc định"; +"private" = "Riêng tư"; +"public" = "Công khai"; +"power_level" = "Độ nhiệt huyết"; +"network_error_not_reachable" = "Vui lòng kiểm tra kết nối mạng của bạn"; +"user_id_placeholder" = "ví dụ: @bob:homeserver"; +"ssl_homeserver_url" = "Home Server URL: %@"; +// Permissions +"camera_access_not_granted_for_call" = "Cuộc gọi video yêu cầu quyền truy cập tới máy ảnh nhưng %@ chưa có quyền để sử dụng nó"; +"microphone_access_not_granted_for_call" = "Cuộc gọi thoại yêu cầu truy cập tới Microphone nhưng %@ chưa có quyền để sử dụng nó"; +"local_contacts_access_not_granted" = "Người dùng tìm thấy từ địa chỉ liên hệ cục bộ yêu cầu quyền truy cập vào danh sách liên hệ của bạn nhưng %@ không có quyền sử dụng nó"; +"local_contacts_access_discovery_warning_title" = "Quét người dùng"; +"local_contacts_access_discovery_warning" = "%@ muốn tải lên danh sách email và số điện thoại từ danh bạ của bạn để quét người dùng"; +// Country picker +"country_picker_title" = "Chọn một quốc gia"; +// Language picker +"language_picker_title" = "Chọn một ngôn ngữ"; +"language_picker_default_language" = "Mặc định (%@)"; +"notice_room_invite" = "%@ đã mời %@"; +"notice_room_third_party_invite" = "%@ gửi một lời mời tới %@ để tham gia phòng"; +"notice_room_third_party_registered_invite" = "%@ chấp nhận lời mời cho %@"; +"notice_room_join" = "%@ đã tham gia"; +"notice_room_leave" = "%@ đã rời"; +"notice_room_reject" = "%@ từ chối lời yêu cầu"; +"notice_room_kick" = "%@ đã đá %@ ra khỏi phòng"; +"notice_room_unban" = "%@ đã huỷ cấm %@"; +"notice_room_ban" = "%@ đã cấm %@"; +"notice_room_withdraw" = "%@ đã thu hồi lời mời của %@"; +"notice_room_reason" = ". Lý do: %@"; +"notice_avatar_url_changed" = "%@ đã thay đổi ảnh đại diện của họ"; +"notice_display_name_set" = "%@ đặt tên hiển thị của họ thành %@"; +"notice_display_name_changed_from" = "%@ thay đổi tên hiển thị của họ từ %@ thành %@"; +"notice_display_name_removed" = "%@ đã gỡ bỏ tên hiển thị của họ"; +"notice_topic_changed" = "%@ thay đổi chủ đề thành: %@"; +"notice_room_name_changed" = "%@ đã thay đổi tên phòng thành: %@"; +"notice_placed_voice_call" = "%@ tạo một cuộc gọi thoại"; +"notice_placed_video_call" = "%@ tạo một cuộc gọi video"; +"notice_answered_video_call" = "%@ đã trả lời cuộc gọi"; +"notice_ended_video_call" = "%@ kết thúc cuộc gọi"; +"notice_conference_call_request" = "%@ yêu cầu một hội nghị VoIP"; +"notice_conference_call_started" = "Cuộc gọi hội nghị VoIP đã bắt đầu"; +"notice_conference_call_finished" = "Cuộc gọi hội nghị VoIP đã kết thúc"; +// button names +"ok" = "OK"; +"cancel" = "Huỷ"; +"save" = "Lưu"; +"leave" = "Rời khỏi"; +"send" = "Gửi"; +"copy_button_name" = "Sao chép"; +"resend" = "Gửi lại"; +"redact" = "Biên tập lại"; +"share" = "Chia sẻ"; +"set_power_level" = "Độ nhiệt huyết"; +"delete" = "Xoá"; +"view" = "Xem"; +// actions +"action_logout" = "Đăng xuất"; +"create_room" = "Tạo phòng"; +"login" = "Đăng nhập"; +"create_account" = "Tạo tài khoản"; +"membership_invite" = "Đã mời"; +"membership_leave" = "Đã rời"; +"membership_ban" = "Bị cấm"; +"num_members_one" = "%@ người dùng"; +"num_members_other" = "%@ người dùng"; +"invite" = "Mời"; +"kick" = "Đá"; +"ban" = "Cấm"; +"unban" = "Huỷ cấm"; +"message_unsaved_changes" = "Có vài thay đổi chưa được lưu. Rời khỏi sẽ mất những thay đổi này."; +// Login Screen +"login_error_already_logged_in" = "Đã đăng nhập"; +"login_error_must_start_http" = "URL phải bắt đầu với http[s]://"; +// room details dialog screen +"room_details_title" = "Chi tiết phòng chat"; +// contacts list screen +"invitation_message" = "Tôi muốn trò chuyện với bạn với matrix. Vui lòng truy cập trang web http://matrix.org để biết thêm thông tin."; +// Settings screen +"settings_title_config" = "Cấu hình"; +"settings_title_notifications" = "Thông báo"; +// Notification settings screen +"notification_settings_disable_all" = "Tắt tất cả thông báo"; +"notification_settings_enable_notifications" = "Bật thông báo"; +"notification_settings_enable_notifications_warning" = "Tất cả thông báo hiện tại đang bị vô hiệu hoá cho tất cả thiết bị."; +"notification_settings_global_info" = "Cài đặt thông báo được lưu vào tài khoản người dùng của bạn và được chia sẻ giữa tất cả các máy khách có hỗ trợ chúng (bao gồm thông báo trên máy bàn).\n\nCác quy tắc được áp dụng theo thứ tự; quy tắc đầu tiên khớp với định nghĩa của thông báo đến từ bên ngoài.\nVì vậy: Thông báo mỗi lần quan trọng hơn thông báo cho mỗi phòng điều mà quan trọng hơn thông báo mỗi người gửi.\nĐối với nhiều quy tắc cùng loại, lựa chọn đầu tiên trong danh sách phù hợp sẽ được ưu tiên."; +"notification_settings_per_word_notifications" = "Thông báo mỗi từ"; +"notification_settings_per_word_info" = "Các từ khớp với trường hợp vô nghĩa và có thể bao gồm ký tự đại diện *. Vì thế:\nfoo phù hợp với chuỗi foo bao quanh bởi từ phân cách chữ (ví dụ: chấm câu và khoảng trắng hoặc bắt đầu / kết thúc dòng).\nfoo * khớp với bất kỳ từ nào bắt đầu foo.\n* foo * khớp bất kỳ từ nào trong đó bao gồm 3 chữ foo."; +"notification_settings_always_notify" = "Luôn thông báo"; +"notification_settings_never_notify" = "Không bao giờ thông báo"; +"notification_settings_word_to_match" = "từ để so khớp"; +"notification_settings_highlight" = "Điểm nhấn"; +"notification_settings_custom_sound" = "Âm thanh tuỳ chỉnh"; +"notification_settings_per_room_notifications" = "Thông báo mỗi phòng"; +"notification_settings_per_sender_notifications" = "Thông báo mỗi người gửi"; +"notification_settings_sender_hint" = "@nguoidung:tenmien.com"; +"notification_settings_select_room" = "Chọn một phòng"; +"notification_settings_other_alerts" = "Các thông báo khác"; +"notification_settings_contain_my_user_name" = "Thông báo cho tôi với âm thanh về các tin nhắn chứa tên của tôi"; +"notification_settings_contain_my_display_name" = "Thông báo cho tôi với âm thanh về các tin nhắn chưa tên hiển thị của tôi"; +"notification_settings_just_sent_to_me" = "Thông báo cho tôi với âm thành về các tin nhắn chỉ gửi cho tôi"; +"notification_settings_invite_to_a_new_room" = "Thông báo cho tôi khi tôi được mời vào một phòng mới"; +"notification_settings_people_join_leave_rooms" = "Thông báo cho tôi khi mọi người tham gia hoặc rời các phòng"; +"notification_settings_receive_a_call" = "Thông báo cho tôi khi tôi nhận cuộc gọi"; +"notification_settings_suppress_from_bots" = "Chặn thông báo từ các bot"; +"notification_settings_by_default" = "Theo mặc định..."; +"notification_settings_notify_all_other" = "Thông báo cho tất cả tin nhắn/phòng"; +// gcm section +"settings_config_home_server" = "Home server: %@"; +"settings_config_identity_server" = "Máy chủ xác thực: %@"; +"settings_config_user_id" = "ID Người dùng: %@"; +// call string +"call_waiting" = "Đang đợi..."; +"call_connecting" = "Đang kết nối cuộc gọi..."; +"call_ended" = "Cuộc gọi kết thúc"; +"call_ring" = "Đang gọi..."; +"incoming_video_call" = "Cuộc gọi video tới"; +"incoming_voice_call" = "Cuộc gọi thoại tới"; +"call_invite_expired" = "Lời mời cuộc gọi đã quá hạn"; +// unrecognized SSL certificate +"ssl_trust" = "Tin tưởng"; +"ssl_logout_account" = "Đăng xuất"; +"ssl_remain_offline" = "Bỏ qua"; +"ssl_fingerprint_hash" = "Vân tay (%@):"; +"ssl_could_not_verify" = "Không thể xác minh danh tính của máy chủ từ xa."; +"ssl_cert_not_trust" = "Điều này có thể có nghĩa là ai đó đang ngăn chặn lưu lượng truy cập của bạn, hoặc điện thoại của bạn không tin tưởng vào chứng chỉ được cung cấp bởi máy chủ từ xa."; +"ssl_cert_new_account_expl" = "Nếu quản trị viên máy chủ đã nói rằng điều này được mong đợi, đảm bảo rằng các dấu vân tay dưới đây phù hợp với dấu vân tay được cung cấp bởi chúng."; +"ssl_unexpected_existing_expl" = "Chứng chỉ đã thay đổi từ chứng chỉ mà điện thoại của bạn tin cậy. Điều này là KHÔNG THƯỜNG XUYÊN. Chúng tôi khuyên bạn KHÔNG CHẤP NHẬN chứng chỉ mới này."; +"ssl_expected_existing_expl" = "Chứng chỉ đã thay đổi từ một tài khoản đáng tin cậy trước đó sang chứng chỉ không đáng tin cậy. Máy chủ có thể đã gia hạn chứng chỉ của nó. Liên hệ với quản trị viên máy chủ để lấy dấu vân tay được mong đợi."; +"ssl_only_accept" = "CHỈ chấp nhận chứng chỉ nếu quản trị viên máy chủ đã xuất bản một dấu vân tay phù hợp với bảng trên."; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/vls.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/vls.lproj/MatrixKit.strings new file mode 100644 index 000000000..0e58e8e60 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/vls.lproj/MatrixKit.strings @@ -0,0 +1,123 @@ +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "Account anmoakn:"; +"login_server_url_placeholder" = "URL (bv. https://matrix.org)"; +"login_home_server_title" = "Thuusserver:"; +"login_home_server_info" = "Je thuusserver sloat al je gespreks- en accountgegeevns ip"; +"login_identity_server_title" = "Identiteitsserver:"; +"login_user_id_placeholder" = "Matrix-ID (bv. @jean:matrix.org of jean)"; +"login_password_placeholder" = "Paswoord"; +"login_optional_field" = "optioneel"; +"login_display_name_placeholder" = "Weergoavenoame (bv. Jean Kieckens)"; +"login_email_info" = "Door een e-mailadresse in te geevn kunn andere gebruukers je gemakkeliker ip Matrix wereviendn, en 't gift jen ook e maniere voor in den toekomst je paswoord te verandern."; +"login_email_placeholder" = "E-mailadresse"; +"login_prompt_email_token" = "Gif jen e-mailadressevalidoatiebewys in:"; +"login_error_title" = "Anmeldn mislukt"; +"login_error_no_login_flow" = "Iphoaln van de authenticoatie-informoatie van dezen thuusserver is mislukt"; +"view" = "Toogn"; +"login_identity_server_info" = "Matrix verstrekt identiteitsservers vo t’achterhoaln wukke e-mailadressn e.d.m. dat der by wukke Matrix-ID’s hoorn. Tot nu toe bestoat alleene mo https://matrix.org."; +"login_error_do_not_support_login_flows" = "Vo de moment biedn we geen oundersteunienge vo sommigste of alle anmeldiengsmethoodn van dezen thuusserver"; +"login_error_registration_is_not_supported" = "Registroasje wor vo de moment nie oundersteund"; +"login_error_forbidden" = "Oungeldig(e) gebrukersnoame/paswoord"; +"login_error_unknown_token" = "’t Ingegeevn toegangsbewys is nie herkend"; +"login_error_bad_json" = "Oungeldigen JSON"; +"login_error_not_json" = "Bevat gene geldigen JSON"; +"login_error_limit_exceeded" = "’t Zyn te vele verzoekn verzonden gewist"; +"login_error_user_in_use" = "Deze gebrukersnoame is al in gebruuk"; +"login_error_login_email_not_yet" = "De koppelienge in den e-mail is nog nie geopend gewist"; +"login_use_fallback" = "Weerevalblad gebruukn"; +"login_leave_fallback" = "Annuleern"; +"login_invalid_param" = "Oungeldige parameter"; +"register_error_title" = "Registroasje mislukt"; +"login_error_forgot_password_is_not_supported" = "Paswoord vergeten wor vo de moment nog nie oundersteund"; +"login_mobile_device" = "Gsm"; +"login_tablet_device" = "Tablet"; +"login_desktop_device" = "Computer"; +"login_error_resource_limit_exceeded_title" = "Bronlimiet overschreedn"; +"login_error_resource_limit_exceeded_message_default" = "Dezen thuusserver èt één of meer van z’n bronlimietn overschreedn."; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "Dezen thuusserver è z’n limiet vo moandeliks actieve gebrukers bereikt."; +"login_error_resource_limit_exceeded_message_contact" = "\n\nContacteert je dienstbeheerder vo deze dienst te bluuvn gebruukn."; +"login_error_resource_limit_exceeded_contact_button" = "Beheerder contacteern"; +// Action +"no" = "Nee"; +"yes" = "Ja"; +"abort" = "Afbreekn"; +"back" = "Weere"; +"close" = "Sluutn"; +"continue" = "Verdergoan"; +"discard" = "Verwerpn"; +"dismiss" = "Sluutn"; +"retry" = "Herprobeern"; +"sign_up" = "Anmeldn"; +"submit" = "Indienn"; +"submit_code" = "Code indienn"; +"set_power_level" = "Machtsniveau instelln"; +"set_default_power_level" = "Machtsniveau herinstelln"; +"set_moderator" = "Benoemn tou moderator"; +"set_admin" = "Benoemn tou beheerder"; +"start_chat" = "Gesprek beginn"; +"start_voice_call" = "Sproakiproep beginn"; +"start_video_call" = "Video-iproep beginn"; +"mention" = "Vermeldn"; +"select_account" = "Selecteert een account"; +"attach_media" = "Media van de bibliotheek byvoegn"; +"capture_media" = "Fotootje/filmtje moakn"; +"invite_user" = "Matrix-gebruker uutnodign"; +"reset_to_default" = "Standoardwoardn herstelln"; +"resend_message" = "Bericht hersteurn"; +"select_all" = "Alles selecteern"; +"cancel_upload" = "Upload annuleern"; +"cancel_download" = "Download annuleern"; +"show_details" = "Details weeregeevn"; +"answer_call" = "Iproep beantwoordn"; +"reject_call" = "Iproep afwyzn"; +"end_call" = "Iphangn"; +"ignore" = "Negeern"; +"unignore" = "Stoppen me negeern"; +// Events formatter +"notice_avatar_changed_too" = "(profielfoto is ook veranderd gewist)"; +"notice_room_name_removed" = "%@ èt de gespreksnoame verwyderd"; +"notice_room_topic_removed" = "%@ è ’t ounderwerp verwyderd"; +"notice_event_redacted" = ""; +"notice_event_redacted_by" = " deur %@"; +"notice_event_redacted_reason" = " [reden: %@]"; +"notice_profile_change_redacted" = "%@ èt zyn/heur profiel bygewerkt %@"; +"notice_room_created" = "%@ è ’t gesprek angemakt"; +"notice_room_join_rule" = "De toetrediengsregel is: %@"; +"notice_room_power_level_intro" = "’t Machtsniveau van gespreksleden is:"; +"notice_room_power_level_acting_requirement" = "De minimoale machtsniveau waarover dat e gebruker moe beschikkn vooraleer da t’n kut handeln zyn:"; +"notice_room_power_level_event_requirement" = "De minimoale machtsniveaus gerelateerd an gebeurtenissn zyn:"; +"notice_room_aliases" = "De gespreksbynoamn zyn: %@"; +"notice_room_related_groups" = "De groepn da geassocieerd zyn me da gesprek hier zyn: %@"; +"notice_encrypted_message" = "Versleuterd bericht"; +"notice_encryption_enabled" = "%@ èt end-tout-end-versleuterienge angezet (%@-algoritme)"; +"notice_image_attachment" = "fotobylage"; +"notice_audio_attachment" = "geluudsbylage"; +"notice_video_attachment" = "videobylage"; +"notice_location_attachment" = "locoasjebylage"; +"notice_file_attachment" = "bestandsbylage"; +"notice_invalid_attachment" = "oungeldige bylage"; +"notice_unsupported_attachment" = "Nie-oundersteunde bylage: %@"; +"notice_feedback" = "Feedbackgebeurtenisse (id: %@): %@"; +"notice_redaction" = "%@ èt e gebeurtenisse verwyderd (id: %@)"; +"notice_error_unsupported_event" = "Nie-oundersteunde gebeurtenisse"; +"notice_error_unexpected_event" = "Ounverwachte gebeurtenisse"; +"notice_error_unknown_event_type" = "Ounbekend gebeurtenistype"; +"notice_room_history_visible_to_anyone" = "%@ èt de toekomstige gesprekgeschiedenisse voor iedereen zichtboar gemakt."; +"cancel" = "Annuleern"; +"save" = "Ipsloan"; +"leave" = "Verloatn"; +"invite" = "Uutnodign"; +"notice_room_history_visible_to_members" = "%@ èt de toekomstige gespreksgeschiedenisse voor alle gespreksleedn zichtboar gemakt ghed."; +"notice_crypto_unable_to_decrypt" = "** Kostege nie ountsleutern: %@ **"; +"notice_crypto_error_unknown_inbound_session_id" = "’t Toestel van den afzender èt uus geen sleuters vo da bericht hier gestuurd ghed."; +"notice_sticker" = "sticker"; +"notice_in_reply_to" = "In antwoord op"; +// room display name +"room_displayname_empty_room" = "Leeg gesprek"; +"room_displayname_two_members" = "%@ en %@"; +"room_displayname_more_than_two_members" = "%@ en %@ anderen"; +// Settings +"settings" = "Instelliengn"; +"settings_enable_inapp_notifications" = "In-app-meldiengn inschoakeln"; +"settings_enable_push_notifications" = "Pushmeldiengn inschoakeln"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/zh_Hans.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/zh_Hans.lproj/MatrixKit.strings new file mode 100644 index 000000000..b1a270dd2 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/zh_Hans.lproj/MatrixKit.strings @@ -0,0 +1,480 @@ +"view" = "视图"; +"back" = "返回"; +"continue" = "继续"; +"leave" = "离开"; +"invite" = "邀请"; +"retry" = "重试"; +"cancel" = "取消"; +"save" = "保存"; +// room details dialog screen +"room_details_title" = "聊天室详情"; +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "创建账户:"; +"login_server_url_placeholder" = "网址(例如 https://matrix.org)"; +"login_home_server_title" = "主服务器网址:"; +"login_home_server_info" = "您的主服务器存储了您所有的对话和账户数据"; +"login_identity_server_title" = "身份认证服务器网址:"; +"login_identity_server_info" = "Matrix 提供了身份认证服务器以验证邮箱地址等是否属于某个 Matrix ID。目前只有 https://matrix.org 支持。"; +"login_user_id_placeholder" = "Matrix ID(例如 @bob:matrix.org 或 bob)"; +"login_password_placeholder" = "密码"; +"login_optional_field" = "可选"; +"login_display_name_placeholder" = "显示名(例如 Bob Obson)"; +"login_email_placeholder" = "电子邮件地址"; +"login_prompt_email_token" = "请输入您的电子邮件认证令牌:"; +"login_error_title" = "登录失败"; +"login_error_registration_is_not_supported" = "目前不支持注册"; +"login_error_forbidden" = "无效的用户名/密码"; +"login_error_unknown_token" = "不能识别特定访问令牌"; +"login_error_bad_json" = "JSON 格式错误"; +"login_error_not_json" = "不包含有效的 JSON"; +"login_error_limit_exceeded" = "发送了太多的请求"; +"login_error_user_in_use" = "此用户名已占用"; +"login_error_login_email_not_yet" = "此电子邮件链接还没有被点击"; +"login_use_fallback" = "使用备用页面"; +"login_leave_fallback" = "取消"; +"login_invalid_param" = "参数无效"; +"register_error_title" = "注册失败"; +"login_error_forgot_password_is_not_supported" = "当前不支持忘记密码"; +// Action +"no" = "否"; +"yes" = "是"; +"abort" = "中止"; +"close" = "关闭"; +"discard" = "放弃"; +"dismiss" = "无视"; +"sign_up" = "注册"; +"submit" = "提交"; +"submit_code" = "提交码"; +"set_default_power_level" = "重置权限级别"; +"set_moderator" = "设置主持人"; +"set_admin" = "设置管理员"; +"start_chat" = "启动聊天"; +"start_voice_call" = "启动语音通话"; +"start_video_call" = "启动视频通话"; +"mention" = "提到"; +"select_account" = "选择一个账户"; +"attach_media" = "从库附加媒体"; +"capture_media" = "拍照片/视频"; +"invite_user" = "邀请 Matrix 用户"; +"reset_to_default" = "重置到默认"; +"resend_message" = "重新发送此消息"; +"select_all" = "选择全部"; +"cancel_upload" = "取消上传"; +"cancel_download" = "取消下载"; +"show_details" = "显示详情"; +"answer_call" = "接听通话"; +"reject_call" = "拒绝通话"; +"end_call" = "结束通话"; +"ignore" = "忽略"; +"unignore" = "取消忽略"; +// Events formatter +"notice_avatar_changed_too" = "(头像也已经改变)"; +"notice_room_name_removed" = "%@ 移除了此聊天室的名称"; +"notice_room_topic_removed" = "%@ 移除了话题"; +"notice_event_redacted" = ""; +"notice_event_redacted_by" = " 由 %@"; +"notice_event_redacted_reason" = " [理由:%@]"; +"notice_profile_change_redacted" = "%@ 已经更新了他的个人档案 %@"; +"notice_room_created" = "%@ 创建并配置了此聊天室。"; +"notice_room_join_rule" = "加入规则是:%@"; +"notice_room_power_level_intro" = "此聊天室成员的权限级别是:"; +"notice_room_power_level_acting_requirement" = "要进行此操作,用户必须具备的最低权限级别是 :"; +"notice_room_power_level_event_requirement" = "事件所需的最小权限级别:"; +"notice_room_aliases" = "此聊天室的别名是:%@"; +"notice_encrypted_message" = "已加密消息"; +"notice_encryption_enabled" = "%@ 打开了端对端加密(算法 %@)"; +"notice_image_attachment" = "图片附件"; +"notice_audio_attachment" = "音频附件"; +"notice_video_attachment" = "视频附件"; +"notice_location_attachment" = "位置附件"; +"notice_file_attachment" = "文件附件"; +"notice_invalid_attachment" = "无效附件"; +"notice_unsupported_attachment" = "不支持的附件:%@"; +"login_email_info" = "指定邮箱地址可以让其他 Matrix 用户更容易找到您,并允许您可以在未来重置密码。"; +"login_error_no_login_flow" = "我们未能从此主服务器获取认证信息"; +"login_error_do_not_support_login_flows" = "当前我们不支持此主服务器定义的任何或者所有登录流"; +"notice_feedback" = "反馈事件 (id: %@):%@"; +"notice_redaction" = "%@ 取消了一个事件 (id: %@)"; +"notice_error_unsupported_event" = "不支持的事件"; +"notice_error_unexpected_event" = "意外的事件"; +"notice_error_unknown_event_type" = "未知的事件类型"; +"notice_room_history_visible_to_anyone" = "%@ 将未来的聊天室消息历史设为对所有人可见。"; +"notice_room_history_visible_to_members" = "%@ 将未来的聊天室消息历史设为对所有聊天室成员可见。"; +"notice_room_history_visible_to_members_from_invited_point" = "你将未来的聊天室消息历史设为对所有聊天室成员可见,从他们被邀请时开始。"; +"notice_room_history_visible_to_members_from_joined_point" = "%@ 将未来的聊天室消息历史设为对所有聊天室成员可见,从他们加入时开始。"; +"notice_crypto_unable_to_decrypt" = "** 无法解密:%@ **"; +"notice_crypto_error_unknown_inbound_session_id" = "发送者的会话没有向我们发送此消息的密钥。"; +// room display name +"room_displayname_empty_room" = "空聊天室"; +"room_displayname_two_members" = "%@ 和 %@"; +"room_displayname_more_than_two_members" = "%@ 和 %@ 个其他人"; +// Settings +"settings" = "设置"; +"settings_enable_inapp_notifications" = "启用应用内通知"; +"settings_enable_push_notifications" = "启用推送通知"; +"settings_enter_validation_token_for" = "请输入 %@ 的验证令牌:"; +"notification_settings_room_rule_title" = "聊天室:“%@”"; +// Devices +"device_details_title" = "会话信息\n"; +"device_details_name" = "公开名称\n"; +"device_details_identifier" = "ID\n"; +"device_details_last_seen" = "最近一次上线\n"; +"device_details_last_seen_format" = "%@ @ %@\n"; +"device_details_rename_prompt_message" = "会话的公开名称对与你联络的人可见"; +"device_details_delete_prompt_title" = "认证"; +"device_details_delete_prompt_message" = "此操作需要额外的认证。\n要继续,请输入您的密码。"; +// Encryption information +"room_event_encryption_info_title" = "端对端加密信息\n\n"; +"room_event_encryption_info_event" = "事件信息\n"; +"room_event_encryption_info_event_user_id" = "用户 ID\n"; +"room_event_encryption_info_event_identity_key" = "Curve25519 认证密钥\n"; +"login_mobile_device" = "移动设备"; +"login_tablet_device" = "平板电脑"; +"login_desktop_device" = "桌面设备"; +"notice_room_related_groups" = "和此聊天室关联的社群是:%@"; +"room_event_encryption_info_event_session_id" = "会话 ID\n"; +"room_event_encryption_info_event_decryption_error" = "解密错误\n"; +"room_event_encryption_info_event_unencrypted" = "未加密"; +"room_event_encryption_info_event_none" = "无"; +"room_event_encryption_info_device" = "\n发送者的会话信息\n"; +"room_event_encryption_info_device_unknown" = "未知会话\n"; +"room_event_encryption_info_device_name" = "公开名称\n"; +"room_event_encryption_info_device_id" = "ID\n"; +"room_event_encryption_info_device_verification" = "验证\n"; +"room_event_encryption_info_device_verified" = "已验证"; +"room_event_encryption_info_device_not_verified" = "未验证"; +"room_event_encryption_info_device_blocked" = "已拉黑"; +"room_event_encryption_info_verify" = "验证…"; +"room_event_encryption_info_unverify" = "取消验证"; +"room_event_encryption_verify_title" = "验证会话\n\n"; +"account_error_display_name_change_failed" = "昵称修改失败"; +"account_error_email_wrong_title" = "邮箱地址无效"; +"account_error_msisdn_wrong_title" = "手机号码无效"; +// Room creation +"room_creation_name_title" = "聊天室名称:"; +"room_creation_name_placeholder" = "(例如:今天中午吃啥)"; +// Room +"room_please_select" = "请选择一个聊天室"; +"room_error_join_failed_title" = "加入聊天室失败"; +"room_error_cannot_load_timeline" = "时间线加载失败"; +"room_error_timeline_event_not_found_title" = "时间线位置加载失败"; +"room_left" = "你离开了聊天室"; +"attachment_small" = "小 (~%@)"; +"attachment_medium" = "中 (~%@)"; +"attachment_large" = "大 (~%@)"; +"attachment_cancel_download" = "是否取消下载?"; +"attachment_cancel_upload" = "是否取消上传?"; +"attachment_e2e_keys_import" = "导入…"; +// Search +"search_no_results" = "没有结果"; +"search_searching" = "正在搜索…"; +// Time +"format_time_s" = "秒"; +"format_time_m" = "分钟"; +"format_time_h" = "小时"; +"format_time_d" = "天"; +// E2E import +"e2e_import_room_keys" = "导入聊天室密钥"; +"e2e_import" = "导入"; +"e2e_passphrase_enter" = "输入密码"; +// E2E export +"e2e_export_room_keys" = "导出聊天室密钥"; +"e2e_export" = "导出"; +"e2e_passphrase_empty" = "密码不能为空"; +// Others +"user_id_title" = "用户 ID:"; +"offline" = "离线"; +"unsent" = "取消发送"; +"error" = "错误"; +"default" = "默认"; +"private" = "私人"; +"public" = "公开"; +"power_level" = "权限级别"; +"network_error_not_reachable" = "请检查你的网络连接"; +"user_id_placeholder" = "例如:@bob:homeserver"; +"ssl_homeserver_url" = "主服务器网址:%@"; +// Country picker +"country_picker_title" = "请选择国家"; +// Language picker +"language_picker_title" = "请选择语言"; +"language_picker_default_language" = "默认(%@)"; +"notice_room_invite" = "%@ 邀请了 %@"; +"notice_room_third_party_invite" = "%@ 邀请 %@ 加入聊天室"; +"notice_room_third_party_registered_invite" = "%@ 同意了 %@ 的邀请"; +"notice_room_join" = "%@ 已加入"; +"notice_room_leave" = "%@ 已退出"; +"notice_room_reject" = "%@ 拒绝了邀请"; +"notice_room_kick" = "%@ 移除了 %@"; +"notice_room_unban" = "%@ 解封了 %@"; +"notice_room_ban" = "%@ 封禁了 %@"; +"notice_room_withdraw" = "%@ 撤回了对 %@ 的邀请"; +"notice_room_reason" = "。理由:%@"; +"notice_avatar_url_changed" = "%@ 更换了头像"; +"notice_display_name_set" = "%@ 将自己的昵称设置为 %@"; +"notice_display_name_changed_from" = "%@ 将自己的昵称从 %@ 改成 %@"; +"notice_display_name_removed" = "%@ 删除了自己的昵称"; +"notice_room_name_changed" = "%@ 将聊天室名称修改为 %@。"; +// button names +"ok" = "确定"; +"send" = "发送"; +"copy_button_name" = "复制"; +"resend" = "重新发送"; +"share" = "分享"; +"redact" = "移除"; +"set_power_level" = "设置权限级别"; +"delete" = "删除"; +"create_room" = "创建聊天室"; +"login" = "登录"; +"create_account" = "创建账号"; +"membership_invite" = "邀请"; +"membership_leave" = "退出"; +"membership_ban" = "已被封禁"; +"num_members_one" = "%@ 位用户"; +"num_members_other" = "%@ 位用户"; +"kick" = "移除"; +"ban" = "封禁"; +"unban" = "解封"; +// Login Screen +"login_error_already_logged_in" = "已登录"; +"login_error_must_start_http" = "URL 必须以 http[s]:// 开头"; +// Settings screen +"settings_title_config" = "选项"; +"settings_title_notifications" = "通知"; +// contacts list screen +"invitation_message" = "我想使用 Matrix 和你聊天。请访问 https://martix.org 以了解更多信息。"; +"account_error_matrix_session_is_not_opened" = "没有打开 Matrix 会话"; +"account_error_email_wrong_description" = "此邮箱地址似乎是无效的"; +"account_error_msisdn_wrong_description" = "此手机号码似乎是无效的"; +"room_creation_participants_placeholder" = "(例如:@bob:homeserver1; @john:homeserver2…)"; +"room_creation_participants_title" = "成员:"; +"room_creation_alias_placeholder_with_homeserver" = "(例如:#foo%@)"; +"room_creation_alias_placeholder" = "(例如:#foo:example.org)"; +"room_error_join_failed_empty_room" = "目前无法加入空聊天室。"; +"room_error_name_edition_not_authorized" = "你没有修改聊天室名称所需的权限"; +"room_error_topic_edition_not_authorized" = "你没有修改聊天室话题所需的权限"; +// Room members +"room_member_ignore_prompt" = "你确定要隐藏所有此用户发送的消息吗?"; +// Contacts +"contact_mx_users" = "Matrix 用户"; +"contact_local_contacts" = "本地联系人"; +// Groups +"group_invite_section" = "邀请"; +"group_section" = "群组"; +"e2e_passphrase_confirm" = "确认密码"; +"notification_settings_enable_notifications" = "启用通知"; +// Notification settings screen +"notification_settings_disable_all" = "禁用通知"; +"notification_settings_highlight" = "高亮"; +"notification_settings_custom_sound" = "自定义铃声"; +"notification_settings_select_room" = "选择一个聊天室"; +"notification_settings_other_alerts" = "其他警报"; +// gcm section +"settings_config_home_server" = "主服务器:%@"; +"settings_config_identity_server" = "身份认证服务器:%@"; +"settings_config_user_id" = "用户 ID:%@"; +"call_ended" = "通话结束"; +// unrecognized SSL certificate +"ssl_trust" = "信任"; +"ssl_logout_account" = "登出"; +// actions +"action_logout" = "登出"; +"ssl_remain_offline" = "忽略"; +"message_unsaved_changes" = "尚有未经保存的修改。现在退出将会取消这些修改。"; +"notice_sticker" = "贴纸"; +"ssl_could_not_verify" = "无法验证远程服务器的身份。"; +// Account +"account_save_changes" = "保存更改"; +"account_link_email" = "邮箱地址"; +"account_linked_emails" = "邮箱地址"; +"account_email_validation_title" = "等待验证中"; +"account_msisdn_validation_title" = "等待验证中"; +"account_msisdn_validation_error" = "无法验证此手机号。"; +"account_error_picture_change_failed" = "头像修改失败"; +"e2e_passphrase_not_match" = "密码必须匹配"; +"not_supported_yet" = "尚未支持"; +"local_contacts_access_discovery_warning_title" = "发现用户"; +"notice_topic_changed" = "%@ 将话题修改为 \"%@\"。"; +"notice_placed_voice_call" = "%@ 发起了语音通话"; +"notice_placed_video_call" = "%@ 发起了视频通话"; +"notice_answered_video_call" = "%@ 接听了通话"; +"notice_ended_video_call" = "%@ 结束了通话"; +"notification_settings_always_notify" = "总是通知"; +"notification_settings_never_notify" = "从不通知"; +"notification_settings_sender_hint" = "@user:domain.com"; +// call string +"call_waiting" = "请等待…"; +"call_connecting" = "连接中…"; +"call_ring" = "正在通话…"; +"room_event_encryption_info_event_algorithm" = "算法\n"; +"room_event_encryption_verify_message" = "为验证此会话是否可信,请通过其他方式(例如当面交换或拨打电话)与其拥有者联系,并询问他们该会话的用户设置中的密钥是否与以下密钥匹配:\n\n\t会话名称:%@\n\t会话 ID:%@\n\t会话密钥:%@\n\n如果匹配,请点击下面的按钮。如果不匹配,那么说明有其他人截取了此会话,您可能想点击黑名单按钮。\n\n未来,这个验证过程将会变得更加精致、巧妙一些。"; +"room_event_encryption_info_event_fingerprint_key" = "声称的 Ed25519 指纹密钥\n"; +"room_event_encryption_info_device_fingerprint" = "Ed25519 指纹\n"; +"room_event_encryption_verify_ok" = "验证"; +"room_event_encryption_info_block" = "拉黑"; +"room_event_encryption_info_unblock" = "取消拉黑"; +"room_creation_alias_title" = "聊天室别称:"; +"notice_conference_call_started" = "VoIP 会议已开始"; +"notice_conference_call_request" = "%@ 发起了 VoIP 会议"; +"notice_conference_call_finished" = "VoIP 会议已结束"; +"notification_settings_enable_notifications_warning" = "所有设备上的通知都已被禁用。"; +"notice_in_reply_to" = "回复"; +"account_email_validation_message" = "请检查您的电子邮箱并点击邮件中的链接。完成此操作后,点击继续。"; +"account_email_validation_error" = "无法验证邮箱地址。请检查你的电子邮箱并点击邮件中的链接。完成后,请点击继续"; +"login_error_resource_limit_exceeded_title" = "超出资源使用限制"; +"login_error_resource_limit_exceeded_contact_button" = "联系管理员"; +"login_error_resource_limit_exceeded_message_default" = "此主服务器已超出某资源的使用限制。"; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "此主服务器已达到月活跃用户限制。"; +"login_error_resource_limit_exceeded_message_contact" = "\n\n请联系管理员以继续使用本服务。"; +"account_msisdn_validation_message" = "我们通过短信向您发送了一条验证码,请在下方输入它。"; +"room_error_timeline_event_not_found" = "应用程序试图加载此聊天室时间线中的特定点,但无法找到该时间点"; +"room_no_power_to_create_conference_call" = "您需要获得邀请权限才能在此聊天室开始会议"; +"room_no_conference_call_in_encrypted_rooms" = "加密聊天室暂不支持通话会议"; +// Reply to message +"message_reply_to_sender_sent_an_image" = "发送了一张图片。"; +"message_reply_to_sender_sent_an_audio_file" = "发送了一个音频文件。"; +"message_reply_to_sender_sent_a_file" = "发送了一个文件。"; +"message_reply_to_message_to_reply_to_prefix" = "回复"; +"room_member_power_level_prompt" = "该用户将被提升至与您一样的权限级别,所以此操作将无法撤销。\n您确定吗?"; +// Attachment +"attachment_size_prompt" = "发送为:"; +"attachment_original" = "实际大小 (%@)"; +"attachment_multiselection_size_prompt" = "发送图片为:"; +"attachment_multiselection_original" = "实际大小"; +"attachment_e2e_keys_file_prompt" = "此文件含有 Matrix 客户端导出的密钥。\n您想要查看文件内容还是导入密钥?"; +"message_reply_to_sender_sent_a_video" = "发送了一段视频。"; +"call_invite_expired" = "通话邀请已过期"; +"ssl_fingerprint_hash" = "指纹(%@):"; +"e2e_import_prompt" = "此操作允许您导入此前从其他 Matrix 客户端上导出的加密密钥。您将能够解密任何该客户端能解密的消息。\n该导出文件受密码保护。您应在此处输入密码以解密该文件。"; +"e2e_export_prompt" = "此操作允许您将加密聊天室中接收到的消息导出为一个本地文件。您将来可以将此文件导入到其他 Matrix 客户端中去解密这些消息。\n导出的文件将允许任何能够读取它的人解密您可以看到的任何加密消息,因此您应该小心保证其安全。"; +"e2e_passphrase_create" = "创建密码"; +"error_common_message" = "出现错误。请稍后再试。"; +// Permissions +"camera_access_not_granted_for_call" = "视频通话需要摄像头使用权限,但 %@ 无此权限"; +"microphone_access_not_granted_for_call" = "通话需要麦克风使用权限,但 %@ 无此权限"; +"local_contacts_access_not_granted" = "本地通讯录用户查找功能需要通讯录权限,但 %@ 无此权限"; +"local_contacts_access_discovery_warning" = "为了发现已经使用 Matrix 的联系人,%@ 可以把你地址簿里的邮箱地址和电话号码发送给你选定的 Matrix 身份认证服务器。如果支持的话,个人数据会在发送前被哈希——请检查你的身份认证服务器的隐私条款获知更多细节。"; +"notification_settings_global_info" = "通知设置已保存在您的账号中并在所有支持的客户端中共享(包括桌面通知)。\n\n规则会按顺序应用;第一条匹配的规则定义了消息的输出结果。\n因此:按字符规则的通知比按聊天室规则的通知级别更高,而这两者都比按发送者规则的通知级别更高。\n对于同一类型的多条规则,匹配列表中的第一条优先级最高。"; +"notification_settings_per_word_notifications" = "按字符通知"; +"notification_settings_per_word_info" = "单词不区分大小写,并且可能包含 * 通配符。 所以:\nfoo 匹配由单词分隔符包围的字符串 foo(例如标点符号和空格,或一行的开头/结尾)。\nfoo* 匹配任何以 foo 开头的单词。\n*foo* 匹配任何包含3个字母 foo 的单词。"; +"notification_settings_word_to_match" = "匹配的单词"; +"notification_settings_per_room_notifications" = "按聊天室通知"; +"notification_settings_per_sender_notifications" = "按发送者通知"; +"notification_settings_contain_my_user_name" = "有包含我的用户名的消息时用铃声通知"; +"notification_settings_contain_my_display_name" = "有包含我的昵称的消息时用铃声通知"; +"notification_settings_just_sent_to_me" = "有发送给我的消息时用铃声通知"; +"notification_settings_invite_to_a_new_room" = "我被邀请去一个新聊天室时用铃声通知"; +"notification_settings_people_join_leave_rooms" = "有人加入或离开聊天室时发送通知"; +"notification_settings_receive_a_call" = "当我收到通话请求时发送通知"; +"notification_settings_suppress_from_bots" = "取消来自机器人的通知"; +"notification_settings_by_default" = "默认…"; +"notification_settings_notify_all_other" = "为所有其他消息/聊天室发送通知"; +"incoming_video_call" = "视频通话来电"; +"incoming_voice_call" = "语音通话来电"; +"ssl_cert_not_trust" = "这可能意味着有人正在恶意劫持您的流量,或者您的手机不信任远程服务器提供的数字证书。"; +"ssl_cert_new_account_expl" = "如果服务器管理员说这是预期的情况,请确保下面的指纹与管理员提供的指纹相匹配。"; +"ssl_unexpected_existing_expl" = "证书已从一个先前受您的设备信任的证书更改为另一个。这非常反常!建议您 不要 接受此新证书。"; +"ssl_expected_existing_expl" = "证书已从曾受信任的证书更改为不受信任的证书。服务器可能已更新其证书,请联系管理员并核对服务器的指纹。"; +"ssl_only_accept" = "请 仅 在服务器管理员发布了与上述指纹匹配的指纹的情况下接受该证书。"; +"notice_encryption_enabled_ok" = "%@ 启用了端到端加密。"; +"notice_encryption_enabled_unknown_algorithm" = "%1$@ 启用了端到端加密(无法识别的算法 %2$@)。"; +"device_details_rename_prompt_title" = "会话名称"; +"account_error_push_not_allowed" = "未允许通知"; +"notice_room_third_party_revoked_invite" = "%@ 撤回了对 %@ 加入聊天室的邀请"; +"notice_encryption_enabled_ok_by_you" = "你启用了端对端加密。"; +"notice_room_created_by_you_for_dm" = "你加入了。"; +"notice_room_topic_removed_by_you" = "你移除了话题"; +"notice_room_name_removed_by_you_for_dm" = "你移除了名称"; +"notice_room_name_removed_by_you" = "你移除了聊天室名称"; +"notice_conference_call_request_by_you" = "你请求了 VoIP 会议"; +"notice_room_ban_by_you" = "你封禁了 %@"; +"notice_room_unban_by_you" = "你解封了 %@"; +"notice_room_kick_by_you" = "你移除了 %@"; +"notice_room_reject_by_you" = "你拒绝了邀请"; +"notice_room_leave_by_you" = "你退出了"; +"notice_room_join_by_you" = "你加入了"; +"notice_room_third_party_registered_invite_by_you" = "你接受了 %@ 的邀请"; +"notice_room_third_party_invite_by_you_for_dm" = "你邀请了 %@"; +"notice_room_invite_you" = "%@ 邀请了你"; + +// Notice Events with "You" +"notice_room_invite_by_you" = "你邀请了 %@"; +"notice_declined_video_call" = "%@ 拒接了通话"; +"notice_room_name_changed_for_dm" = "%@ 将名称修改为 %@。"; +"notice_room_third_party_revoked_invite_for_dm" = "%@ 撤回了对 %@ 的邀请"; +"notice_room_third_party_invite_for_dm" = "%@ 邀请了 %@"; +"room_left_for_dm" = "你离开了"; +"notice_room_power_level_intro_for_dm" = "成员的权限级别是:"; +"notice_room_aliases_for_dm" = "别名是:%@"; +"notice_room_created_for_dm" = "%@ 已加入。"; +"notice_room_name_removed_for_dm" = "%@ 移除了名称"; +"resume_call" = "恢复"; +"notice_encryption_enabled_unknown_algorithm_by_you" = "你启用了端对端加密(无法识别的算法 %@)。"; +"notice_event_redacted_by_you" = " 被你"; +"notice_room_third_party_revoked_invite_by_you" = "你撤回了对 %@ 加入此聊天室的邀请"; +"notice_room_join_rule_public_by_you_for_dm" = "你将此聊天设为公开。"; +"notice_room_join_rule_public_by_you" = "你将此聊天室设为公开。"; +"notice_room_join_rule_public_for_dm" = "%@ 将此聊天设为公开。"; +"notice_room_join_rule_public" = "%@ 将此聊天室设为公开。"; +"notice_room_join_rule_invite_by_you_for_dm" = "你将此聊天设为仅邀请。"; +"notice_room_join_rule_invite_by_you" = "你将此聊天室设为仅邀请。"; +"notice_room_join_rule_invite_for_dm" = "%@ 将此聊天设为仅邀请。"; +// New +"notice_room_join_rule_invite" = "%@ 将此聊天室设为仅邀请。"; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ 将未来的消息设为对所有人可见,从他们被邀请时开始。"; +"notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "你将未来的消息设为对所有人可见,从他们被邀请时开始。"; +"notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "你将未来的消息设为对所有人可见,从他们加入时开始。"; +"notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ 将未来的消息设为对所有人可见,从他们加入时开始。"; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "你将未来的聊天室消息历史设为对所有聊天室成员可见,从他们加入时开始。"; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "你将未来的聊天室消息历史设为对所有聊天室成员可见,从他们被邀请时开始。"; +"notice_room_history_visible_to_members_for_dm" = "%@ 将未来的消息设为对所有聊天室成员可见。"; +"notice_room_third_party_invite_by_you" = "你邀请 %@ 加入此聊天室"; +"notice_room_history_visible_to_members_by_you_for_dm" = "你将未来的消息设为对所有聊天室成员可见。"; +"notice_room_history_visible_to_members_by_you" = "你将未来的聊天室消息历史设为对所有聊天室成员可见。"; +"notice_room_history_visible_to_anyone_by_you" = "你将未来的聊天室消息历史设为对所有人可见。"; +"notice_redaction_by_you" = "你撤回了一个事件(id:%@)"; +"notice_room_withdraw_by_you" = "你撤回了对 %@ 的邀请"; +"notice_room_third_party_revoked_invite_by_you_for_dm" = "你撤回了对 %@ 的邀请"; +"call_more_actions_dialpad" = "拨号键盘"; +"call_more_actions_transfer" = "转移"; +"call_more_actions_audio_use_device" = "设备扬声器"; +"call_more_actions_audio_use_headset" = "使用耳机音频"; +"call_more_actions_change_audio_device" = "更改音频设备"; +"call_more_actions_unhold" = "继续"; +"call_more_actions_hold" = "挂起"; +"call_holded" = "你挂起了通话"; +"call_remote_holded" = "%@ 挂起了通话"; +"notice_room_created_by_you" = "你创建并配置了此聊天室。"; +"notice_profile_change_redacted_by_you" = "你更新了你的资料 %@"; +"notice_declined_video_call_by_you" = "你拒绝了通话"; +"notice_ended_video_call_by_you" = "你挂断了通话"; +"notice_answered_video_call_by_you" = "你接听了通话"; +"notice_placed_video_call_by_you" = "你发起了视频通话"; +"notice_placed_voice_call_by_you" = "你发起了语音通话"; +"notice_room_name_changed_by_you_for_dm" = "你将名称修改为 %@。"; +"notice_room_name_changed_by_you" = "你将聊天室名称修改为 %@。"; +"notice_topic_changed_by_you" = "你将话题修改为 \"%@\"。"; +"notice_display_name_set_by_you" = "你将你的昵称设置为 %@"; +"notice_display_name_changed_from_by_you" = "你将你的昵称从 %@ 更改为 %@"; +"notice_display_name_removed_by_you" = "你移除了你的昵称"; +"notice_avatar_url_changed_by_you" = "你更换了头像"; +"call_transfer_to_user" = "转接到 %@"; +"call_ringing" = "响铃中…"; +"call_consulting_with_user" = "与 %@ 商量"; +"call_video_with_user" = "与 %@ 进行视频通话"; +"call_voice_with_user" = "与 %@ 进行语音通话"; +"e2e_passphrase_too_short" = "密码口令太短 (长度至少为 %d 个字符)"; +"microphone_access_not_granted_for_voice_message" = "语音消息需要访问麦克风,但 %@ 无权使用它"; +"message_reply_to_sender_sent_a_voice_message" = "发送了一条语音消息。"; +"attachment_large_with_resolution" = "大 %@ (~%@)"; +"attachment_medium_with_resolution" = "中等 %@ (~%@)"; +"attachment_small_with_resolution" = "小 %@ (~%@)"; +"attachment_size_prompt_message" = "你可以在设置中关闭这个。"; +"attachment_size_prompt_title" = "确认要发送的大小"; +"room_displayname_all_other_participants_left" = "%@ (离开)"; +"auth_reset_password_error_not_found" = "未找到"; +"auth_reset_password_error_unauthorized" = "未经授权"; +"auth_invalid_user_name" = "用户名无效"; +"room_displayname_all_other_members_left" = "%@ (离开)"; +"auth_username_in_use" = "用户名被占用"; +"rename" = "重命名"; diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/zh_Hant.lproj/MatrixKit.strings b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/zh_Hant.lproj/MatrixKit.strings new file mode 100644 index 000000000..a30a6d084 --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/zh_Hant.lproj/MatrixKit.strings @@ -0,0 +1,247 @@ +"view" = "檢視"; +"back" = "上一步"; +"continue" = "繼續"; +"leave" = "離開"; +"invite" = "邀請"; +"retry" = "重試"; +"cancel" = "取消"; +"save" = "儲存"; +"matrix" = "Matrix"; +// Login Screen +"login_create_account" = "建立帳號:"; +"login_server_url_placeholder" = "URL(如 https://matrix.org)"; +"login_home_server_title" = "主伺服器:"; +"login_home_server_info" = "您的主伺服器會儲存所有的對話紀錄跟帳號資料"; +"login_identity_server_title" = "身份認證伺服器:"; +"login_identity_server_info" = "Matrix 提供身份認證伺服器來追蹤電子郵件信箱與 Matrix ID 的關係。目前只有 https://matrix.org 提供這個服務。"; +"login_user_id_placeholder" = "Matrix ID(如 @bob:matrix.org 或 bob)"; +"login_password_placeholder" = "密碼"; +"login_optional_field" = "選擇性"; +"login_display_name_placeholder" = "顯示名稱(如 Bob Obson)"; +"login_email_placeholder" = "電子郵件地址"; +"login_leave_fallback" = "取消"; +// Encryption information +"room_event_encryption_info_title" = "點對點加密資訊\n\n"; +"room_event_encryption_info_event" = "事件資訊\n"; +"room_event_encryption_info_event_user_id" = "使用者 ID\n"; +"room_event_encryption_info_event_identity_key" = "Curve25519 身份認證金鑰\n"; +"room_event_encryption_info_event_fingerprint_key" = "已聲請之 Ed25519 指紋金鑰\n"; +"room_event_encryption_info_event_algorithm" = "演算法\n"; +"room_event_encryption_info_event_session_id" = "會話 ID\n"; +"room_event_encryption_info_event_decryption_error" = "解密錯誤\n"; +"room_event_encryption_info_event_unencrypted" = "未加密"; +"room_event_encryption_info_event_none" = "無"; +"room_event_encryption_info_device" = "\n發送者的裝置訊息\n"; +"room_event_encryption_info_device_unknown" = "未知的裝置\n"; +"room_event_encryption_info_device_name" = "名稱\n"; +"room_event_encryption_info_device_id" = "裝置 ID\n"; +"room_event_encryption_info_device_verification" = "驗證\n"; +"room_event_encryption_info_device_fingerprint" = "Ed25519 指紋\n"; +"room_event_encryption_info_device_verified" = "已驗證"; +"room_event_encryption_info_device_not_verified" = "未驗證"; +"room_event_encryption_info_device_blocked" = "已列入黑名單"; +"room_event_encryption_info_verify" = "驗證..."; +"room_event_encryption_info_unverify" = "取消驗證"; +"room_event_encryption_info_block" = "黑名單"; +"room_event_encryption_info_unblock" = "解除黑名單"; +"room_event_encryption_verify_title" = "驗證裝置\n\n"; +"room_event_encryption_verify_message" = "若要檢查這個裝置是可被信任的,請透過其他方法聯絡所有者(例如面對面或是在電話中),並詢問在其使用者設定中以下金鑰是否是一致的:\n\n\n\t裝置名稱:%@\n\t裝置 ID:%@\n\t裝置金鑰:%@\n\n若相同,請點選下面的「驗證確認」按鈕。如果不相同,表示有人從中攔截這個裝置,您可能要點選「黑名單」按鈕。\n\n未來驗證手續會更加簡單,若有不便敬請見諒。"; +"room_event_encryption_verify_ok" = "驗證確認"; +// Account +"account_save_changes" = "儲存修改"; +// Groups +"group_invite_section" = "邀請"; +// E2E import +"e2e_import_room_keys" = "匯入聊天室金鑰"; +"e2e_import" = "匯入"; +"e2e_passphrase_enter" = "輸入通關密語"; +// E2E export +"e2e_export_room_keys" = "匯出房間金鑰"; +"e2e_export" = "匯出"; +"e2e_passphrase_empty" = "通關密語不能為空"; +"e2e_passphrase_not_match" = "通關密語必須符合"; +// Others +"user_id_title" = "使用者 ID:"; +"offline" = "離線"; +"unsent" = "取消傳送"; +"error" = "錯誤"; +"not_supported_yet" = "尚未支援"; +"default" = "預設"; +"private" = "私密"; +"public" = "公開"; +"power_level" = "權限等級"; +"network_error_not_reachable" = "請檢查您的網路連線"; +"user_id_placeholder" = "例:@bob:homeserver"; +"ssl_homeserver_url" = "家伺服器 URL:%@"; +// Permissions +"camera_access_not_granted_for_call" = "視訊電話需要使用相機權限,但是 %@ 沒有存取權限"; +"microphone_access_not_granted_for_call" = "電話需要使用麥克風權限,但是 %@ 沒有存取權限"; +"local_contacts_access_not_granted" = "從本機的聯絡資訊探索使用者,需要存取聯絡資訊的權限,但是 %@ 沒有存取權限"; +"local_contacts_access_discovery_warning_title" = "使用者探索"; +"local_contacts_access_discovery_warning" = "%@ 要從您的聯絡資訊上傳電子郵件位址跟電話號碼來探索使用者"; +// Country picker +"country_picker_title" = "選擇國家"; +// Language picker +"language_picker_title" = "選擇語言"; +"language_picker_default_language" = "預設 (%@)"; +"notice_room_invite" = "%@ 邀請了 %@"; +"notice_room_third_party_invite" = "%@ 已邀請 %@ 加入聊天室"; +"notice_room_third_party_registered_invite" = "%@ 同意了 %@ 的邀請"; +"notice_room_join" = "%@ 已進入"; +"notice_room_leave" = "%@ 已離開"; +"notice_room_reject" = "%@ 拒絕了邀請"; +"notice_room_kick" = "%@ 踢了 %@"; +"notice_room_unban" = "%@ 解除了 %@ 的封鎖"; +"notice_room_ban" = "%@ 封鎖了 %@"; +"notice_room_withdraw" = "%@ 撤回了 %@ 的邀請"; +"notice_room_reason" = ",原因:%@"; +"notice_avatar_url_changed" = "%@ 變更了頭像"; +"notice_display_name_set" = "%@ 設定了自己的顯示名稱為 %@"; +"notice_display_name_changed_from" = "%@ 將自己的顯示名稱從 %@ 改為 %@"; +"notice_display_name_removed" = "%@ 移除了自己的顯示名稱"; +"notice_topic_changed" = "%@ 已經變更主題為:%@"; +"notice_room_name_changed" = "%@ 將房間名稱變更為 %@"; +"notice_placed_voice_call" = "%@ 開始了語音通話"; +"notice_placed_video_call" = "%@ 開始了視訊通話"; +"notice_answered_video_call" = "%@ 接聽了通話"; +"notice_ended_video_call" = "%@ 結束了通話"; +"notice_conference_call_request" = "%@ 請求了 VoIP 會議"; +"notice_conference_call_started" = "VoIP 會議已開始"; +"notice_conference_call_finished" = "VoIP 會議已結束"; +// button names +"ok" = "好"; +"send" = "傳送"; +"copy_button_name" = "複製"; +"resend" = "重新傳送"; +"redact" = "撤除"; +"share" = "分享"; +"set_power_level" = "權限等級"; +"delete" = "刪除"; +// actions +"action_logout" = "登出"; +"create_room" = "建立聊天室"; +"login" = "登入"; +"create_account" = "建立帳號"; +"membership_invite" = "邀請"; +"membership_leave" = "離開"; +"membership_ban" = "已被封鎖"; +"num_members_one" = "%@ 位使用者"; +"num_members_other" = "%@ 位使用者"; +"kick" = "踢人"; +"ban" = "封鎖"; +"unban" = "解除封鎖"; +// unrecognized SSL certificate +"ssl_trust" = "信任"; +"ssl_logout_account" = "登出"; +"ssl_remain_offline" = "忽略"; +"ssl_fingerprint_hash" = "指紋 (%@):"; +"ssl_could_not_verify" = "無法驗證遠端伺服器的身份。"; +"ssl_cert_not_trust" = "這可能代表有人惡意攔截您的流量,或是裝置無法信任遠端伺服器所提供的憑證。"; +"ssl_cert_new_account_expl" = "如果伺服器管理者表示這是可預期的狀況,請確定以下指紋與管理者提供的一致。"; +"ssl_unexpected_existing_expl" = "這個憑證有別於原本在您裝置所信任的憑證,這個狀況相當不常見。建議您不要信任新的憑證。"; +"ssl_expected_existing_expl" = "這個憑證從原本信任的憑證換成不信任的憑證,可能因為伺服器更新了它的憑證。請聯絡伺服器管理者確認新的指紋一致。"; +"ssl_only_accept" = "只有在伺服器管理者提供的指紋與以上指紋一致時,您才能信任這個憑證。"; +// Devices +"device_details_title" = "裝置資訊\n"; +"login_error_title" = "登入失敗"; +"login_error_no_login_flow" = "無法從該主伺服器取得驗證訊息"; +"login_error_do_not_support_login_flows" = "目前我們不支援任何該主伺服器定義的登入流程"; +"login_error_registration_is_not_supported" = "目前不支援註冊"; +"login_error_forbidden" = "無效的使用者名稱/密碼"; +"login_error_unknown_token" = "不能識別指定的訪問權杖"; +"login_error_bad_json" = "JSON 格式錯誤"; +"login_error_not_json" = "未包含有效的 JSON"; +"login_error_limit_exceeded" = "已傳送過多的請求"; +"login_error_user_in_use" = "該使用者名稱已被使用"; +"login_error_login_email_not_yet" = "該電子郵件連結向未被點擊"; +"login_use_fallback" = "使用備用頁"; +"login_invalid_param" = "無效的參數"; +"register_error_title" = "註冊失敗"; +"login_tablet_device" = "平板電腦"; +// Action +"no" = "否"; +"yes" = "是"; +"abort" = "終止"; +"login_email_info" = "指定一個電子郵件地址可以讓其他 Matirx 用戶更容易找到您,並讓您可以在未來重置密碼。"; +"login_prompt_email_token" = "請輸入您的電子郵件認證權杖:"; +"login_error_forgot_password_is_not_supported" = "目前不支援忘記密碼"; +"login_mobile_device" = "行動裝置"; +"login_desktop_device" = "桌上型電腦"; +"close" = "關閉"; +"discard" = "放棄"; +"dismiss" = "無視"; +"sign_up" = "註冊"; +"submit" = "送出"; +"submit_code" = "送出碼"; +"set_default_power_level" = "重設權限等級"; +"set_admin" = "設定管理員"; +"set_moderator" = "設定主持人"; +"start_chat" = "開始聊天"; +"start_voice_call" = "開始語音通話"; +"start_video_call" = "開始視訊通話"; +"mention" = "提到"; +"select_account" = "選擇一個帳號"; +"capture_media" = "拍攝照片/影片"; +"invite_user" = "邀請 Matrix 用戶"; +"reset_to_default" = "重置為預設值"; +"attach_media" = "從庫中附加媒體"; +"resend_message" = "重新傳送該訊息"; +"select_all" = "全選"; +"cancel_upload" = "取消上傳"; +"cancel_download" = "取消下載"; +"show_details" = "顯示詳細資料"; +"answer_call" = "接聽來電"; +"reject_call" = "拒絕來電"; +"end_call" = "結束通話"; +"ignore" = "忽略"; +"unignore" = "取消忽略"; +// Events formatter +"notice_avatar_changed_too" = "(頭像也已經改變)"; +"notice_room_name_removed" = "%@ 移除了該聊天室的名字"; +"notice_room_topic_removed" = "%@ 移除了該主題"; +"notice_event_redacted_by" = " 由 %@"; +"notice_event_redacted_reason" = " [理由:%@]"; +"notice_profile_change_redacted" = "%@ 已更新他的個人檔案 %@"; +"notice_room_created" = "%@ 創建了該聊天室"; +"notice_room_join_rule" = "加入規則: %@"; +"notice_room_power_level_intro" = "聊天室成員們的權限级别是:"; +"notice_event_redacted" = "<撤回%@>"; +// room details dialog screen +"room_details_title" = "聊天室詳細資料"; +"notice_encrypted_message" = "已加密的訊息"; +"notice_image_attachment" = "附加圖片"; +"notice_audio_attachment" = "附加音訊"; +"notice_video_attachment" = "附加視訊"; +"notice_location_attachment" = "附加位置資訊"; +"notice_file_attachment" = "附加檔案"; +"notice_invalid_attachment" = "無效的附加資訊"; +"notice_unsupported_attachment" = "未支援的附加資訊:%@"; +"notice_sticker" = "貼圖"; +// room display name +"room_displayname_empty_room" = "空的聊天室"; +"room_displayname_two_members" = "%@ 和 %@"; +"room_displayname_more_than_two_members" = "%@ 和 %u 個其他人"; +// Settings +"settings" = "設定"; +"settings_enable_push_notifications" = "啟用推播通知"; +"device_details_name" = "名稱\n"; +"device_details_identifier" = "裝置代碼\n"; +"device_details_last_seen" = "上次使用\n"; +"device_details_rename_prompt_message" = "裝置名稱:"; +"login_error_resource_limit_exceeded_title" = "超過資源限制"; +"login_error_resource_limit_exceeded_message_default" = "此家伺服器已經超過其中一項資源限制。"; +"login_error_resource_limit_exceeded_message_monthly_active_user" = "此家伺服器已經達到其每月活躍使用者限制。"; +"login_error_resource_limit_exceeded_message_contact" = "\n\n請聯絡您的伺服器管理員以繼續使用其服務。"; +"login_error_resource_limit_exceeded_contact_button" = "聯絡管理員"; +"notice_room_power_level_acting_requirement" = "完成此操作之前使用者必須具有的最小權限級別是:"; +"notice_room_power_level_event_requirement" = "事件相關的最小權限級別是:"; +"notice_room_aliases" = "此聊天室別名是:%@"; +"notice_room_related_groups" = "此聊天室關聯的群組是:%@"; +"notice_encryption_enabled" = "%@ 開啓了端對端加密 (演算法 %@)"; +"notice_feedback" = "回報事件 (id:%@):%@"; +"notice_redaction" = "%@ 取消了一个事件 (id: %@)"; +"notice_error_unsupported_event" = "不支援的事件"; +"notice_error_unexpected_event" = "意外事件"; +"notice_error_unknown_event_type" = "未知的事件類型"; +"notice_room_history_visible_to_anyone" = "%@ 讓任何人都能看到未來的聊天室歷史記錄。"; +"notice_room_history_visible_to_members" = "%@ 讓所有聊天室成員都能看到未來的房間歷史記錄。"; diff --git a/Riot/Modules/MatrixKit/Categories/DTHTMLElement+MatrixKit.swift b/Riot/Modules/MatrixKit/Categories/DTHTMLElement+MatrixKit.swift new file mode 100644 index 000000000..9e4315d09 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/DTHTMLElement+MatrixKit.swift @@ -0,0 +1,97 @@ +// +// Copyright 2020 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DTCoreText + +public extension DTHTMLElement { + typealias ImageHandler = (_ sourceURL: String, _ width: CGFloat, _ height: CGFloat) -> URL? + + /// Sanitize the element using the given parameters. + /// - Parameters: + /// - allowedHTMLTags: An array of tags that are allowed. All other tags will be removed. + /// - font: The default font to use when resetting the content of any unsupported tags. + /// - imageHandler: An optional image handler to be run on `img` tags (if allowed) to update the `src` attribute. + @objc func sanitize(with allowedHTMLTags: [String], bodyFont font: UIFont, imageHandler: ImageHandler?) { + if let name = name, !allowedHTMLTags.contains(name) { + + // This is an unsupported tag. + // Remove any attachments to fix rendering. + textAttachment = nil + + // If the element has plain text content show that, + // otherwise prevent the tag from displaying. + if let stringContent = attributedString()?.string, + !stringContent.isEmpty, + let element = DTTextHTMLElement(name: nil, attributes: nil) { + element.setText(stringContent) + removeAllChildNodes() + addChildNode(element) + + if let parent = parent() { + element.inheritAttributes(from: parent) + } else { + fontDescriptor = DTCoreTextFontDescriptor() + fontDescriptor.fontFamily = font.familyName + fontDescriptor.fontName = font.fontName + fontDescriptor.pointSize = font.pointSize + paragraphStyle = DTCoreTextParagraphStyle.default() + + element.inheritAttributes(from: self) + } + element.interpretAttributes() + + } else if let parent = parent() { + parent.removeChildNode(self) + } else { + didOutput = true + } + + } else { + // Process images with the handler when self is an image tag. + if name == "img", let imageHandler = imageHandler { + process(with: imageHandler) + } + + // This element is a supported tag, but it may contain children that aren't, + // so santize all child nodes to ensure correct tags. + if let childNodes = childNodes as? [DTHTMLElement] { + childNodes.forEach { $0.sanitize(with: allowedHTMLTags, bodyFont: font, imageHandler: imageHandler) } + } + } + } + + /// Process the element with the supplied image handler. + private func process(with imageHandler: ImageHandler) { + // Get the values required to pass to the image handler + guard let sourceURL = attributes["src"] as? String else { return } + + var width: CGFloat = -1 + if let widthString = attributes["width"] as? String, + let widthDouble = Double(widthString) { + width = CGFloat(widthDouble) + } + + var height: CGFloat = -1 + if let heightString = attributes["height"] as? String, + let heightDouble = Double(heightString) { + height = CGFloat(heightDouble) + } + + // If the handler returns an updated URL, update the text attachment. + guard let localSourceURL = imageHandler(sourceURL, width, height) else { return } + textAttachment.contentURL = localSourceURL + } +} diff --git a/Riot/Modules/MatrixKit/Categories/MXAggregatedReactions+MatrixKit.h b/Riot/Modules/MatrixKit/Categories/MXAggregatedReactions+MatrixKit.h new file mode 100644 index 000000000..f4d023328 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/MXAggregatedReactions+MatrixKit.h @@ -0,0 +1,30 @@ +/* + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKEventFormatter.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + Define a `MXEvent` category at matrixKit level to store data related to UI handling. + */ +@interface MXAggregatedReactions (MatrixKit) + +- (nullable MXAggregatedReactions *)aggregatedReactionsWithSingleEmoji; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/MatrixKit/Categories/MXAggregatedReactions+MatrixKit.m b/Riot/Modules/MatrixKit/Categories/MXAggregatedReactions+MatrixKit.m new file mode 100644 index 000000000..4d4baee51 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/MXAggregatedReactions+MatrixKit.m @@ -0,0 +1,44 @@ +/* + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXAggregatedReactions+MatrixKit.h" + +#import "MXKTools.h" + +@implementation MXAggregatedReactions (MatrixKit) + +- (nullable MXAggregatedReactions *)aggregatedReactionsWithSingleEmoji +{ + NSMutableArray *reactions = [NSMutableArray arrayWithCapacity:self.reactions.count]; + for (MXReactionCount *reactionCount in self.reactions) + { + if ([MXKTools isSingleEmojiString:reactionCount.reaction]) + { + [reactions addObject:reactionCount]; + } + } + + MXAggregatedReactions *aggregatedReactionsWithSingleEmoji; + if (reactions.count) + { + aggregatedReactionsWithSingleEmoji = [MXAggregatedReactions new]; + aggregatedReactionsWithSingleEmoji.reactions = reactions; + } + + return aggregatedReactionsWithSingleEmoji; +} + +@end diff --git a/Riot/Modules/MatrixKit/Categories/MXEvent+MatrixKit.h b/Riot/Modules/MatrixKit/Categories/MXEvent+MatrixKit.h new file mode 100644 index 000000000..9ef0eb1b1 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/MXEvent+MatrixKit.h @@ -0,0 +1,34 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKEventFormatter.h" + +/** + Define a `MXEvent` category at matrixKit level to store data related to UI handling. + */ +@interface MXEvent (MatrixKit) + +/** + The potential error observed when the event formatter tried to stringify the event (MXKEventFormatterErrorNone by default). + */ +@property (nonatomic) MXKEventFormatterError mxkEventFormatterError; + +/** + Tell whether the event is highlighted or not (NO by default). + */ +@property (nonatomic) BOOL mxkIsHighlighted; + +@end diff --git a/Riot/Modules/MatrixKit/Categories/MXEvent+MatrixKit.m b/Riot/Modules/MatrixKit/Categories/MXEvent+MatrixKit.m new file mode 100644 index 000000000..53c79e6c9 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/MXEvent+MatrixKit.m @@ -0,0 +1,52 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXEvent+MatrixKit.h" +#import + +@implementation MXEvent (MatrixKit) + +- (MXKEventFormatterError)mxkEventFormatterError +{ + NSNumber *associatedError = objc_getAssociatedObject(self, @selector(mxkEventFormatterError)); + if (associatedError) + { + return [associatedError unsignedIntegerValue]; + } + return MXKEventFormatterErrorNone; +} + +- (void)setMxkEventFormatterError:(MXKEventFormatterError)mxkEventFormatterError +{ + objc_setAssociatedObject(self, @selector(mxkEventFormatterError), [NSNumber numberWithUnsignedInteger:mxkEventFormatterError], OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +- (BOOL)mxkIsHighlighted +{ + NSNumber *associatedIsHighlighted = objc_getAssociatedObject(self, @selector(mxkIsHighlighted)); + if (associatedIsHighlighted) + { + return [associatedIsHighlighted boolValue]; + } + return NO; +} + +- (void)setMxkIsHighlighted:(BOOL)mxkIsHighlighted +{ + objc_setAssociatedObject(self, @selector(mxkIsHighlighted), [NSNumber numberWithBool:mxkIsHighlighted], OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +@end diff --git a/Riot/Modules/MatrixKit/Categories/MXRoom+Sync.h b/Riot/Modules/MatrixKit/Categories/MXRoom+Sync.h new file mode 100644 index 000000000..b95389d39 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/MXRoom+Sync.h @@ -0,0 +1,34 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import + +/** + Temporary category to help in the transition from synchronous access to room.state + to asynchronous access. + */ +@interface MXRoom (Sync) + +/** + Get the room state if it has been already loaded else return nil. + + Use this method only where you are sure the room state is already mounted. + */ +@property (nonatomic, readonly) MXRoomState *dangerousSyncState; + +@end diff --git a/Riot/Modules/MatrixKit/Categories/MXRoom+Sync.m b/Riot/Modules/MatrixKit/Categories/MXRoom+Sync.m new file mode 100644 index 000000000..8866c8a49 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/MXRoom+Sync.m @@ -0,0 +1,36 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXRoom+Sync.h" + +@implementation MXRoom (Sync) + +- (MXRoomState *)dangerousSyncState +{ + __block MXRoomState *syncState; + + // If syncState is called from the right place, the following call will be + // synchronous and every thing will be fine + [self state:^(MXRoomState *roomState) { + syncState = roomState; + }]; + + NSAssert(syncState, @"[MXRoom+Sync] syncState failed. Are you sure the state of the room has been already loaded?"); + + return syncState; +} + +@end diff --git a/Riot/Modules/MatrixKit/Categories/MXSession+MatrixKit.h b/Riot/Modules/MatrixKit/Categories/MXSession+MatrixKit.h new file mode 100644 index 000000000..188b6b40b --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/MXSession+MatrixKit.h @@ -0,0 +1,28 @@ +// +// Copyright 2020 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MXSession (MatrixKit) + +/// Flag to indicate whether the session is in a suitable state to show some activity indicators on UI. +@property (nonatomic, readonly) BOOL shouldShowActivityIndicator; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/MatrixKit/Categories/MXSession+MatrixKit.m b/Riot/Modules/MatrixKit/Categories/MXSession+MatrixKit.m new file mode 100644 index 000000000..6d136c306 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/MXSession+MatrixKit.m @@ -0,0 +1,34 @@ +// +// Copyright 2020 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "MXSession+MatrixKit.h" + +@implementation MXSession (MatrixKit) + +- (BOOL)shouldShowActivityIndicator +{ + switch (self.state) + { + case MXSessionStateInitialised: + case MXSessionStateProcessingBackgroundSyncCache: + case MXSessionStateSyncInProgress: + return YES; + default: + return NO; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Categories/NSAttributedString+MatrixKit.swift b/Riot/Modules/MatrixKit/Categories/NSAttributedString+MatrixKit.swift new file mode 100644 index 000000000..c72e5c76c --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/NSAttributedString+MatrixKit.swift @@ -0,0 +1,32 @@ +// +// Copyright 2020 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public extension NSAttributedString { + /// Returns a string created by joining all ranges of the attributed string that don't have + /// the `kMXKToolsBlockquoteMarkAttribute` attribute. + @objc func mxk_unquotedString() -> NSString? { + var unquotedSubstrings = [String]() + + enumerateAttributes(in: NSRange(location: 0, length: self.length), options: []) { attributes, range, stop in + guard !attributes.keys.contains(where: { $0.rawValue == kMXKToolsBlockquoteMarkAttribute }) else { return } + unquotedSubstrings.append(self.attributedSubstring(from: range).string) + } + + return unquotedSubstrings.joined(separator: " ") as NSString + } +} diff --git a/Riot/Modules/MatrixKit/Categories/NSBundle+MXKLanguage.h b/Riot/Modules/MatrixKit/Categories/NSBundle+MXKLanguage.h new file mode 100644 index 000000000..f8e5dff5d --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/NSBundle+MXKLanguage.h @@ -0,0 +1,54 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +@interface NSBundle (MXKLanguage) + +/** + Set the application language independently from the device language. + + The language can be changed at runtime but the app display must be reloaded. + + @param language the ISO language code. nil lets the OS choose it according to the device language + and languages available in the app bundle. + */ ++ (void)mxk_setLanguage:(NSString *)language; + +/** + The language set by mxk_setLanguage. + + @return the ISO language code of the current language. + */ ++ (NSString *)mxk_language; + +/** + Some strings may lack a translation in a language. + Use mxk_setFallbackLanguage to define a fallback language where all the + translation is complete. + + @param language the ISO language code. + */ ++ (void)mxk_setFallbackLanguage:(NSString*)language; + +/** + The fallback language set by mxk_setFallbackLanguage. + + @return the ISO language code of the current fallback language. + */ ++ (NSString *)mxk_fallbackLanguage; + +@end diff --git a/Riot/Modules/MatrixKit/Categories/NSBundle+MXKLanguage.m b/Riot/Modules/MatrixKit/Categories/NSBundle+MXKLanguage.m new file mode 100644 index 000000000..a0112bf99 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/NSBundle+MXKLanguage.m @@ -0,0 +1,103 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "NSBundle+MXKLanguage.h" + +#import + +static const char _bundle = 0; +static const char _fallbackBundle = 0; +static const char _language = 0; +static const char _fallbackLanguage = 0; + +@interface MXKLanguageBundle : NSBundle +@end + +@implementation MXKLanguageBundle + +- (NSString*)localizedStringForKey:(NSString *)key value:(NSString *)value table:(NSString *)tableName +{ + NSBundle* bundle = objc_getAssociatedObject(self, &_bundle); + + // Check if the translation is available in the selected or default language. + // Use "_", a string that does not worth to be translated, as default value to mark + // a key that does not have a translation. + NSString *localizedString = bundle ? [bundle localizedStringForKey:key value:@"_" table:tableName] : [super localizedStringForKey:key value:@"_" table:tableName]; + + if (!localizedString || (localizedString.length == 1 && [localizedString isEqualToString:@"_"])) + { + // Use the string in the fallback language + NSBundle *fallbackBundle = objc_getAssociatedObject(self, &_fallbackBundle); + localizedString = [fallbackBundle localizedStringForKey:key value:value table:tableName]; + } + + return localizedString; +} +@end + +@implementation NSBundle (MXKLanguage) + ++ (void)mxk_setLanguage:(NSString *)language +{ + [self setupMXKLanguageBundle]; + + // [NSBundle localizedStringForKey] calls will be redirected to the bundle corresponding + // to "language" + objc_setAssociatedObject([NSBundle mainBundle], + &_bundle, language ? [NSBundle bundleWithPath:[[NSBundle mainBundle] pathForResource:language ofType:@"lproj"]] : nil, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + + objc_setAssociatedObject([NSBundle mainBundle], + &_language, language, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + ++ (NSString *)mxk_language +{ + return objc_getAssociatedObject([NSBundle mainBundle], &_language); +} + ++ (void)mxk_setFallbackLanguage:(NSString *)language +{ + [self setupMXKLanguageBundle]; + + objc_setAssociatedObject([NSBundle mainBundle], + &_fallbackBundle, language ? [NSBundle bundleWithPath:[[NSBundle mainBundle] pathForResource:language ofType:@"lproj"]] : nil, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + + objc_setAssociatedObject([NSBundle mainBundle], + &_fallbackLanguage, language, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + ++ (NSString *)mxk_fallbackLanguage +{ + return objc_getAssociatedObject([NSBundle mainBundle], &_fallbackLanguage); +} + +#pragma mark - Private methods + ++ (void)setupMXKLanguageBundle +{ + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + + // Use MXKLanguageBundle as the [NSBundle mainBundle] class + object_setClass([NSBundle mainBundle], [MXKLanguageBundle class]); + }); +} + +@end diff --git a/Riot/Modules/MatrixKit/Categories/NSBundle+MatrixKit.h b/Riot/Modules/MatrixKit/Categories/NSBundle+MatrixKit.h new file mode 100644 index 000000000..9bcc5572a --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/NSBundle+MatrixKit.h @@ -0,0 +1,61 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +/** + Define a `NSBundle` category at MatrixKit level to retrieve images and sounds from MatrixKit Assets bundle. + */ +@interface NSBundle (MatrixKit) + +/** + Retrieve an image from MatrixKit Assets bundle. + + @param name image file name without extension. + @return a UIImage instance (nil if the file does not exist). + */ ++ (UIImage *)mxk_imageFromMXKAssetsBundleWithName:(NSString *)name; + +/** + Retrieve an audio file url from MatrixKit Assets bundle. + + @param name audio file name without extension. + @return a NSURL instance. + */ ++ (NSURL *)mxk_audioURLFromMXKAssetsBundleWithName:(NSString *)name; + +/** + Customize the table used to retrieve the localized version of a string during [mxk_localizedStringForKey:] call. + If the key is not defined in this table, the localized string is retrieved from the default table "MatrixKit.strings". + + @param tableName the name of the table containing the key-value pairs. Also, the suffix for the strings file (a file with the .strings extension) to store the localized string. + */ ++ (void)mxk_customizeLocalizedStringTableName:(NSString*)tableName; + +/** + Retrieve localized string from the customized table. If none, MatrixKit Assets bundle is used. + + @param key The string key. + @return The localized string. + */ ++ (NSString *)mxk_localizedStringForKey:(NSString *)key; + +/** + An AppExtension-compatible wrapper for bundleForClass. + */ ++ (NSBundle *)mxk_bundleForClass:(Class)aClass; + +@end diff --git a/Riot/Modules/MatrixKit/Categories/NSBundle+MatrixKit.m b/Riot/Modules/MatrixKit/Categories/NSBundle+MatrixKit.m new file mode 100644 index 000000000..70a75a59b --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/NSBundle+MatrixKit.m @@ -0,0 +1,150 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "NSBundle+MatrixKit.h" +#import "NSBundle+MXKLanguage.h" +#import "MXKViewController.h" + +@implementation NSBundle (MatrixKit) + +static NSString *customLocalizedStringTableName = nil; + ++ (NSBundle*)mxk_assetsBundle +{ + // Get the bundle within MatrixKit + NSBundle *bundle = [NSBundle mxk_bundleForClass:[MXKViewController class]]; + NSURL *assetsBundleURL = [bundle URLForResource:@"MatrixKitAssets" withExtension:@"bundle"]; + + return [NSBundle bundleWithURL:assetsBundleURL]; +} + ++ (NSBundle*)mxk_languageBundle +{ + NSString *language = [NSBundle mxk_language]; + NSBundle *bundle = [NSBundle mxk_assetsBundle]; + + // If there is a runtime language (different from the legacy language chose by the OS), + // return the sub bundle for this language + if (language) + { + bundle = [NSBundle bundleWithPath:[bundle pathForResource:[NSBundle mxk_language] ofType:@"lproj"]]; + } + + return bundle; +} + ++ (NSBundle*)mxk_fallbackLanguageBundle +{ + NSString *fallbackLanguage = [NSBundle mxk_fallbackLanguage]; + NSBundle *bundle = [NSBundle mxk_assetsBundle]; + + // Return the sub bundle of the fallback language if any + if (fallbackLanguage) + { + bundle = [NSBundle bundleWithPath:[bundle pathForResource:fallbackLanguage ofType:@"lproj"]]; + } + + return bundle; +} + +// use a cache to avoid loading images from file system. +// It often triggers an UI lag. +static MXLRUCache *imagesResourceCache = nil; + ++ (UIImage *)mxk_imageFromMXKAssetsBundleWithName:(NSString *)name +{ + // use a cache to avoid loading the image at each call + if (!imagesResourceCache) + { + imagesResourceCache = [[MXLRUCache alloc] initWithCapacity:20]; + } + + NSString *imagePath = [[NSBundle mxk_assetsBundle] pathForResource:name ofType:@"png" inDirectory:@"Images"]; + UIImage* image = (UIImage*)[imagesResourceCache get:imagePath]; + + // the image does not exist + if (!image) + { + // retrieve it + image = [UIImage imageWithContentsOfFile:imagePath]; + // and store it in the cache. + [imagesResourceCache put:imagePath object:image]; + } + + return image; +} + ++ (NSURL*)mxk_audioURLFromMXKAssetsBundleWithName:(NSString *)name +{ + return [NSURL fileURLWithPath:[[NSBundle mxk_assetsBundle] pathForResource:name ofType:@"mp3" inDirectory:@"Sounds"]]; +} + ++ (void)mxk_customizeLocalizedStringTableName:(NSString*)tableName +{ + customLocalizedStringTableName = tableName; +} + ++ (NSString *)mxk_localizedStringForKey:(NSString *)key +{ + NSString *localizedString; + + // Check first customized table + // Use "_", a string that does not worth to be translated, as default value to mark + // a key that does not have a value in the customized table. + if (customLocalizedStringTableName) + { + localizedString = NSLocalizedStringWithDefaultValue(key, customLocalizedStringTableName, [NSBundle mainBundle], @"_", nil); + } + + if (!localizedString || (localizedString.length == 1 && [localizedString isEqualToString:@"_"])) + { + // Check if we need to manage a fallback language + // as we do in NSBundle+MXKLanguage + NSString *language = [NSBundle mxk_language]; + NSString *fallbackLanguage = [NSBundle mxk_fallbackLanguage]; + + BOOL manageFallbackLanguage = fallbackLanguage && ![fallbackLanguage isEqualToString:language]; + + localizedString = NSLocalizedStringWithDefaultValue(key, @"MatrixKit", + [NSBundle mxk_languageBundle], + manageFallbackLanguage ? @"_" : nil, + nil); + + if (manageFallbackLanguage + && (!localizedString || (localizedString.length == 1 && [localizedString isEqualToString:@"_"]))) + { + // The translation is not available, use the fallback language + localizedString = NSLocalizedStringFromTableInBundle(key, @"MatrixKit", + [NSBundle mxk_fallbackLanguageBundle], + nil); + } + } + + return localizedString; +} + ++ (NSBundle *)mxk_bundleForClass:(Class)aClass +{ + NSBundle *bundle = [NSBundle bundleForClass:aClass]; + if ([[bundle.bundleURL pathExtension] isEqualToString:@"appex"]) + { + // For App extensions, peel off two levels + bundle = [NSBundle bundleWithURL:[[bundle.bundleURL URLByDeletingLastPathComponent] URLByDeletingLastPathComponent]]; + } + return bundle; +} + +@end diff --git a/Riot/Modules/MatrixKit/Categories/NSString+MatrixKit.swift b/Riot/Modules/MatrixKit/Categories/NSString+MatrixKit.swift new file mode 100644 index 000000000..0a7826571 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/NSString+MatrixKit.swift @@ -0,0 +1,66 @@ +// +// Copyright 2020 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import MatrixSDK.MXLog + +public extension NSString { + /// Gets the first URL contained in the string ignoring any links to hosts defined in + /// the `firstURLDetectionIgnoredHosts` property of `MXKAppSettings`. + /// - Returns: A URL if detected, otherwise nil. + @objc func mxk_firstURLDetected() -> NSURL? { + let hosts = MXKAppSettings.standard().firstURLDetectionIgnoredHosts ?? [] + return mxk_firstURLDetected(ignoring: hosts) + } + + /// Gets the first URL contained in the string ignoring any links to the specified hosts. + /// - Returns: A URL if detected, otherwise nil. + @objc func mxk_firstURLDetected(ignoring ignoredHosts: [String]) -> NSURL? { + guard let linkDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else { + MXLog.debug("[NSString+URLDetector]: Unable to create link detector.") + return nil + } + + var detectedURL: NSURL? + + // enumerate all urls that were found in the string to ensure + // detection of a valid link if there are invalid links preceding it + linkDetector.enumerateMatches(in: self as String, + options: [], + range: NSRange(location: 0, length: self.length)) { match, flags, stop in + guard let match = match else { return } + + // check if the match is a valid url + let urlString = self.substring(with: match.range) + guard let url = NSURL(string: urlString) else { return } + + // ensure the match is a web link + guard let scheme = url.scheme?.lowercased(), + scheme == "https" || scheme == "http" + else { return } + + // discard any links to ignored hosts + guard let host = url.host?.lowercased(), + !ignoredHosts.contains(host) + else { return } + + detectedURL = url + stop.pointee = true + } + + return detectedURL + } +} diff --git a/Riot/Modules/MatrixKit/Categories/UIAlertController+MatrixKit.h b/Riot/Modules/MatrixKit/Categories/UIAlertController+MatrixKit.h new file mode 100644 index 000000000..d30b01787 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/UIAlertController+MatrixKit.h @@ -0,0 +1,31 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +/** + Define a `UIAlertController` category at MatrixKit level to handle accessibility identifiers. + */ +@interface UIAlertController (MatrixKit) + +/** + Apply an accessibility on the alert view and its items (actions and text fields). + + @param accessibilityIdentifier the identifier. + */ +- (void)mxk_setAccessibilityIdentifier:(NSString *)accessibilityIdentifier; + +@end diff --git a/Riot/Modules/MatrixKit/Categories/UIAlertController+MatrixKit.m b/Riot/Modules/MatrixKit/Categories/UIAlertController+MatrixKit.m new file mode 100644 index 000000000..716da03ce --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/UIAlertController+MatrixKit.m @@ -0,0 +1,38 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "UIAlertController+MatrixKit.h" + +@implementation UIAlertController (MatrixKit) + +- (void)mxk_setAccessibilityIdentifier:(NSString *)accessibilityIdentifier +{ + self.view.accessibilityIdentifier = accessibilityIdentifier; + + for (UIAlertAction *action in self.actions) + { + action.accessibilityLabel = [NSString stringWithFormat:@"%@Action%@", accessibilityIdentifier, action.title]; + } + + NSArray *textFieldArray = self.textFields; + for (NSUInteger index = 0; index < textFieldArray.count; index++) + { + UITextField *textField = textFieldArray[index]; + textField.accessibilityIdentifier = [NSString stringWithFormat:@"%@TextField%tu", accessibilityIdentifier, index]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Categories/UITextView+MatrixKit.h b/Riot/Modules/MatrixKit/Categories/UITextView+MatrixKit.h new file mode 100644 index 000000000..775aa9f8b --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/UITextView+MatrixKit.h @@ -0,0 +1,33 @@ +/* + Copyright 2019 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +@import UIKit; + +NS_ASSUME_NONNULL_BEGIN + +@interface UITextView(MatrixKit) + +/** + Determine if there is a link near a location point in UITextView bounds. + + @param point The point inside the UITextView bounds + @return YES to indicate that a link has been detected near the location point. + */ +- (BOOL)isThereALinkNearPoint:(CGPoint)point; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/MatrixKit/Categories/UITextView+MatrixKit.m b/Riot/Modules/MatrixKit/Categories/UITextView+MatrixKit.m new file mode 100644 index 000000000..b22817f60 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/UITextView+MatrixKit.m @@ -0,0 +1,54 @@ +/* + Copyright 2019 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "UITextView+MatrixKit.h" + +@implementation UITextView(MatrixKit) + +- (BOOL)isThereALinkNearPoint:(CGPoint)point +{ + if (!CGRectContainsPoint(self.bounds, point)) + { + return NO; + } + + UITextPosition *textPosition = [self closestPositionToPoint:point]; + + if (!textPosition) + { + return NO; + } + + UITextRange *textRange = [self.tokenizer rangeEnclosingPosition:textPosition + withGranularity:UITextGranularityCharacter + inDirection:UITextLayoutDirectionLeft]; + + if (!textRange) + { + return NO; + } + + NSInteger startIndex = [self offsetFromPosition:self.beginningOfDocument toPosition:textRange.start]; + + if (startIndex < 0) + { + return NO; + } + + return [self.attributedText attribute:NSLinkAttributeName atIndex:startIndex effectiveRange:NULL] != nil; +} + +@end diff --git a/Riot/Modules/MatrixKit/Categories/UIViewController+MatrixKit.h b/Riot/Modules/MatrixKit/Categories/UIViewController+MatrixKit.h new file mode 100644 index 000000000..1b1b8fde2 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/UIViewController+MatrixKit.h @@ -0,0 +1,30 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface UIViewController (MatrixKit) + +/** + The main navigation controller if the view controller is embedded inside a split view controller. + */ +@property (nullable, nonatomic, readonly) UINavigationController *mxk_mainNavigationController; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/MatrixKit/Categories/UIViewController+MatrixKit.m b/Riot/Modules/MatrixKit/Categories/UIViewController+MatrixKit.m new file mode 100644 index 000000000..4d9f981fb --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/UIViewController+MatrixKit.m @@ -0,0 +1,45 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "UIViewController+MatrixKit.h" + +@implementation UIViewController (MatrixKit) + +- (UINavigationController *)mxk_mainNavigationController +{ + UINavigationController *mainNavigationController; + + if (self.splitViewController) + { + mainNavigationController = self.navigationController; + UIViewController *parentViewController = self.parentViewController; + while (parentViewController) + { + if (parentViewController.navigationController) + { + mainNavigationController = parentViewController.navigationController; + parentViewController = parentViewController.parentViewController; + } + else + { + break; + } + } + } + + return mainNavigationController; +} +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.h new file mode 100644 index 000000000..60b9c4044 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.h @@ -0,0 +1,124 @@ +/* +Copyright 2015 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +#import + +#import "MXKTableViewController.h" + +#import "MXKAccountManager.h" + +#import "MXK3PID.h" + +/** + */ +typedef void (^blockMXKAccountDetailsViewController_onReadyToLeave)(void); + +/** + MXKAccountDetailsViewController instance may be used to display/edit the details of a matrix account. + Only one matrix session is handled by this view controller. + */ +@interface MXKAccountDetailsViewController : MXKTableViewController +{ +@protected + + /** + Section index + */ + NSInteger linkedEmailsSection; + NSInteger notificationsSection; + NSInteger configurationSection; + + /** + The logout button + */ + UIButton *logoutButton; + + /** + Linked email + */ + MXK3PID *submittedEmail; + UIButton *emailSubmitButton; + UITextField *emailTextField; + + // Notifications + UISwitch *apnsNotificationsSwitch; + UISwitch *inAppNotificationsSwitch; + + // The table cell with "Global Notification Settings" button + UIButton *notificationSettingsButton; +} + +/** + The account displayed into the view controller. + */ +@property (nonatomic) MXKAccount *mxAccount; + +/** + The default account picture displayed when no picture is defined. + */ +@property (nonatomic) UIImage *picturePlaceholder; + +@property (nonatomic, readonly) IBOutlet UIButton *userPictureButton; +@property (nonatomic, readonly) IBOutlet UITextField *userDisplayName; +@property (nonatomic, readonly) IBOutlet UIButton *saveUserInfoButton; + +@property (nonatomic, readonly) IBOutlet UIView *profileActivityIndicatorBgView; +@property (nonatomic, readonly) IBOutlet UIActivityIndicatorView *profileActivityIndicator; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKAccountDetailsViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `accountDetailsViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKAccountDetailsViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKAccountDetailsViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)accountDetailsViewController; + +/** + Action registered on the following events: + - 'UIControlEventTouchUpInside' for each UIButton instance. + - 'UIControlEventValueChanged' for each UISwitch instance. + */ +- (IBAction)onButtonPressed:(id)sender; + +/** + Action registered to handle text field edition + */ +- (IBAction)textFieldEditingChanged:(id)sender; + +/** + Prompt user to save potential changes before leaving the view controller. + + @param handler A block object called when the changes have been saved or discarded. + + @return YES if no change is observed. NO when the user is prompted. + */ +- (BOOL)shouldLeave:(blockMXKAccountDetailsViewController_onReadyToLeave)handler; + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.m new file mode 100644 index 000000000..95732b072 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.m @@ -0,0 +1,1172 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAccountDetailsViewController.h" + +@import MatrixSDK; +#import "MXK3PID.h" + +#import "MXKTools.h" + +#import "MXKTableViewCellWithButton.h" +#import "MXKTableViewCellWithTextFieldAndButton.h" +#import "MXKTableViewCellWithLabelTextFieldAndButton.h" +#import "MXKTableViewCellWithTextView.h" +#import "MXKTableViewCellWithLabelAndSwitch.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKConstants.h" + +#import "MXKSwiftHeader.h" + +NSString* const kMXKAccountDetailsLinkedEmailCellId = @"kMXKAccountDetailsLinkedEmailCellId"; + +@interface MXKAccountDetailsViewController () +{ + NSMutableArray *alertsArray; + + // User's profile + MXMediaLoader *imageLoader; + NSString *currentDisplayName; + NSString *currentPictureURL; + NSString *currentDownloadId; + NSString *uploadedPictureURL; + // Local changes + BOOL isAvatarUpdated; + BOOL isSavingInProgress; + blockMXKAccountDetailsViewController_onReadyToLeave onReadyToLeaveHandler; + + // account user's profile observer + id accountUserInfoObserver; + + // Dynamic rows in the Linked emails section + NSInteger submittedEmailRowIndex; + + // Notifications + // Dynamic rows in the Notifications section + NSInteger enablePushNotifRowIndex; + NSInteger enableInAppNotifRowIndex; + + UIImagePickerController *mediaPicker; +} + +@end + +@implementation MXKAccountDetailsViewController +@synthesize userPictureButton, userDisplayName, saveUserInfoButton; +@synthesize profileActivityIndicator, profileActivityIndicatorBgView; + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKAccountDetailsViewController class]) + bundle:[NSBundle bundleForClass:[MXKAccountDetailsViewController class]]]; +} + ++ (instancetype)accountDetailsViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKAccountDetailsViewController class]) + bundle:[NSBundle bundleForClass:[MXKAccountDetailsViewController class]]]; +} + +#pragma mark - + +- (void)finalizeInit +{ + [super finalizeInit]; + + alertsArray = [NSMutableArray array]; + + isAvatarUpdated = NO; + isSavingInProgress = NO; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!userPictureButton) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + self.userPictureButton.backgroundColor = [UIColor clearColor]; + [self updateUserPictureButton:self.picturePlaceholder]; + + [userPictureButton.layer setCornerRadius:userPictureButton.frame.size.width / 2]; + userPictureButton.clipsToBounds = YES; + + [saveUserInfoButton setTitle:[MatrixKitL10n accountSaveChanges] forState:UIControlStateNormal]; + [saveUserInfoButton setTitle:[MatrixKitL10n accountSaveChanges] forState:UIControlStateHighlighted]; + + // Force refresh + self.mxAccount = _mxAccount; +} + +- (void)didReceiveMemoryWarning +{ + [super didReceiveMemoryWarning]; + // Dispose of any resources that can be recreated. + + if (imageLoader) + { + [imageLoader cancel]; + imageLoader = nil; + } +} + +- (void)dealloc +{ + alertsArray = nil; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onAPNSStatusUpdate) name:kMXKAccountAPNSActivityDidChangeNotification object:nil]; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + [self stopProfileActivityIndicator]; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXKAccountAPNSActivityDidChangeNotification object:nil]; +} + +#pragma mark - override + +- (void)onMatrixSessionChange +{ + [super onMatrixSessionChange]; + + if (self.mainSession.state != MXSessionStateRunning) + { + userPictureButton.enabled = NO; + userDisplayName.enabled = NO; + } + else if (!isSavingInProgress) + { + userPictureButton.enabled = YES; + userDisplayName.enabled = YES; + } +} + +#pragma mark - + +- (void)setMxAccount:(MXKAccount *)account +{ + // Remove observer and existing data + [self reset]; + + _mxAccount = account; + + if (account) + { + // Report matrix account session + [self addMatrixSession:account.mxSession]; + + // Set current user's information and add observers + [self updateUserPicture:_mxAccount.userAvatarUrl force:YES]; + currentDisplayName = _mxAccount.userDisplayName; + self.userDisplayName.text = currentDisplayName; + [self updateSaveUserInfoButtonStatus]; + + // Load linked emails + [self loadLinkedEmails]; + + // Add observer on user's information + accountUserInfoObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXKAccountUserInfoDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + // Ignore any refresh when saving is in progress + if (self->isSavingInProgress) + { + return; + } + + NSString *accountUserId = notif.object; + + if ([accountUserId isEqualToString:self->_mxAccount.mxCredentials.userId]) + { + // Update displayName + if (![self->currentDisplayName isEqualToString:self->_mxAccount.userDisplayName]) + { + self->currentDisplayName = self->_mxAccount.userDisplayName; + self.userDisplayName.text = self->_mxAccount.userDisplayName; + } + // Update user's avatar + [self updateUserPicture:self->_mxAccount.userAvatarUrl force:NO]; + + // Update button management + [self updateSaveUserInfoButtonStatus]; + + // Display user's presence + UIColor *presenceColor = [MXKAccount presenceColor:self->_mxAccount.userPresence]; + if (presenceColor) + { + self->userPictureButton.layer.borderWidth = 2; + self->userPictureButton.layer.borderColor = presenceColor.CGColor; + } + else + { + self->userPictureButton.layer.borderWidth = 0; + } + } + }]; + } + + [self.tableView reloadData]; +} + +- (UIImage*)picturePlaceholder +{ + return [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"default-profile"]; +} + +- (BOOL)shouldLeave:(blockMXKAccountDetailsViewController_onReadyToLeave)handler +{ + // Check whether some local changes have not been saved + if (saveUserInfoButton.enabled) + { + dispatch_async(dispatch_get_main_queue(), ^{ + + UIAlertController *alert = [UIAlertController alertControllerWithTitle:nil message:[MatrixKitL10n messageUnsavedChanges] preferredStyle:UIAlertControllerStyleAlert]; + + [self->alertsArray addObject:alert]; + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n discard] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + [self->alertsArray removeObject:alert]; + + // Discard changes + self.userDisplayName.text = self->currentDisplayName; + [self updateUserPicture:self->_mxAccount.userAvatarUrl force:YES]; + + // Ready to leave + if (handler) + { + handler(); + } + + }]]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n save] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + [self->alertsArray removeObject:alert]; + + // Start saving (Report handler to leave at the end). + self->onReadyToLeaveHandler = handler; + [self saveUserInfo]; + + }]]; + + [self presentViewController:alert animated:YES completion:nil]; + }); + + return NO; + } + else if (isSavingInProgress) + { + // Report handler to leave at the end of saving + onReadyToLeaveHandler = handler; + return NO; + } + return YES; +} + +#pragma mark - Internal methods + +- (void)startProfileActivityIndicator +{ + if (profileActivityIndicatorBgView.hidden) + { + profileActivityIndicatorBgView.hidden = NO; + [profileActivityIndicator startAnimating]; + } + userPictureButton.enabled = NO; + userDisplayName.enabled = NO; + saveUserInfoButton.enabled = NO; +} + +- (void)stopProfileActivityIndicator +{ + if (!isSavingInProgress) + { + if (!profileActivityIndicatorBgView.hidden) + { + profileActivityIndicatorBgView.hidden = YES; + [profileActivityIndicator stopAnimating]; + } + userPictureButton.enabled = YES; + userDisplayName.enabled = YES; + [self updateSaveUserInfoButtonStatus]; + } +} + +- (void)reset +{ + [self dismissMediaPicker]; + + // Remove observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + // Cancel picture loader (if any) + if (imageLoader) + { + [imageLoader cancel]; + imageLoader = nil; + } + + // Cancel potential alerts + for (UIAlertController *alert in alertsArray) + { + [alert dismissViewControllerAnimated:NO completion:nil]; + } + + // Remove listener + if (accountUserInfoObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:accountUserInfoObserver]; + accountUserInfoObserver = nil; + } + + currentPictureURL = nil; + currentDownloadId = nil; + uploadedPictureURL = nil; + isAvatarUpdated = NO; + [self updateUserPictureButton:self.picturePlaceholder]; + + currentDisplayName = nil; + self.userDisplayName.text = nil; + + saveUserInfoButton.enabled = NO; + + submittedEmail = nil; + emailSubmitButton = nil; + emailTextField = nil; + + [self removeMatrixSession:self.mainSession]; + + logoutButton = nil; + + onReadyToLeaveHandler = nil; +} + +- (void)destroy +{ + if (isSavingInProgress) + { + __weak typeof(self) weakSelf = self; + onReadyToLeaveHandler = ^() + { + __strong __typeof(weakSelf)strongSelf = weakSelf; + [strongSelf destroy]; + }; + } + else + { + // Reset account to dispose all resources (Discard here potentials changes) + self.mxAccount = nil; + + if (imageLoader) + { + [imageLoader cancel]; + imageLoader = nil; + } + + // Remove listener + if (accountUserInfoObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:accountUserInfoObserver]; + accountUserInfoObserver = nil; + } + + [super destroy]; + } +} + +- (void)saveUserInfo +{ + [self startProfileActivityIndicator]; + isSavingInProgress = YES; + + // Check whether the display name has been changed + NSString *displayname = self.userDisplayName.text; + if ((displayname.length || currentDisplayName.length) && [displayname isEqualToString:currentDisplayName] == NO) + { + // Save display name + __weak typeof(self) weakSelf = self; + + [_mxAccount setUserDisplayName:displayname success:^{ + + if (weakSelf) + { + // Update the current displayname + typeof(self) self = weakSelf; + self->currentDisplayName = displayname; + + // Go to the next change saving step + [self saveUserInfo]; + } + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKAccountDetailsVC] Failed to set displayName"); + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Alert user + NSString *title = [error.userInfo valueForKey:NSLocalizedFailureReasonErrorKey]; + if (!title) + { + title = [MatrixKitL10n accountErrorDisplayNameChangeFailed]; + } + NSString *msg = [error.userInfo valueForKey:NSLocalizedDescriptionKey]; + + UIAlertController *alert = [UIAlertController alertControllerWithTitle:title message:msg preferredStyle:UIAlertControllerStyleAlert]; + + [self->alertsArray addObject:alert]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n abort] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + [self->alertsArray removeObject:alert]; + // Discard changes + self.userDisplayName.text = self->currentDisplayName; + [self updateUserPicture:self.mxAccount.userAvatarUrl force:YES]; + // Loop to end saving + [self saveUserInfo]; + + }]]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n retry] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + [self->alertsArray removeObject:alert]; + // Loop to retry saving + [self saveUserInfo]; + + }]]; + + + [self presentViewController:alert animated:YES completion:nil]; + } + + }]; + + return; + } + + // Check whether avatar has been updated + if (isAvatarUpdated) + { + if (uploadedPictureURL == nil) + { + // Retrieve the current picture and make sure its orientation is up + UIImage *updatedPicture = [MXKTools forceImageOrientationUp:[self.userPictureButton imageForState:UIControlStateNormal]]; + + MXWeakify(self); + + // Upload picture + MXMediaLoader *uploader = [MXMediaManager prepareUploaderWithMatrixSession:self.mainSession initialRange:0 andRange:1.0]; + [uploader uploadData:UIImageJPEGRepresentation(updatedPicture, 0.5) filename:nil mimeType:@"image/jpeg" success:^(NSString *url) + { + MXStrongifyAndReturnIfNil(self); + + // Store uploaded picture url and trigger picture saving + self->uploadedPictureURL = url; + [self saveUserInfo]; + } failure:^(NSError *error) + { + MXLogDebug(@"[MXKAccountDetailsVC] Failed to upload image"); + MXStrongifyAndReturnIfNil(self); + [self handleErrorDuringPictureSaving:error]; + }]; + + } + else + { + MXWeakify(self); + + [_mxAccount setUserAvatarUrl:uploadedPictureURL + success:^{ + + // uploadedPictureURL becomes the user's picture + MXStrongifyAndReturnIfNil(self); + + [self updateUserPicture:self->uploadedPictureURL force:YES]; + // Loop to end saving + [self saveUserInfo]; + + } + failure:^(NSError *error) { + MXLogDebug(@"[MXKAccountDetailsVC] Failed to set avatar url"); + MXStrongifyAndReturnIfNil(self); + [self handleErrorDuringPictureSaving:error]; + }]; + } + + return; + } + + // Backup is complete + isSavingInProgress = NO; + [self stopProfileActivityIndicator]; + + // Ready to leave + if (onReadyToLeaveHandler) + { + onReadyToLeaveHandler(); + onReadyToLeaveHandler = nil; + } +} + +- (void)handleErrorDuringPictureSaving:(NSError*)error +{ + NSString *title = [error.userInfo valueForKey:NSLocalizedFailureReasonErrorKey]; + if (!title) + { + title = [MatrixKitL10n accountErrorPictureChangeFailed]; + } + NSString *msg = [error.userInfo valueForKey:NSLocalizedDescriptionKey]; + + UIAlertController *alert = [UIAlertController alertControllerWithTitle:title message:msg preferredStyle:UIAlertControllerStyleAlert]; + + [alertsArray addObject:alert]; + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n abort] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + [self->alertsArray removeObject:alert]; + + // Remove change + self.userDisplayName.text = self->currentDisplayName; + [self updateUserPicture:self->_mxAccount.userAvatarUrl force:YES]; + // Loop to end saving + [self saveUserInfo]; + + }]]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n retry] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + [self->alertsArray removeObject:alert]; + + // Loop to retry saving + [self saveUserInfo]; + + }]]; + + [self presentViewController:alert animated:YES completion:nil]; +} + +- (void)updateUserPicture:(NSString *)avatar_url force:(BOOL)force +{ + if (force || currentPictureURL == nil || [currentPictureURL isEqualToString:avatar_url] == NO) + { + // Remove any pending observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + // Cancel previous loader (if any) + if (imageLoader) + { + [imageLoader cancel]; + imageLoader = nil; + } + // Cancel any local change + isAvatarUpdated = NO; + uploadedPictureURL = nil; + + currentPictureURL = [avatar_url isEqual:[NSNull null]] ? nil : avatar_url; + + // Check whether this url is valid + currentDownloadId = [MXMediaManager thumbnailDownloadIdForMatrixContentURI:currentPictureURL + inFolder:kMXMediaManagerAvatarThumbnailFolder + toFitViewSize:self.userPictureButton.frame.size + withMethod:MXThumbnailingMethodCrop]; + if (!currentDownloadId) + { + // Set the placeholder in case of invalid Matrix Content URI. + [self updateUserPictureButton:self.picturePlaceholder]; + } + else + { + // Check whether the image download is in progress + id loader = [MXMediaManager existingDownloaderWithIdentifier:currentDownloadId]; + if (loader) + { + // Observe this loader + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(onMediaLoaderStateChange:) + name:kMXMediaLoaderStateDidChangeNotification + object:loader]; + } + else + { + NSString *cacheFilePath = [MXMediaManager thumbnailCachePathForMatrixContentURI:currentPictureURL + andType:nil + inFolder:kMXMediaManagerAvatarThumbnailFolder + toFitViewSize:self.userPictureButton.frame.size + withMethod:MXThumbnailingMethodCrop]; + // Retrieve the image from cache + UIImage* image = [MXMediaManager loadPictureFromFilePath:cacheFilePath]; + if (image) + { + [self updateUserPictureButton:image]; + } + else + { + // Download the image, by adding download observer + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(onMediaLoaderStateChange:) + name:kMXMediaLoaderStateDidChangeNotification + object:nil]; + imageLoader = [self.mainSession.mediaManager downloadThumbnailFromMatrixContentURI:currentPictureURL + withType:nil + inFolder:kMXMediaManagerAvatarThumbnailFolder + toFitViewSize:self.userPictureButton.frame.size + withMethod:MXThumbnailingMethodCrop + success:nil + failure:nil]; + } + } + } + } +} + +- (void)updateUserPictureButton:(UIImage*)image +{ + [self.userPictureButton setImage:image forState:UIControlStateNormal]; + [self.userPictureButton setImage:image forState:UIControlStateHighlighted]; + [self.userPictureButton setImage:image forState:UIControlStateDisabled]; +} + +- (void)updateSaveUserInfoButtonStatus +{ + // Check whether display name has been changed + NSString *displayname = self.userDisplayName.text; + BOOL isDisplayNameUpdated = ((displayname.length || currentDisplayName.length) && [displayname isEqualToString:currentDisplayName] == NO); + + saveUserInfoButton.enabled = (isDisplayNameUpdated || isAvatarUpdated) && !isSavingInProgress; +} + +- (void)onMediaLoaderStateChange:(NSNotification *)notif +{ + MXMediaLoader *loader = (MXMediaLoader*)notif.object; + if ([loader.downloadId isEqualToString:currentDownloadId]) + { + // update the image + switch (loader.state) { + case MXMediaLoaderStateDownloadCompleted: + { + UIImage *image = [MXMediaManager loadPictureFromFilePath:loader.downloadOutputFilePath]; + if (image == nil) + { + image = self.picturePlaceholder; + } + [self updateUserPictureButton:image]; + // remove the observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + imageLoader = nil; + break; + } + case MXMediaLoaderStateDownloadFailed: + case MXMediaLoaderStateCancelled: + [self updateUserPictureButton:self.picturePlaceholder]; + // remove the observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + imageLoader = nil; + // Reset picture URL in order to try next time + currentPictureURL = nil; + break; + default: + break; + } + } +} + +- (void)onAPNSStatusUpdate +{ + // Force table reload to update notifications section + apnsNotificationsSwitch = nil; + + [self.tableView reloadData]; +} + +- (void)dismissMediaPicker +{ + if (mediaPicker) + { + [self dismissViewControllerAnimated:NO completion:nil]; + mediaPicker.delegate = nil; + mediaPicker = nil; + } +} + +- (void)showValidationEmailDialogWithMessage:(NSString*)message +{ + UIAlertController *alert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n accountEmailValidationTitle] message:message preferredStyle:UIAlertControllerStyleAlert]; + + [alertsArray addObject:alert]; + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n abort] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + [self->alertsArray removeObject:alert]; + + self->emailSubmitButton.enabled = YES; + + }]]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n continue] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + [self->alertsArray removeObject:alert]; + + __weak typeof(self) weakSelf = self; + + // We do not bind anymore emails when registering, so let's do the same here + [self->submittedEmail add3PIDToUser:NO success:^{ + + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Release pending email and refresh table to remove related cell + self->emailTextField.text = nil; + self->submittedEmail = nil; + + // Update linked emails + [self loadLinkedEmails]; + } + + } failure:^(NSError *error) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + MXLogDebug(@"[MXKAccountDetailsVC] Failed to bind email"); + + // Display the same popup again if the error is M_THREEPID_AUTH_FAILED + MXError *mxError = [[MXError alloc] initWithNSError:error]; + if (mxError && [mxError.errcode isEqualToString:kMXErrCodeStringThreePIDAuthFailed]) + { + [self showValidationEmailDialogWithMessage:[MatrixKitL10n accountEmailValidationError]]; + } + else + { + // Notify MatrixKit user + NSString *myUserId = self.mxAccount.mxCredentials.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + } + + // Release the pending email (even if it is Authenticated) + [self.tableView reloadData]; + } + + }]; + + }]]; + + [self presentViewController:alert animated:YES completion:nil]; +} + +- (void)loadLinkedEmails +{ + // Refresh the account 3PIDs list + [_mxAccount load3PIDs:^{ + + [self.tableView reloadData]; + + } failure:^(NSError *error) { + // Display the data that has been loaded last time + [self.tableView reloadData]; + }]; +} + +#pragma mark - Actions + +- (IBAction)onButtonPressed:(id)sender +{ + [self dismissKeyboard]; + + if (sender == saveUserInfoButton) + { + [self saveUserInfo]; + } + else if (sender == userPictureButton) + { + // Open picture gallery + mediaPicker = [[UIImagePickerController alloc] init]; + mediaPicker.delegate = self; + mediaPicker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary; + mediaPicker.allowsEditing = NO; + [self presentViewController:mediaPicker animated:YES completion:nil]; + } + else if (sender == logoutButton) + { + [[MXKAccountManager sharedManager] removeAccount:_mxAccount completion:nil]; + self.mxAccount = nil; + } + else if (sender == emailSubmitButton) + { + // Email check + if (![MXTools isEmailAddress:emailTextField.text]) + { + UIAlertController *alert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n accountErrorEmailWrongTitle] message:[MatrixKitL10n accountErrorEmailWrongDescription] preferredStyle:UIAlertControllerStyleAlert]; + + [alertsArray addObject:alert]; + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + [self->alertsArray removeObject:alert]; + + }]]; + + [self presentViewController:alert animated:YES completion:nil]; + + return; + } + + if (!submittedEmail || ![submittedEmail.address isEqualToString:emailTextField.text]) + { + submittedEmail = [[MXK3PID alloc] initWithMedium:kMX3PIDMediumEmail andAddress:emailTextField.text]; + } + + emailSubmitButton.enabled = NO; + __weak typeof(self) weakSelf = self; + + [submittedEmail requestValidationTokenWithMatrixRestClient:self.mainSession.matrixRestClient isDuringRegistration:NO nextLink:nil success:^{ + + if (weakSelf) + { + typeof(self) self = weakSelf; + [self showValidationEmailDialogWithMessage:[MatrixKitL10n accountEmailValidationMessage]]; + } + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKAccountDetailsVC] Failed to request email token"); + if (weakSelf) + { + typeof(self) self = weakSelf; + // Notify MatrixKit user + NSString *myUserId = self.mxAccount.mxCredentials.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + self->emailSubmitButton.enabled = YES; + } + + }]; + } + else if (sender == apnsNotificationsSwitch) + { + [_mxAccount enablePushNotifications:apnsNotificationsSwitch.on success:nil failure:nil]; + apnsNotificationsSwitch.enabled = NO; + } + else if (sender == inAppNotificationsSwitch) + { + _mxAccount.enableInAppNotifications = inAppNotificationsSwitch.on; + [self.tableView reloadData]; + } +} + +#pragma mark - keyboard + +- (void)dismissKeyboard +{ + if ([userDisplayName isFirstResponder]) + { + // Hide the keyboard + [userDisplayName resignFirstResponder]; + [self updateSaveUserInfoButtonStatus]; + } + else if ([emailTextField isFirstResponder]) + { + [emailTextField resignFirstResponder]; + } +} + +#pragma mark - UITextField delegate + +- (BOOL)textFieldShouldReturn:(UITextField*)textField +{ + // "Done" key has been pressed + [self dismissKeyboard]; + return YES; +} + +- (IBAction)textFieldEditingChanged:(id)sender +{ + if (sender == userDisplayName) + { + [self updateSaveUserInfoButtonStatus]; + } +} + +#pragma mark - UITableView data source + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView +{ + NSInteger count = 0; + + linkedEmailsSection = notificationsSection = configurationSection = -1; + + if (!_mxAccount.disabled) + { + linkedEmailsSection = count ++; + notificationsSection = count ++; + } + + configurationSection = count ++; + + return count; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + NSInteger count = 0; + if (section == linkedEmailsSection) + { + count = _mxAccount.linkedEmails.count; + submittedEmailRowIndex = count++; + } + else if (section == notificationsSection) + { + enableInAppNotifRowIndex = enablePushNotifRowIndex = -1; + + if ([MXKAccountManager sharedManager].isAPNSAvailable) { + enablePushNotifRowIndex = count++; + } + enableInAppNotifRowIndex = count++; + } + else if (section == configurationSection) + { + count = 2; + } + + return count; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (indexPath.section == configurationSection) + { + if (indexPath.row == 0) + { + UITextView *textView = [[UITextView alloc] initWithFrame:CGRectMake(0, 0, tableView.frame.size.width, MAXFLOAT)]; + textView.font = [UIFont systemFontOfSize:14]; + textView.text = [NSString stringWithFormat:@"%@\n%@\n%@", [MatrixKitL10n settingsConfigHomeServer:_mxAccount.mxCredentials.homeServer], [MatrixKitL10n settingsConfigIdentityServer:_mxAccount.identityServerURL], [MatrixKitL10n settingsConfigUserId:_mxAccount.mxCredentials.userId]]; + + CGSize contentSize = [textView sizeThatFits:textView.frame.size]; + return contentSize.height + 1; + } + } + + return 44; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UITableViewCell *cell = nil; + + if (indexPath.section == linkedEmailsSection) + { + if (indexPath.row < _mxAccount.linkedEmails.count) + { + cell = [tableView dequeueReusableCellWithIdentifier:kMXKAccountDetailsLinkedEmailCellId]; + if (!cell) + { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:kMXKAccountDetailsLinkedEmailCellId]; + } + + cell.selectionStyle = UITableViewCellSelectionStyleNone; + cell.textLabel.text = [_mxAccount.linkedEmails objectAtIndex:indexPath.row]; + } + else if (indexPath.row == submittedEmailRowIndex) + { + // Report the current email value (if any) + NSString *currentEmail = nil; + if (emailTextField) + { + currentEmail = emailTextField.text; + } + + MXKTableViewCellWithTextFieldAndButton *submittedEmailCell = [tableView dequeueReusableCellWithIdentifier:[MXKTableViewCellWithTextFieldAndButton defaultReuseIdentifier]]; + if (!submittedEmailCell) + { + submittedEmailCell = [[MXKTableViewCellWithTextFieldAndButton alloc] init]; + } + + submittedEmailCell.mxkTextField.text = currentEmail; + submittedEmailCell.mxkTextField.keyboardType = UIKeyboardTypeEmailAddress; + submittedEmailCell.mxkButton.enabled = (currentEmail.length != 0); + [submittedEmailCell.mxkButton setTitle:[MatrixKitL10n accountLinkEmail] forState:UIControlStateNormal]; + [submittedEmailCell.mxkButton setTitle:[MatrixKitL10n accountLinkEmail] forState:UIControlStateHighlighted]; + [submittedEmailCell.mxkButton addTarget:self action:@selector(onButtonPressed:) forControlEvents:UIControlEventTouchUpInside]; + + emailSubmitButton = submittedEmailCell.mxkButton; + emailTextField = submittedEmailCell.mxkTextField; + + cell = submittedEmailCell; + } + } + else if (indexPath.section == notificationsSection) + { + MXKTableViewCellWithLabelAndSwitch *notificationsCell = [tableView dequeueReusableCellWithIdentifier:[MXKTableViewCellWithLabelAndSwitch defaultReuseIdentifier]]; + if (!notificationsCell) + { + notificationsCell = [[MXKTableViewCellWithLabelAndSwitch alloc] init]; + } + else + { + // Force layout before reusing a cell (fix switch displayed outside the screen) + [notificationsCell layoutIfNeeded]; + } + + [notificationsCell.mxkSwitch addTarget:self action:@selector(onButtonPressed:) forControlEvents:UIControlEventValueChanged]; + + if (indexPath.row == enableInAppNotifRowIndex) + { + notificationsCell.mxkLabel.text = [MatrixKitL10n settingsEnableInappNotifications]; + notificationsCell.mxkSwitch.on = _mxAccount.enableInAppNotifications; + inAppNotificationsSwitch = notificationsCell.mxkSwitch; + } + else /* enablePushNotifRowIndex */ + { + notificationsCell.mxkLabel.text = [MatrixKitL10n settingsEnablePushNotifications]; + notificationsCell.mxkSwitch.on = _mxAccount.pushNotificationServiceIsActive; + notificationsCell.mxkSwitch.enabled = YES; + apnsNotificationsSwitch = notificationsCell.mxkSwitch; + } + + cell = notificationsCell; + } + else if (indexPath.section == configurationSection) + { + if (indexPath.row == 0) + { + MXKTableViewCellWithTextView *configCell = [tableView dequeueReusableCellWithIdentifier:[MXKTableViewCellWithTextView defaultReuseIdentifier]]; + if (!configCell) + { + configCell = [[MXKTableViewCellWithTextView alloc] init]; + } + + configCell.mxkTextView.text = [NSString stringWithFormat:@"%@\n%@\n%@", [MatrixKitL10n settingsConfigHomeServer:_mxAccount.mxCredentials.homeServer], [MatrixKitL10n settingsConfigIdentityServer:_mxAccount.identityServerURL], [MatrixKitL10n settingsConfigUserId:_mxAccount.mxCredentials.userId]]; + + cell = configCell; + } + else + { + MXKTableViewCellWithButton *logoutBtnCell = [tableView dequeueReusableCellWithIdentifier:[MXKTableViewCellWithButton defaultReuseIdentifier]]; + if (!logoutBtnCell) + { + logoutBtnCell = [[MXKTableViewCellWithButton alloc] init]; + } + [logoutBtnCell.mxkButton setTitle:[MatrixKitL10n actionLogout] forState:UIControlStateNormal]; + [logoutBtnCell.mxkButton setTitle:[MatrixKitL10n actionLogout] forState:UIControlStateHighlighted]; + [logoutBtnCell.mxkButton addTarget:self action:@selector(onButtonPressed:) forControlEvents:UIControlEventTouchUpInside]; + + logoutButton = logoutBtnCell.mxkButton; + + cell = logoutBtnCell; + } + + } + else + { + // Return a fake cell to prevent app from crashing. + cell = [[UITableViewCell alloc] init]; + } + + return cell; +} + +#pragma mark - UITableView delegate + +- (CGFloat) tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section +{ + return 30; +} +- (CGFloat) tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)section +{ + return 1; +} + +- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section +{ + UIView *sectionHeader = [[UIView alloc] initWithFrame:[tableView rectForHeaderInSection:section]]; + sectionHeader.backgroundColor = [UIColor colorWithRed:0.9 green:0.9 blue:0.9 alpha:1.0]; + UILabel *sectionLabel = [[UILabel alloc] initWithFrame:CGRectMake(5, 5, sectionHeader.frame.size.width - 10, sectionHeader.frame.size.height - 10)]; + sectionLabel.font = [UIFont boldSystemFontOfSize:16]; + sectionLabel.backgroundColor = [UIColor clearColor]; + [sectionHeader addSubview:sectionLabel]; + + if (section == linkedEmailsSection) + { + sectionLabel.text = [MatrixKitL10n accountLinkedEmails]; + } + else if (section == notificationsSection) + { + sectionLabel.text = [MatrixKitL10n settingsTitleNotifications]; + } + else if (section == configurationSection) + { + sectionLabel.text = [MatrixKitL10n settingsTitleConfig]; + } + + return sectionHeader; +} + +- (void)tableView:(UITableView *)aTableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (self.tableView == aTableView) + { + [aTableView deselectRowAtIndexPath:indexPath animated:YES]; + } +} + +# pragma mark - UIImagePickerControllerDelegate + +- (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info +{ + UIImage *selectedImage = [info objectForKey:UIImagePickerControllerOriginalImage]; + if (selectedImage) + { + [self updateUserPictureButton:selectedImage]; + isAvatarUpdated = YES; + saveUserInfoButton.enabled = YES; + } + [self dismissMediaPicker]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.xib new file mode 100644 index 000000000..2d7cfd528 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.xib @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKActivityHandlingViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKActivityHandlingViewController.h new file mode 100644 index 000000000..400b9cc3b --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKActivityHandlingViewController.h @@ -0,0 +1,26 @@ +// +// Copyright 2020 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import +#import "MXKViewControllerActivityHandling.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface MXKActivityHandlingViewController : UIViewController + +@end + +NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/MatrixKit/Controllers/MXKActivityHandlingViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKActivityHandlingViewController.m new file mode 100644 index 000000000..5628c7396 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKActivityHandlingViewController.m @@ -0,0 +1,83 @@ +// +// Copyright 2020 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "MXKActivityHandlingViewController.h" + +@interface MXKActivityHandlingViewController () + +@end + +@implementation MXKActivityHandlingViewController +@synthesize activityIndicator; + +#pragma mark - + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Add default activity indicator + activityIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhite]; + activityIndicator.backgroundColor = [UIColor colorWithRed:0.8 green:0.8 blue:0.8 alpha:1.0]; + activityIndicator.autoresizingMask = UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin | UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin; + activityIndicator.hidesWhenStopped = YES; + + CGRect frame = activityIndicator.frame; + frame.size.width += 30; + frame.size.height += 30; + activityIndicator.bounds = frame; + [activityIndicator.layer setCornerRadius:5]; + + activityIndicator.center = self.view.center; + [self.view addSubview:activityIndicator]; +} + +- (void)dealloc +{ + if (activityIndicator) + { + [activityIndicator removeFromSuperview]; + activityIndicator = nil; + } +} + +#pragma mark - Activity indicator + +- (void)startActivityIndicator +{ + if (activityIndicator) + { + [self.view bringSubviewToFront:activityIndicator]; + [activityIndicator startAnimating]; + + // Show the loading wheel after a delay so that if the caller calls stopActivityIndicator + // in a short future, the loading wheel will not be displayed to the end user. + activityIndicator.alpha = 0; + [UIView animateWithDuration:0.3 delay:0.3 options:UIViewAnimationOptionBeginFromCurrentState animations:^{ + self->activityIndicator.alpha = 1; + } completion:^(BOOL finished) + { + }]; + } +} + +- (void)stopActivityIndicator +{ + [activityIndicator stopAnimating]; +} + + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.h new file mode 100644 index 000000000..60bfdf732 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.h @@ -0,0 +1,123 @@ +/* +Copyright 2015 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +#import + +#import "MXKViewController.h" +#import "MXKAttachment.h" +#import "MXKAttachmentAnimator.h" + +@protocol MXKAttachmentsViewControllerDelegate; + +/** + This view controller is used to display attachments of a room. + Only one attachment is displayed at once, the user is able to swipe one by one the attachment. + */ +@interface MXKAttachmentsViewController : MXKViewController + +@property (nonatomic) IBOutlet UICollectionView *attachmentsCollection; +@property (nonatomic) IBOutlet UINavigationBar *navigationBar; +@property (unsafe_unretained, nonatomic) IBOutlet UIBarButtonItem *backButton; + +/** + The attachments array. + */ +@property (nonatomic, readonly) NSArray *attachments; + +/** + Tell whether all attachments have been retrieved from the room history (In that case no attachment can be added at the beginning of attachments array). + */ +@property (nonatomic) BOOL complete; + +/** + The delegate notified when inputs are ready. + */ +@property (nonatomic, weak) id delegate; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKAttachmentsViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `roomViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKAttachmentsViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKAttachmentsViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)attachmentsViewController; + +/** + Creates and returns a new `MXKAttachmentsViewController` object, also sets sets up environment for animated interactive transitions. + */ ++ (instancetype)animatedAttachmentsViewControllerWithSourceViewController:(UIViewController *)sourceViewController; + +/** + Display attachments of a room. + + The provided event id is used to select the attachment to display first. Use nil to unchange the current displayed attachment. + By default the first attachment is displayed. + If the back pagination spinner is currently displayed and provided event id is nil, + the viewer will display the first added attachment during back pagination. + + @param attachmentArray the array of attachments (MXKAttachment instances). + @param eventId the identifier of the attachment to display first. + + */ +- (void)displayAttachments:(NSArray*)attachmentArray focusOn:(NSString*)eventId; + +/** + Action used to handle the `backButton` in the navigation bar. + */ +- (IBAction)onButtonPressed:(id)sender; + +@end + +@protocol MXKAttachmentsViewControllerDelegate + +/** + Ask the delegate for more attachments. + This method is called only if 'complete' is NO. + + When some attachments are available, the delegate update the attachmnet list by using + [MXKAttachmentsViewController displayAttachments: focusOn:]. + When no new attachment is available, the delegate must update the property 'complete'. + + @param attachmentsViewController the attachments view controller. + @param eventId the event identifier of the current first attachment. + @return a boolean which tells whether some new attachments may be added or not. + */ +- (BOOL)attachmentsViewController:(MXKAttachmentsViewController*)attachmentsViewController paginateAttachmentBefore:(NSString*)eventId; + +@optional + +/** + Informs the delegate that a new attachment has been shown + the parameter eventId is used by the delegate to identify the attachment + */ +- (void)displayedNewAttachmentWithEventId:(NSString *)eventId; + + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.m new file mode 100644 index 000000000..a21fbc396 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.m @@ -0,0 +1,1439 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAttachmentsViewController.h" + +#import + +@import MatrixSDK.MXMediaManager; + +#import "MXKMediaCollectionViewCell.h" + +#import "MXKPieChartView.h" + +#import "MXKConstants.h" + +#import "MXKTools.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKEventFormatter.h" + +#import "MXKAttachmentInteractionController.h" + +#import "MXKSwiftHeader.h" + +@interface MXKAttachmentsViewController () +{ + /** + Current alert (if any). + */ + UIAlertController *currentAlert; + + /** + Navigation bar handling + */ + NSTimer *navigationBarDisplayTimer; + + /** + SplitViewController handling + */ + BOOL shouldRestoreBottomBar; + UISplitViewControllerDisplayMode savedSplitViewControllerDisplayMode; + + /** + Audio session handling + */ + NSString *savedAVAudioSessionCategory; + + /** + The attachments array (MXAttachment instances). + */ + NSMutableArray *attachments; + + /** + The index of the current visible collection item + */ + NSInteger currentVisibleItemIndex; + + /** + The document interaction Controller used to share attachment + */ + UIDocumentInteractionController *documentInteractionController; + MXKAttachment *currentSharedAttachment; + + /** + Tells whether back pagination is in progress. + */ + BOOL isBackPaginationInProgress; + + /** + A temporary file used to store decrypted attachments + */ + NSString *tempFile; + + /** + Path to a file containing video data for the currently selected + attachment, if it's a video attachment and the data is + available. + */ + NSString *videoFile; +} + +//animations +@property (nonatomic) MXKAttachmentInteractionController *interactionController; + +@property (nonatomic, weak) UIViewController *sourceViewController; + +@property (nonatomic) UIImageView *originalImageView; +@property (nonatomic) CGRect convertedFrame; + +@property (nonatomic) BOOL customAnimationsEnabled; + +@end + +@implementation MXKAttachmentsViewController +@synthesize attachments; + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKAttachmentsViewController class]) + bundle:[NSBundle bundleForClass:[MXKAttachmentsViewController class]]]; +} + ++ (instancetype)attachmentsViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKAttachmentsViewController class]) + bundle:[NSBundle bundleForClass:[MXKAttachmentsViewController class]]]; +} + ++ (instancetype)animatedAttachmentsViewControllerWithSourceViewController:(UIViewController *)sourceViewController +{ + MXKAttachmentsViewController *attachmentsController = [[[self class] alloc] initWithNibName:NSStringFromClass([MXKAttachmentsViewController class]) + bundle:[NSBundle bundleForClass:[MXKAttachmentsViewController class]]]; + + //create an interactionController for it to handle the gestue recognizer and control the interactions + attachmentsController.interactionController = [[MXKAttachmentInteractionController alloc] initWithDestinationViewController:attachmentsController sourceViewController:sourceViewController]; + + //we use the animationsEnabled property to enable/disable animations. Instances created not using this method should use the default animations + attachmentsController.customAnimationsEnabled = YES; + + //this properties will be needed by animationControllers in order to perform the animations + attachmentsController.sourceViewController = sourceViewController; + + //setting transitioningDelegate and navigationController.delegate so that the animations will work for present/dismiss as well as push/pop + attachmentsController.transitioningDelegate = attachmentsController; + sourceViewController.navigationController.delegate = attachmentsController; + + + return attachmentsController; +} + +#pragma mark - + +- (void)finalizeInit +{ + [super finalizeInit]; + + tempFile = nil; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!_attachmentsCollection) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + self.backButton.image = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"back_icon"]; + + // Register collection view cell class + [self.attachmentsCollection registerClass:MXKMediaCollectionViewCell.class forCellWithReuseIdentifier:[MXKMediaCollectionViewCell defaultReuseIdentifier]]; + + // Hide collection to hide first scrolling into the attachments. + _attachmentsCollection.hidden = YES; + + // Display collection cell in full screen + self.automaticallyAdjustsScrollViewInsets = NO; +} + +- (BOOL)prefersStatusBarHidden +{ + // Hide status bar. + // Caution: Enable [UIViewController prefersStatusBarHidden] use at application level + // by turning on UIViewControllerBasedStatusBarAppearance in Info.plist. + return YES; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + videoFile = nil; + + savedAVAudioSessionCategory = [[AVAudioSession sharedInstance] category]; + + // Hide navigation bar by default. + [self hideNavigationBar]; + + // Hide status bar + // TODO: remove this [UIApplication statusBarHidden] use (deprecated since iOS 9). + // Note: setting statusBarHidden does nothing if your application is using the default UIViewController-based status bar system. + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication) + { + sharedApplication.statusBarHidden = YES; + } + + // Handle here the case of splitviewcontroller use on iOS 8 and later. + if (self.splitViewController && [self.splitViewController respondsToSelector:@selector(displayMode)]) + { + if (self.hidesBottomBarWhenPushed) + { + // This screen should be displayed without tabbar, but hidesBottomBarWhenPushed flag has no effect in case of splitviewcontroller use. + // Trick: on iOS 8 and later the tabbar is hidden manually + shouldRestoreBottomBar = YES; + self.tabBarController.tabBar.hidden = YES; + } + + // Hide the primary view controller to allow full screen display + savedSplitViewControllerDisplayMode = [self.splitViewController displayMode]; + self.splitViewController.preferredDisplayMode = UISplitViewControllerDisplayModePrimaryHidden; + [self.splitViewController.view layoutIfNeeded]; + } + + [_attachmentsCollection reloadData]; +} + +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + + // Adjust content offset and make visible the attachmnet collections + [self refreshAttachmentCollectionContentOffset]; + _attachmentsCollection.hidden = NO; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + if (tempFile) + { + NSError *err; + [[NSFileManager defaultManager] removeItemAtPath:tempFile error:&err]; + tempFile = nil; + } + + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + currentAlert = nil; + } + + // Stop playing any video + for (MXKMediaCollectionViewCell *cell in self.attachmentsCollection.visibleCells) + { + [cell.moviePlayer.player pause]; + cell.moviePlayer.player = nil; + } + + // Restore audio category + if (savedAVAudioSessionCategory) + { + [[AVAudioSession sharedInstance] setCategory:savedAVAudioSessionCategory error:nil]; + savedAVAudioSessionCategory = nil; + } + + [navigationBarDisplayTimer invalidate]; + navigationBarDisplayTimer = nil; + + // Restore status bar + // TODO: remove this [UIApplication statusBarHidden] use (deprecated since iOS 9). + // Note: setting statusBarHidden does nothing if your application is using the default UIViewController-based status bar system. + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication) + { + sharedApplication.statusBarHidden = NO; + } + + if (shouldRestoreBottomBar) + { + self.tabBarController.tabBar.hidden = NO; + } + + if (self.splitViewController && [self.splitViewController respondsToSelector:@selector(displayMode)]) + { + self.splitViewController.preferredDisplayMode = savedSplitViewControllerDisplayMode; + [self.splitViewController.view layoutIfNeeded]; + } + + [super viewWillDisappear:animated]; +} + +- (void)dealloc +{ + [self destroy]; +} + +- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id )coordinator +{ + [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(coordinator.transitionDuration * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + + // Cell width will be updated, force collection layout refresh to take into account the changes + [self->_attachmentsCollection.collectionViewLayout invalidateLayout]; + + // Refresh the current attachment display + [self refreshAttachmentCollectionContentOffset]; + + }); +} + +#pragma mark - Override MXKViewController + +- (void)destroy +{ + if (documentInteractionController) + { + [documentInteractionController dismissPreviewAnimated:NO]; + [documentInteractionController dismissMenuAnimated:NO]; + documentInteractionController = nil; + } + + if (currentSharedAttachment) + { + [currentSharedAttachment onShareEnded]; + currentSharedAttachment = nil; + } + + if (self.sourceViewController) + { + self.sourceViewController.navigationController.delegate = nil; + self.sourceViewController = nil; + } + + [super destroy]; +} + +#pragma mark - Public API + +- (void)displayAttachments:(NSArray*)attachmentArray focusOn:(NSString*)eventId +{ + NSString *currentAttachmentEventId = eventId; + NSString *currentAttachmentOriginalFileName = nil; + + if (currentAttachmentEventId.length == 0 && attachments) + { + if (isBackPaginationInProgress && currentVisibleItemIndex == 0) + { + // Here the spinner were displayed, we update the viewer by displaying the first added attachment + // (the one just added before the first item of the current attachments array). + if (attachments.count) + { + // Retrieve the event id of the first item in the current attachments array + MXKAttachment *attachment = attachments[0]; + NSString *firstAttachmentEventId = attachment.eventId; + NSString *firstAttachmentOriginalFileName = nil; + + // The original file name is used when the attachment is a local echo. + // Indeed its event id may be replaced by the actual one in the new attachments array. + if ([firstAttachmentEventId hasPrefix:kMXEventLocalEventIdPrefix]) + { + firstAttachmentOriginalFileName = attachment.originalFileName; + } + + // Look for the attachment added before this attachment in new array. + for (attachment in attachmentArray) + { + if (firstAttachmentOriginalFileName && [attachment.originalFileName isEqualToString:firstAttachmentOriginalFileName]) + { + break; + } + else if ([attachment.eventId isEqualToString:firstAttachmentEventId]) + { + break; + } + currentAttachmentEventId = attachment.eventId; + } + } + } + else if (currentVisibleItemIndex != NSNotFound) + { + // Compute the attachment index + NSUInteger currentAttachmentIndex = (isBackPaginationInProgress ? currentVisibleItemIndex - 1 : currentVisibleItemIndex); + + if (currentAttachmentIndex < attachments.count) + { + MXKAttachment *attachment = attachments[currentAttachmentIndex]; + currentAttachmentEventId = attachment.eventId; + + // The original file name is used when the attachment is a local echo. + // Indeed its event id may be replaced by the actual one in the new attachments array. + if ([currentAttachmentEventId hasPrefix:kMXEventLocalEventIdPrefix]) + { + currentAttachmentOriginalFileName = attachment.originalFileName; + } + } + } + } + + // Stop back pagination (Do not call here 'stopBackPaginationActivity' because a full collection reload is planned at the end). + isBackPaginationInProgress = NO; + + // Set/reset the attachments array + attachments = [NSMutableArray arrayWithArray:attachmentArray]; + + // Update the index of the current displayed attachment by looking for the + // current event id (or the current original file name, if any) in the new attachments array. + currentVisibleItemIndex = 0; + if (currentAttachmentEventId) + { + for (NSUInteger index = 0; index < attachments.count; index++) + { + MXKAttachment *attachment = attachments[index]; + + // Check first the original filename if any. + if (currentAttachmentOriginalFileName && [attachment.originalFileName isEqualToString:currentAttachmentOriginalFileName]) + { + currentVisibleItemIndex = index; + break; + } + // Check the event id then + else if ([attachment.eventId isEqualToString:currentAttachmentEventId]) + { + currentVisibleItemIndex = index; + break; + } + } + } + + // Refresh + [_attachmentsCollection reloadData]; + + // Adjust content offset + [self refreshAttachmentCollectionContentOffset]; +} + +- (void)setComplete:(BOOL)complete +{ + _complete = complete; + + if (complete) + { + [self stopBackPaginationActivity]; + } +} + +- (IBAction)onButtonPressed:(id)sender +{ + if (sender == self.backButton) + { + [self withdrawViewControllerAnimated:YES completion:nil]; + } +} + +#pragma mark - Privates + +- (IBAction)hideNavigationBar +{ + self.navigationBar.hidden = YES; + + [navigationBarDisplayTimer invalidate]; + navigationBarDisplayTimer = nil; +} + +- (void)refreshCurrentVisibleItemIndex +{ + // Check whether the collection is actually rendered + if (_attachmentsCollection.contentSize.width) + { + currentVisibleItemIndex = _attachmentsCollection.contentOffset.x / [[UIScreen mainScreen] bounds].size.width; + } + else + { + currentVisibleItemIndex = NSNotFound; + } +} + +- (void)refreshAttachmentCollectionContentOffset +{ + if (currentVisibleItemIndex != NSNotFound && _attachmentsCollection) + { + // Set the content offset to display the current attachment + CGPoint contentOffset = _attachmentsCollection.contentOffset; + contentOffset.x = currentVisibleItemIndex * [[UIScreen mainScreen] bounds].size.width; + _attachmentsCollection.contentOffset = contentOffset; + } +} + +- (void)refreshCurrentVisibleCell +{ + // In case of attached image, load here the high res image. + + [self refreshCurrentVisibleItemIndex]; + + if (currentVisibleItemIndex == NSNotFound) { + // Tell the delegate that no attachment is displayed for the moment + if ([self.delegate respondsToSelector:@selector(displayedNewAttachmentWithEventId:)]) + { + [self.delegate displayedNewAttachmentWithEventId:nil]; + } + } + else + { + NSInteger item = currentVisibleItemIndex; + if (isBackPaginationInProgress) + { + if (item == 0) + { + // Tell the delegate that no attachment is displayed for the moment + if ([self.delegate respondsToSelector:@selector(displayedNewAttachmentWithEventId:)]) + { + [self.delegate displayedNewAttachmentWithEventId:nil]; + } + + return; + } + + item --; + } + + if (item < attachments.count) + { + MXKAttachment *attachment = attachments[item]; + NSString *mimeType = attachment.contentInfo[@"mimetype"]; + + // Tell the delegate which attachment has been shown using its eventId + if ([self.delegate respondsToSelector:@selector(displayedNewAttachmentWithEventId:)]) + { + [self.delegate displayedNewAttachmentWithEventId:attachment.eventId]; + } + + // Check attachment type + if (attachment.type == MXKAttachmentTypeImage && attachment.contentURL && ![mimeType isEqualToString:@"image/gif"]) + { + // Retrieve the related cell + UICollectionViewCell *cell = [_attachmentsCollection cellForItemAtIndexPath:[NSIndexPath indexPathForItem:currentVisibleItemIndex inSection:0]]; + if ([cell isKindOfClass:[MXKMediaCollectionViewCell class]]) + { + MXKMediaCollectionViewCell *mediaCollectionViewCell = (MXKMediaCollectionViewCell*)cell; + + // Load high res image + mediaCollectionViewCell.mxkImageView.stretchable = YES; + mediaCollectionViewCell.mxkImageView.enableInMemoryCache = NO; + + [mediaCollectionViewCell.mxkImageView setAttachment:attachment]; + } + } + } + } +} + +- (void)stopBackPaginationActivity +{ + if (isBackPaginationInProgress) + { + isBackPaginationInProgress = NO; + + [self.attachmentsCollection deleteItemsAtIndexPaths:@[[NSIndexPath indexPathForItem:0 inSection:0]]]; + } +} + +- (void)prepareVideoForItem:(NSInteger)item success:(void(^)(void))success failure:(void(^)(NSError *))failure +{ + MXKAttachment *attachment = attachments[item]; + if (attachment.isEncrypted) + { + [attachment decryptToTempFile:^(NSString *file) { + if (self->tempFile) + { + [[NSFileManager defaultManager] removeItemAtPath:self->tempFile error:nil]; + } + self->tempFile = file; + self->videoFile = file; + success(); + } failure:^(NSError *error) { + if (failure) failure(error); + }]; + } + else + { + if ([[NSFileManager defaultManager] fileExistsAtPath:attachment.cacheFilePath]) + { + videoFile = attachment.cacheFilePath; + success(); + } + else + { + [attachment prepare:^{ + self->videoFile = attachment.cacheFilePath; + success(); + } failure:^(NSError *error) { + if (failure) failure(error); + }]; + } + } +} + +#pragma mark - UICollectionViewDataSource + +- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section +{ + if (isBackPaginationInProgress) + { + return (attachments.count + 1); + } + + return attachments.count; +} + +- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath +{ + MXKMediaCollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:[MXKMediaCollectionViewCell defaultReuseIdentifier] + forIndexPath:indexPath]; + + NSInteger item = indexPath.item; + + if (isBackPaginationInProgress) + { + if (item == 0) + { + cell.mxkImageView.hidden = YES; + cell.customView.hidden = NO; + + // Add back pagination spinner + UIActivityIndicatorView* spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge]; + spinner.autoresizingMask = (UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin); + spinner.hidesWhenStopped = NO; + spinner.backgroundColor = [UIColor clearColor]; + [spinner startAnimating]; + + spinner.center = cell.customView.center; + [cell.customView addSubview:spinner]; + + return cell; + } + + item --; + } + + if (item < attachments.count) + { + MXKAttachment *attachment = attachments[item]; + NSString *mimeType = attachment.contentInfo[@"mimetype"]; + + // Use the cached thumbnail (if any) as preview + UIImage* preview = [attachment getCachedThumbnail]; + + // Check attachment type + if ((attachment.type == MXKAttachmentTypeImage || attachment.type == MXKAttachmentTypeSticker) && attachment.contentURL) + { + if ([mimeType isEqualToString:@"image/gif"]) + { + cell.mxkImageView.hidden = YES; + // Set the preview as the default image even if the image view is hidden. It will be used during zoom out animation. + cell.mxkImageView.image = preview; + + cell.customView.hidden = NO; + + // Animated gif is displayed in webview + CGFloat minSize = (cell.frame.size.width < cell.frame.size.height) ? cell.frame.size.width : cell.frame.size.height; + CGFloat width, height; + if (attachment.contentInfo[@"w"] && attachment.contentInfo[@"h"]) + { + width = [attachment.contentInfo[@"w"] integerValue]; + height = [attachment.contentInfo[@"h"] integerValue]; + if (width > minSize || height > minSize) + { + if (width > height) + { + height = (height * minSize) / width; + height = floorf(height / 2) * 2; + width = minSize; + } + else + { + width = (width * minSize) / height; + width = floorf(width / 2) * 2; + height = minSize; + } + } + else + { + width = minSize; + height = minSize; + } + } + else + { + width = minSize; + height = minSize; + } + + WKWebView *animatedGifViewer = [[WKWebView alloc] initWithFrame:CGRectMake(0, 0, width, height)]; + animatedGifViewer.center = cell.customView.center; + animatedGifViewer.opaque = NO; + animatedGifViewer.backgroundColor = cell.customView.backgroundColor; + animatedGifViewer.contentMode = UIViewContentModeScaleAspectFit; + animatedGifViewer.autoresizingMask = (UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin); + animatedGifViewer.userInteractionEnabled = NO; + [cell.customView addSubview:animatedGifViewer]; + + UIImageView *previewImage = [[UIImageView alloc] initWithFrame:animatedGifViewer.frame]; + previewImage.contentMode = animatedGifViewer.contentMode; + previewImage.autoresizingMask = animatedGifViewer.autoresizingMask; + previewImage.image = preview; + previewImage.center = cell.customView.center; + [cell.customView addSubview:previewImage]; + + MXKPieChartView *pieChartView = [[MXKPieChartView alloc] initWithFrame:CGRectMake(0, 0, 40, 40)]; + pieChartView.progress = 0; + pieChartView.progressColor = [UIColor colorWithRed:1 green:1 blue:1 alpha:0.25]; + pieChartView.unprogressColor = [UIColor clearColor]; + pieChartView.autoresizingMask = animatedGifViewer.autoresizingMask; + pieChartView.center = cell.customView.center; + [cell.customView addSubview:pieChartView]; + + // Add download progress observer + NSString *downloadId = attachment.downloadId; + cell.notificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXMediaLoaderStateDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXMediaLoader *loader = (MXMediaLoader*)notif.object; + if ([loader.downloadId isEqualToString:downloadId]) + { + // update the image + switch (loader.state) { + case MXMediaLoaderStateDownloadInProgress: + { + NSNumber* progressNumber = [loader.statisticsDict valueForKey:kMXMediaLoaderProgressValueKey]; + if (progressNumber) + { + pieChartView.progress = progressNumber.floatValue; + } + break; + } + default: + break; + } + } + + }]; + + void (^onDownloaded)(NSData *) = ^(NSData *data){ + if (cell.notificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:cell.notificationObserver]; + cell.notificationObserver = nil; + } + + if (animatedGifViewer.superview) + { + [animatedGifViewer loadData:data MIMEType:@"image/gif" characterEncodingName:@"UTF-8" baseURL:[NSURL URLWithString:@"http://"]]; + + [pieChartView removeFromSuperview]; + [previewImage removeFromSuperview]; + } + }; + + void (^onFailure)(NSError *) = ^(NSError *error){ + if (cell.notificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:cell.notificationObserver]; + cell.notificationObserver = nil; + } + + MXLogDebug(@"[MXKAttachmentsVC] gif download failed"); + // Notify MatrixKit user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + }; + + + [attachment getAttachmentData:^(NSData *data) { + onDownloaded(data); + } failure:^(NSError *error) { + onFailure(error); + }]; + } + else if (indexPath.item == currentVisibleItemIndex) + { + // Load high res image + cell.mxkImageView.stretchable = YES; + [cell.mxkImageView setAttachment:attachment]; + } + else + { + // Use the thumbnail here - Full res images should only be downloaded explicitly when requested (see [self refreshCurrentVisibleItemIndex]) + cell.mxkImageView.stretchable = YES; + [cell.mxkImageView setAttachmentThumb:attachment]; + } + } + else if (attachment.type == MXKAttachmentTypeVideo && attachment.contentURL) + { + cell.mxkImageView.mediaFolder = attachment.eventRoomId; + cell.mxkImageView.stretchable = NO; + cell.mxkImageView.enableInMemoryCache = YES; + // Display video thumbnail, the video is played only when user selects this cell + [cell.mxkImageView setAttachmentThumb:attachment]; + + cell.centerIcon.image = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"play"]; + cell.centerIcon.hidden = NO; + } + + // Add gesture recognizers on collection cell to handle tap and long press on collection cell. + // Note: tap gesture recognizer is required here because mxkImageView enables user interaction to allow image stretching. + // [collectionView:didSelectItemAtIndexPath] is not triggered when mxkImageView is displayed. + UITapGestureRecognizer *cellTapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onCollectionViewCellTap:)]; + [cellTapGesture setNumberOfTouchesRequired:1]; + [cellTapGesture setNumberOfTapsRequired:1]; + cell.tag = item; + [cell addGestureRecognizer:cellTapGesture]; + + UILongPressGestureRecognizer *cellLongPressGesture = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(onCollectionViewCellLongPress:)]; + [cell addGestureRecognizer:cellLongPressGesture]; + } + + return cell; +} + +#pragma mark - UICollectionViewDelegate + +- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath +{ + NSInteger item = indexPath.item; + + BOOL navigationBarDisplayHandled = NO; + + if (isBackPaginationInProgress) + { + if (item == 0) + { + return; + } + + item --; + } + + // Check whether the selected attachment is a video + if (item < attachments.count) + { + MXKAttachment *attachment = attachments[item]; + + if (attachment.type == MXKAttachmentTypeVideo && attachment.contentURL) + { + MXKMediaCollectionViewCell *selectedCell = (MXKMediaCollectionViewCell*)[collectionView cellForItemAtIndexPath:indexPath]; + + // Add movie player if none + if (selectedCell.moviePlayer == nil) + { + [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback error:nil]; + + selectedCell.moviePlayer = [[AVPlayerViewController alloc] init]; + if (selectedCell.moviePlayer != nil) + { + // Switch in custom view + selectedCell.mxkImageView.hidden = YES; + selectedCell.customView.hidden = NO; + + // Report the video preview + UIImageView *previewImage = [[UIImageView alloc] initWithFrame:selectedCell.customView.frame]; + previewImage.contentMode = UIViewContentModeScaleAspectFit; + previewImage.autoresizingMask = (UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin); + previewImage.image = selectedCell.mxkImageView.image; + previewImage.center = selectedCell.customView.center; + [selectedCell.customView addSubview:previewImage]; + + selectedCell.moviePlayer.videoGravity = AVLayerVideoGravityResizeAspect; + selectedCell.moviePlayer.view.frame = selectedCell.customView.frame; + selectedCell.moviePlayer.view.center = selectedCell.customView.center; + selectedCell.moviePlayer.view.hidden = YES; + [selectedCell.customView addSubview:selectedCell.moviePlayer.view]; + + // Force the video to stay in fullscreen + NSLayoutConstraint* topConstraint = [NSLayoutConstraint constraintWithItem:selectedCell.moviePlayer.view + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:selectedCell.customView + attribute:NSLayoutAttributeTop + multiplier:1.0f + constant:0.0f]; + + NSLayoutConstraint *leadingConstraint = [NSLayoutConstraint constraintWithItem:selectedCell.moviePlayer.view + attribute:NSLayoutAttributeLeading + relatedBy:0 + toItem:selectedCell.customView + attribute:NSLayoutAttributeLeading + multiplier:1.0 + constant:0]; + + NSLayoutConstraint *bottomConstraint = [NSLayoutConstraint constraintWithItem:selectedCell.moviePlayer.view + attribute:NSLayoutAttributeBottom + relatedBy:0 + toItem:selectedCell.customView + attribute:NSLayoutAttributeBottom + multiplier:1 + constant:0]; + + NSLayoutConstraint *trailingConstraint = [NSLayoutConstraint constraintWithItem:selectedCell.moviePlayer.view + attribute:NSLayoutAttributeTrailing + relatedBy:0 + toItem:selectedCell.customView + attribute:NSLayoutAttributeTrailing + multiplier:1.0 + constant:0]; + + selectedCell.moviePlayer.view.translatesAutoresizingMaskIntoConstraints = NO; + + [NSLayoutConstraint activateConstraints:@[topConstraint, leadingConstraint, bottomConstraint, trailingConstraint]]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(moviePlayerPlaybackDidFinishWithErrorNotification:) + name:AVPlayerItemFailedToPlayToEndTimeNotification + object:nil]; + } + } + + if (selectedCell.moviePlayer) + { + if (selectedCell.moviePlayer.player.status == AVPlayerStatusReadyToPlay) + { + // Show or hide the navigation bar + + // The video controls bar display is automatically managed by MPMoviePlayerController. + // We have no control on it and no notifications about its displays changes. + // The following code synchronizes the display of the navigation bar with the + // MPMoviePlayerController controls bar. + + // Check the MPMoviePlayerController controls bar display status by an hacky way + BOOL controlsVisible = NO; + for(id views in [[selectedCell.moviePlayer view] subviews]) + { + for(id subViews in [views subviews]) + { + for (id controlView in [subViews subviews]) + { + if ([controlView isKindOfClass:[UIView class]] && ((UIView*)controlView).tag == 1004) + { + UIView *subView = (UIView*)controlView; + + controlsVisible = (subView.alpha <= 0.0) ? NO : YES; + } + } + } + } + + // Apply the same display to the navigation bar + self.navigationBar.hidden = !controlsVisible; + + navigationBarDisplayHandled = YES; + if (!self.navigationBar.hidden) + { + // Automaticaly hide the nav bar after 5s. This is the same timer value that + // MPMoviePlayerController uses for its controls bar + [navigationBarDisplayTimer invalidate]; + navigationBarDisplayTimer = [NSTimer scheduledTimerWithTimeInterval:5 target:self selector:@selector(hideNavigationBar) userInfo:self repeats:NO]; + } + } + else + { + MXKPieChartView *pieChartView = [[MXKPieChartView alloc] initWithFrame:CGRectMake(0, 0, 40, 40)]; + pieChartView.progress = 0; + pieChartView.progressColor = [UIColor colorWithRed:1 green:1 blue:1 alpha:0.25]; + pieChartView.unprogressColor = [UIColor clearColor]; + pieChartView.autoresizingMask = (UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin); + pieChartView.center = selectedCell.customView.center; + [selectedCell.customView addSubview:pieChartView]; + + // Add download progress observer + NSString *downloadId = attachment.downloadId; + selectedCell.notificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXMediaLoaderStateDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXMediaLoader *loader = (MXMediaLoader*)notif.object; + if ([loader.downloadId isEqualToString:downloadId]) + { + // update progress + switch (loader.state) { + case MXMediaLoaderStateDownloadInProgress: + { + NSNumber* progressNumber = [loader.statisticsDict valueForKey:kMXMediaLoaderProgressValueKey]; + if (progressNumber) + { + pieChartView.progress = progressNumber.floatValue; + } + break; + } + default: + break; + } + } + + }]; + + [self prepareVideoForItem:item success:^{ + + if (selectedCell.notificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:selectedCell.notificationObserver]; + selectedCell.notificationObserver = nil; + } + + if (selectedCell.moviePlayer.view.superview) + { + selectedCell.moviePlayer.view.hidden = NO; + selectedCell.centerIcon.hidden = YES; + selectedCell.moviePlayer.player = [AVPlayer playerWithURL:[NSURL fileURLWithPath:self->videoFile]]; + [selectedCell.moviePlayer.player play]; + + [pieChartView removeFromSuperview]; + + [self hideNavigationBar]; + } + + } failure:^(NSError *error) { + + if (selectedCell.notificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:selectedCell.notificationObserver]; + selectedCell.notificationObserver = nil; + } + + MXLogDebug(@"[MXKAttachmentsVC] video download failed"); + + [pieChartView removeFromSuperview]; + + // Display the navigation bar so that the user can leave this screen + self.navigationBar.hidden = NO; + + // Notify MatrixKit user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + + }]; + + // Do not animate the navigation bar on video playback preparing + return; + } + } + } + } + + // Animate navigation bar if it is has not been handled + if (!navigationBarDisplayHandled) + { + if (self.navigationBar.hidden) + { + self.navigationBar.hidden = NO; + [navigationBarDisplayTimer invalidate]; + navigationBarDisplayTimer = [NSTimer scheduledTimerWithTimeInterval:3 target:self selector:@selector(hideNavigationBar) userInfo:self repeats:NO]; + } + else + { + [self hideNavigationBar]; + } + } +} + +- (void)collectionView:(UICollectionView *)collectionView didEndDisplayingCell:(UICollectionViewCell *)cell forItemAtIndexPath:(NSIndexPath *)indexPath +{ + // Here the cell is not displayed anymore, but it may be displayed again if the user swipes on it. + if ([cell isKindOfClass:[MXKMediaCollectionViewCell class]]) + { + MXKMediaCollectionViewCell *mediaCollectionViewCell = (MXKMediaCollectionViewCell*)cell; + + // Check whether a video was playing in this cell. + if (mediaCollectionViewCell.moviePlayer) + { + // This cell concerns an attached video. + // We stop the player, and restore the default display based on the video thumbnail + [mediaCollectionViewCell.moviePlayer.player pause]; + mediaCollectionViewCell.moviePlayer.player = nil; + mediaCollectionViewCell.moviePlayer = nil; + + mediaCollectionViewCell.mxkImageView.hidden = NO; + mediaCollectionViewCell.centerIcon.hidden = NO; + mediaCollectionViewCell.customView.hidden = YES; + + // Remove potential media download observer + if (mediaCollectionViewCell.notificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:mediaCollectionViewCell.notificationObserver]; + mediaCollectionViewCell.notificationObserver = nil; + } + } + } +} + +- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset +{ + // Detect horizontal bounce at the beginning of the collection to trigger pagination + if (scrollView == self.attachmentsCollection && !isBackPaginationInProgress && !self.complete && self.delegate) + { + if (scrollView.contentOffset.x < -30) + { + isBackPaginationInProgress = YES; + [self.attachmentsCollection insertItemsAtIndexPaths:@[[NSIndexPath indexPathForItem:0 inSection:0]]]; + } + } +} + +- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView +{ + if (scrollView == self.attachmentsCollection) + { + if (isBackPaginationInProgress) + { + MXKAttachment *attachment = self.attachments.firstObject; + self.complete = ![self.delegate attachmentsViewController:self paginateAttachmentBefore:attachment.eventId]; + } + else + { + [self refreshCurrentVisibleCell]; + } + } +} + +#pragma mark - UICollectionViewDelegateFlowLayout + +- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath +{ + return [[UIScreen mainScreen] bounds].size; +} + +#pragma mark - Movie Player + +- (void)moviePlayerPlaybackDidFinishWithErrorNotification:(NSNotification *)notification +{ + NSDictionary *notificationUserInfo = [notification userInfo]; + + NSError *mediaPlayerError = [notificationUserInfo objectForKey:AVPlayerItemFailedToPlayToEndTimeErrorKey]; + if (mediaPlayerError) + { + MXLogDebug(@"[MXKAttachmentsVC] Playback failed with error description: %@", [mediaPlayerError localizedDescription]); + + // Display the navigation bar so that the user can leave this screen + self.navigationBar.hidden = NO; + + // Notify MatrixKit user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:mediaPlayerError]; + } +} + +#pragma mark - Gesture recognizer + +- (void)onCollectionViewCellTap:(UIGestureRecognizer*)gestureRecognizer +{ + MXKMediaCollectionViewCell *selectedCell; + + UIView *view = gestureRecognizer.view; + if ([view isKindOfClass:[MXKMediaCollectionViewCell class]]) + { + selectedCell = (MXKMediaCollectionViewCell*)view; + } + + // Notify the collection view delegate a cell has been selected. + if (selectedCell && selectedCell.tag < attachments.count) + { + [self collectionView:self.attachmentsCollection didSelectItemAtIndexPath:[NSIndexPath indexPathForItem:(isBackPaginationInProgress ? selectedCell.tag + 1: selectedCell.tag) inSection:0]]; + } +} + +- (void)onCollectionViewCellLongPress:(UIGestureRecognizer*)gestureRecognizer +{ + MXKMediaCollectionViewCell *selectedCell; + + if (gestureRecognizer.state == UIGestureRecognizerStateBegan) + { + UIView *view = gestureRecognizer.view; + if ([view isKindOfClass:[MXKMediaCollectionViewCell class]]) + { + selectedCell = (MXKMediaCollectionViewCell*)view; + } + } + + // Notify the collection view delegate a cell has been selected. + if (selectedCell && selectedCell.tag < attachments.count) + { + MXKAttachment *attachment = attachments[selectedCell.tag]; + + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + } + + __weak __typeof(self) weakSelf = self; + + currentAlert = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + + if ([MXKAppSettings standardAppSettings].messageDetailsAllowSaving) + { + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n save] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + [self startActivityIndicator]; + + [attachment save:^{ + + typeof(self) self = weakSelf; + [self stopActivityIndicator]; + + } failure:^(NSError *error) { + + typeof(self) self = weakSelf; + [self stopActivityIndicator]; + + // Notify MatrixKit user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + + }]; + + }]]; + } + + if ([MXKAppSettings standardAppSettings].messageDetailsAllowCopyingMedia) + { + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n copyButtonName] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + [self startActivityIndicator]; + + [attachment copy:^{ + + typeof(self) self = weakSelf; + [self stopActivityIndicator]; + + } failure:^(NSError *error) { + + typeof(self) self = weakSelf; + [self stopActivityIndicator]; + + // Notify MatrixKit user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + + }]; + + }]]; + } + + if ([MXKAppSettings standardAppSettings].messageDetailsAllowSharing) + { + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n share] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + MXWeakify(self); + + self->currentAlert = nil; + + [self startActivityIndicator]; + + [attachment prepareShare:^(NSURL *fileURL) { + + MXStrongifyAndReturnIfNil(self); + + [self stopActivityIndicator]; + + self->documentInteractionController = [UIDocumentInteractionController interactionControllerWithURL:fileURL]; + [self->documentInteractionController setDelegate:self]; + self->currentSharedAttachment = attachment; + + if (![self->documentInteractionController presentOptionsMenuFromRect:self.view.frame inView:self.view animated:YES]) + { + self->documentInteractionController = nil; + [attachment onShareEnded]; + self->currentSharedAttachment = nil; + } + + } failure:^(NSError *error) { + + MXStrongifyAndReturnIfNil(self); + + [self stopActivityIndicator]; + + // Notify MatrixKit user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + + }]; + + }]]; + } + + if ([MXMediaManager existingDownloaderWithIdentifier:attachment.downloadId]) + { + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancelDownload] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Get again the loader + MXMediaLoader *loader = [MXMediaManager existingDownloaderWithIdentifier:attachment.downloadId]; + if (loader) + { + [loader cancel]; + } + + }]]; + } + + if (currentAlert.actions.count) + { + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleCancel + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + }]]; + + [currentAlert popoverPresentationController].sourceView = _attachmentsCollection; + [currentAlert popoverPresentationController].sourceRect = _attachmentsCollection.bounds; + [self presentViewController:currentAlert animated:YES completion:nil]; + } + else + { + currentAlert = nil; + } + } +} + +#pragma mark - UIDocumentInteractionControllerDelegate + +- (UIViewController *)documentInteractionControllerViewControllerForPreview: (UIDocumentInteractionController *) controller +{ + return self; +} + +// Preview presented/dismissed on document. Use to set up any HI underneath. +- (void)documentInteractionControllerWillBeginPreview:(UIDocumentInteractionController *)controller +{ + documentInteractionController = controller; +} + +- (void)documentInteractionControllerDidEndPreview:(UIDocumentInteractionController *)controller +{ + documentInteractionController = nil; + if (currentSharedAttachment) + { + [currentSharedAttachment onShareEnded]; + currentSharedAttachment = nil; + } +} + +- (void)documentInteractionControllerDidDismissOptionsMenu:(UIDocumentInteractionController *)controller +{ + documentInteractionController = nil; + if (currentSharedAttachment) + { + [currentSharedAttachment onShareEnded]; + currentSharedAttachment = nil; + } +} + +- (void)documentInteractionControllerDidDismissOpenInMenu:(UIDocumentInteractionController *)controller +{ + documentInteractionController = nil; + if (currentSharedAttachment) + { + [currentSharedAttachment onShareEnded]; + currentSharedAttachment = nil; + } +} + +- (UIImageView *)finalImageView +{ + MXKMediaCollectionViewCell *cell = (MXKMediaCollectionViewCell *)[self.attachmentsCollection.visibleCells firstObject]; + return cell.mxkImageView.imageView; +} + +#pragma mark - UIViewControllerTransitioningDelegate + +- (id )animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source +{ + if (self.customAnimationsEnabled) + { + return [[MXKAttachmentAnimator alloc] initWithAnimationType:PhotoBrowserZoomInAnimation sourceViewController:self.sourceViewController]; + } + return nil; +} + +- (id )animationControllerForDismissedController:(UIViewController *)dismissed +{ + [self hideNavigationBar]; + + if (self.customAnimationsEnabled) + { + return [[MXKAttachmentAnimator alloc] initWithAnimationType:PhotoBrowserZoomOutAnimation sourceViewController:self.sourceViewController]; + } + return nil; +} + +- (id)interactionControllerForDismissal:(id)animator +{ + //if there is an interaction, use the custom interaction controller to handle it + if (self.interactionController.interactionInProgress) + { + return self.interactionController; + } + return nil; +} + +#pragma mark - UINavigationControllerDelegate + +- (id )navigationController:(UINavigationController *)navigationController interactionControllerForAnimationController:(id ) animationController { + if (self.customAnimationsEnabled && self.interactionController.interactionInProgress) + { + return self.interactionController; + } + return nil; +} + +- (id )navigationController:(UINavigationController *)navigationController animationControllerForOperation:(UINavigationControllerOperation)operation + fromViewController:(UIViewController *)fromVC + toViewController:(UIViewController *)toVC +{ + + if (self.customAnimationsEnabled) + { + if (operation == UINavigationControllerOperationPush) + { + return [[MXKAttachmentAnimator alloc] initWithAnimationType:PhotoBrowserZoomInAnimation sourceViewController:self.sourceViewController]; + } + if (operation == UINavigationControllerOperationPop) + { + return [[MXKAttachmentAnimator alloc] initWithAnimationType:PhotoBrowserZoomOutAnimation sourceViewController:self.sourceViewController]; + } + return nil; + } + + return nil; +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.xib new file mode 100644 index 000000000..28409d2de --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.xib @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.h new file mode 100644 index 000000000..ffb64afbe --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.h @@ -0,0 +1,311 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKViewController.h" + +#import "MXKAuthInputsView.h" +#import "MXKAuthenticationFallbackWebView.h" + +@class MXKAuthenticationViewController; + +/** + `MXKAuthenticationViewController` delegate. + */ +@protocol MXKAuthenticationViewControllerDelegate + +/** + Tells the delegate the authentication process succeeded to add a new account. + + @param authenticationViewController the `MXKAuthenticationViewController` instance. + @param userId the user id of the new added account. + */ +- (void)authenticationViewController:(MXKAuthenticationViewController *)authenticationViewController didLogWithUserId:(NSString*)userId; + +@end + +/** + This view controller should be used to manage registration or login flows with matrix homeserver. + + Only the flow based on password is presently supported. Other flows should be added later. + + You may add a delegate to be notified when a new account has been added successfully. + */ +@interface MXKAuthenticationViewController : MXKViewController +{ +@protected + + /** + Reference to any opened alert view. + */ + UIAlertController *alert; + + /** + Tell whether the password has been reseted with success. + Used to return on login screen on submit button pressed. + */ + BOOL isPasswordReseted; +} + +@property (weak, nonatomic) IBOutlet UIImageView *welcomeImageView; + +@property (strong, nonatomic) IBOutlet UIScrollView *authenticationScrollView; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *authScrollViewBottomConstraint; + +@property (weak, nonatomic) IBOutlet UIView *contentView; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *contentViewHeightConstraint; + +@property (weak, nonatomic) IBOutlet UILabel *subTitleLabel; + +@property (weak, nonatomic) IBOutlet UIView *authInputsContainerView; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *authInputContainerViewHeightConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *authInputContainerViewMinHeightConstraint; + +@property (weak, nonatomic) IBOutlet UILabel *homeServerLabel; +@property (weak, nonatomic) IBOutlet UITextField *homeServerTextField; +@property (weak, nonatomic) IBOutlet UILabel *homeServerInfoLabel; +@property (weak, nonatomic) IBOutlet UIView *identityServerContainer; +@property (weak, nonatomic) IBOutlet UILabel *identityServerLabel; +@property (weak, nonatomic) IBOutlet UITextField *identityServerTextField; +@property (weak, nonatomic) IBOutlet UILabel *identityServerInfoLabel; + +@property (weak, nonatomic) IBOutlet UIButton *submitButton; +@property (weak, nonatomic) IBOutlet UIButton *authSwitchButton; + +@property (strong, nonatomic) IBOutlet UIActivityIndicatorView *authenticationActivityIndicator; +@property (weak, nonatomic) IBOutlet UIView *authenticationActivityIndicatorContainerView; +@property (weak, nonatomic) IBOutlet UILabel *noFlowLabel; +@property (weak, nonatomic) IBOutlet UIButton *retryButton; + +@property (weak, nonatomic) IBOutlet UIView *authFallbackContentView; +// WKWebView is not available to be created from xib because of NSCoding support below iOS 11. So we're using a container view. +// See this: https://stackoverflow.com/questions/46221577/xcode-9-gm-wkwebview-nscoding-support-was-broken-in-previous-versions +@property (weak, nonatomic) IBOutlet UIView *authFallbackWebViewContainer; +@property (strong, nonatomic) MXKAuthenticationFallbackWebView *authFallbackWebView; +@property (weak, nonatomic) IBOutlet UIButton *cancelAuthFallbackButton; + +/** + The current authentication type (MXKAuthenticationTypeLogin by default). + */ +@property (nonatomic) MXKAuthenticationType authType; + +/** + The view in which authentication inputs are displayed (`MXKAuthInputsView-inherited` instance). + */ +@property (nonatomic) MXKAuthInputsView *authInputsView; + +/** + The default homeserver url (nil by default). + */ +@property (nonatomic) NSString *defaultHomeServerUrl; + +/** + The default identity server url (nil by default). + */ +@property (nonatomic) NSString *defaultIdentityServerUrl; + +/** + Force a registration process based on a predefined set of parameters. + Use this property to pursue a registration from the next_link sent in an email validation email. + */ +@property (nonatomic) NSDictionary* externalRegistrationParameters; + +/** + Use a login process based on the soft logout credentials. + */ +@property (nonatomic) MXCredentials *softLogoutCredentials; + +/** + Enable/disable overall the user interaction option. + It is used during authentication process to prevent multiple requests. + */ +@property(nonatomic,getter=isUserInteractionEnabled) BOOL userInteractionEnabled; + +/** + The device name used to display it in the user's devices list (nil by default). + If nil, the device display name field is filled with a default string: "Mobile", "Tablet"... + */ +@property (nonatomic) NSString *deviceDisplayName; + +/** + The delegate for the view controller. + */ +@property (nonatomic, weak) id delegate; + +/** + current ongoing MXHTTPOperation. Nil if none. + */ +@property (nonatomic, nullable, readonly) MXHTTPOperation *currentHttpOperation; + +/** + Returns the `UINib` object initialized for a `MXKAuthenticationViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `authenticationViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKAuthenticationViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + + @return An initialized `MXKAuthenticationViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)authenticationViewController; + +/** + Register the MXKAuthInputsView class that will be used to display inputs for an authentication type. + + By default the 'MXKAuthInputsPasswordBasedView' class is registered for 'MXKAuthenticationTypeLogin' authentication. + No class is registered for 'MXKAuthenticationTypeRegister' type. + No class is registered for 'MXKAuthenticationTypeForgotPassword' type. + + @param authInputsViewClass a MXKAuthInputsView-inherited class. + @param authType the concerned authentication type + */ +- (void)registerAuthInputsViewClass:(Class)authInputsViewClass forAuthType:(MXKAuthenticationType)authType; + +/** + Refresh login/register mechanism supported by the server and the application. + */ +- (void)refreshAuthenticationSession; + +/** + Handle supported flows and associated information returned by the homeserver. + */ +- (void)handleAuthenticationSession:(MXAuthenticationSession *)authSession; + +/** + Customize the MXHTTPClientOnUnrecognizedCertificate block that will be used to handle unrecognized certificate observed during authentication challenge from a server. + By default we prompt the user by displaying a fingerprint (SHA256) of the certificate. The user is then able to trust or not the certificate. + + @param onUnrecognizedCertificateBlock the block that will be used to handle unrecognized certificate + */ +- (void)setOnUnrecognizedCertificateBlock:(MXHTTPClientOnUnrecognizedCertificate)onUnrecognizedCertificateBlock; + +/** + Check whether the current username is already in use. + + @param callback A block object called when the operation is completed. + */ +- (void)isUserNameInUse:(void (^)(BOOL isUserNameInUse))callback; + +/** + Make a ping to the registration endpoint to detect a possible registration problem earlier. + + @param callback A block object called when the operation is completed. + It provides a MXError to check to verify if the user can be registered. + */ +- (void)testUserRegistration:(void (^)(MXError *mxError))callback; + +/** + Action registered on the following events: + - 'UIControlEventTouchUpInside' for each UIButton instance. + - 'UIControlEventValueChanged' for each UISwitch instance. + */ +- (IBAction)onButtonPressed:(id)sender; + +/** + Set the homeserver url and force a new authentication session. + The default homeserver url is used when the provided url is nil. + + @param homeServerUrl the homeserver url to use + */ +- (void)setHomeServerTextFieldText:(NSString *)homeServerUrl; + +/** + Set the identity server url. + The default identity server url is used when the provided url is nil. + + @param identityServerUrl the identity server url to use + */ +- (void)setIdentityServerTextFieldText:(NSString *)identityServerUrl; + +/** + Fetch the identity server from the wellknown API of the selected homeserver. + and check if the HS requires an identity server. + */ +- (void)checkIdentityServer; + +/** + Force dismiss keyboard + */ +- (void)dismissKeyboard; + +/** + Cancel the current operation, and return to the initial step + */ +- (void)cancel; + +/** + Handle the error received during an authentication request. + + @param error the received error. + */ +- (void)onFailureDuringAuthRequest:(NSError *)error; + + +/** + Display a kMXErrCodeStringResourceLimitExceeded error received during an authentication + request. + + @param errorDict the error data. + @param onAdminContactTapped a callback indicating if the user wants to contact their admin. + */ +- (void)showResourceLimitExceededError:(NSDictionary *)errorDict onAdminContactTapped:(void (^)(NSURL *adminContact))onAdminContactTapped; + +/** + Handle the successful authentication request. + + @param credentials the user's credentials. + */ +- (void)onSuccessfulLogin:(MXCredentials*)credentials; + +/// Login with custom parameters +/// @param parameters Login parameters +- (void)loginWithParameters:(NSDictionary*)parameters; + +/// Create an account with the given credentials +/// @param credentials Account credentials +- (void)createAccountWithCredentials:(MXCredentials *)credentials; + +#pragma mark - Authentication Fallback + +/** + Display the fallback URL within a webview. + */ +- (void)showAuthenticationFallBackView; + +#pragma mark - Device rehydration + +/** + Call this method at an appropriate time to attempt rehydrating from an existing dehydrated device + @param keyData Secret key data + @param credentials Account credentials + */ + +- (void)attemptDeviceRehydrationWithKeyData:(NSData *)keyData credentials:(MXCredentials *)credentials; + +@end + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.m new file mode 100644 index 000000000..91a1eda04 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.m @@ -0,0 +1,2150 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAuthenticationViewController.h" + +#import "MXKAuthInputsEmailCodeBasedView.h" +#import "MXKAuthInputsPasswordBasedView.h" + +#import "MXKAccountManager.h" + +#import "NSBundle+MatrixKit.h" + +#import +#import "MXKAppSettings.h" + +#import "MXKSwiftHeader.h" + +@interface MXKAuthenticationViewController () +{ + /** + The matrix REST client used to make matrix API requests. + */ + MXRestClient *mxRestClient; + + /** + Current request in progress. + */ + MXHTTPOperation *mxCurrentOperation; + + /** + The MXKAuthInputsView class or a sub-class used when logging in. + */ + Class loginAuthInputsViewClass; + + /** + The MXKAuthInputsView class or a sub-class used when registering. + */ + Class registerAuthInputsViewClass; + + /** + The MXKAuthInputsView class or a sub-class used to handle forgot password case. + */ + Class forgotPasswordAuthInputsViewClass; + + /** + Customized block used to handle unrecognized certificate (nil by default). + */ + MXHTTPClientOnUnrecognizedCertificate onUnrecognizedCertificateCustomBlock; + + /** + The current authentication fallback URL (if any). + */ + NSString *authenticationFallback; + + /** + The cancel button added in navigation bar when fallback page is opened. + */ + UIBarButtonItem *cancelFallbackBarButton; + + /** + The timer used to postpone the registration when the authentication is pending (for example waiting for email validation) + */ + NSTimer* registrationTimer; + + /** + Identity server discovery. + */ + MXAutoDiscovery *autoDiscovery; + + MXHTTPOperation *checkIdentityServerOperation; +} + +/** + The identity service used to make identity server API requests. + */ +@property (nonatomic) MXIdentityService *identityService; + +@end + +@implementation MXKAuthenticationViewController + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKAuthenticationViewController class]) + bundle:[NSBundle bundleForClass:[MXKAuthenticationViewController class]]]; +} + ++ (instancetype)authenticationViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKAuthenticationViewController class]) + bundle:[NSBundle bundleForClass:[MXKAuthenticationViewController class]]]; +} + +#pragma mark - + +- (void)finalizeInit +{ + [super finalizeInit]; + + // Set initial auth type + _authType = MXKAuthenticationTypeLogin; + + _deviceDisplayName = nil; + + // Initialize authInputs view classes + loginAuthInputsViewClass = MXKAuthInputsPasswordBasedView.class; + registerAuthInputsViewClass = nil; // No registration flow is supported yet + forgotPasswordAuthInputsViewClass = nil; +} + +#pragma mark - + +- (void)viewDidLoad +{ + [super viewDidLoad]; + // Do any additional setup after loading the view, typically from a nib. + + // Check whether the view controller has been pushed via storyboard + if (!_authenticationScrollView) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + self.authFallbackWebView = [[MXKAuthenticationFallbackWebView alloc] initWithFrame:self.authFallbackWebViewContainer.bounds]; + [self.authFallbackWebViewContainer addSubview:self.authFallbackWebView]; + [self.authFallbackWebView.leadingAnchor constraintEqualToAnchor:self.authFallbackWebViewContainer.leadingAnchor constant:0].active = YES; + [self.authFallbackWebView.trailingAnchor constraintEqualToAnchor:self.authFallbackWebViewContainer.trailingAnchor constant:0].active = YES; + [self.authFallbackWebView.topAnchor constraintEqualToAnchor:self.authFallbackWebViewContainer.topAnchor constant:0].active = YES; + [self.authFallbackWebView.bottomAnchor constraintEqualToAnchor:self.authFallbackWebViewContainer.bottomAnchor constant:0].active = YES; + + // Load welcome image from MatrixKit asset bundle + self.welcomeImageView.image = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"logoHighRes"]; + + _authenticationScrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth; + + _subTitleLabel.numberOfLines = 0; + + _submitButton.enabled = NO; + _authSwitchButton.enabled = YES; + + _homeServerTextField.text = _defaultHomeServerUrl; + _identityServerTextField.text = _defaultIdentityServerUrl; + + // Hide the identity server by default + [self setIdentityServerHidden:YES]; + + // Create here REST client (if homeserver is defined) + [self updateRESTClient]; + + // Localize labels + _homeServerLabel.text = [MatrixKitL10n loginHomeServerTitle]; + _homeServerTextField.placeholder = [MatrixKitL10n loginServerUrlPlaceholder]; + _homeServerInfoLabel.text = [MatrixKitL10n loginHomeServerInfo]; + _identityServerLabel.text = [MatrixKitL10n loginIdentityServerTitle]; + _identityServerTextField.placeholder = [MatrixKitL10n loginServerUrlPlaceholder]; + _identityServerInfoLabel.text = [MatrixKitL10n loginIdentityServerInfo]; + [_cancelAuthFallbackButton setTitle:[MatrixKitL10n cancel] forState:UIControlStateNormal]; + [_cancelAuthFallbackButton setTitle:[MatrixKitL10n cancel] forState:UIControlStateHighlighted]; +} + +- (void)didReceiveMemoryWarning +{ + [super didReceiveMemoryWarning]; + // Dispose of any resources that can be recreated. +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onTextFieldChange:) name:UITextFieldTextDidChangeNotification object:nil]; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + [self dismissKeyboard]; + + // close any opened alert + if (alert) + { + [alert dismissViewControllerAnimated:NO completion:nil]; + alert = nil; + } + [[NSNotificationCenter defaultCenter] removeObserver:self name:AFNetworkingReachabilityDidChangeNotification object:nil]; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:UITextFieldTextDidChangeNotification object:nil]; +} + +#pragma mark - Override MXKViewController + +- (void)onKeyboardShowAnimationComplete +{ + // Report the keyboard view in order to track keyboard frame changes + // TODO define inputAccessoryView for each text input + // and report the inputAccessoryView.superview of the firstResponder in self.keyboardView. +} + +- (void)setKeyboardHeight:(CGFloat)keyboardHeight +{ + // Deduce the bottom inset for the scroll view (Don't forget the potential tabBar) + CGFloat scrollViewInsetBottom = keyboardHeight - self.bottomLayoutGuide.length; + // Check whether the keyboard is over the tabBar + if (scrollViewInsetBottom < 0) + { + scrollViewInsetBottom = 0; + } + + UIEdgeInsets insets = self.authenticationScrollView.contentInset; + insets.bottom = scrollViewInsetBottom; + self.authenticationScrollView.contentInset = insets; +} + +- (void)destroy +{ + self.authInputsView = nil; + + if (registrationTimer) + { + [registrationTimer invalidate]; + registrationTimer = nil; + } + + if (mxCurrentOperation) + { + [mxCurrentOperation cancel]; + mxCurrentOperation = nil; + } + + [self cancelIdentityServerCheck]; + + [mxRestClient close]; + mxRestClient = nil; + + authenticationFallback = nil; + cancelFallbackBarButton = nil; + + [super destroy]; +} + +#pragma mark - Class methods + +- (void)registerAuthInputsViewClass:(Class)authInputsViewClass forAuthType:(MXKAuthenticationType)authType +{ + // Sanity check: accept only MXKAuthInputsView classes or sub-classes + NSParameterAssert([authInputsViewClass isSubclassOfClass:MXKAuthInputsView.class]); + + if (authType == MXKAuthenticationTypeLogin) + { + loginAuthInputsViewClass = authInputsViewClass; + } + else if (authType == MXKAuthenticationTypeRegister) + { + registerAuthInputsViewClass = authInputsViewClass; + } + else if (authType == MXKAuthenticationTypeForgotPassword) + { + forgotPasswordAuthInputsViewClass = authInputsViewClass; + } +} + +- (void)setAuthType:(MXKAuthenticationType)authType +{ + if (_authType != authType) + { + _authType = authType; + + // Cancel external registration parameters if any + _externalRegistrationParameters = nil; + + // Remove the current inputs view + self.authInputsView = nil; + + isPasswordReseted = NO; + + [self.authInputsContainerView bringSubviewToFront: _authenticationActivityIndicator]; + [_authenticationActivityIndicator startAnimating]; + } + + // Restore user interaction + self.userInteractionEnabled = YES; + + if (authType == MXKAuthenticationTypeLogin) + { + _subTitleLabel.hidden = YES; + [_submitButton setTitle:[MatrixKitL10n login] forState:UIControlStateNormal]; + [_submitButton setTitle:[MatrixKitL10n login] forState:UIControlStateHighlighted]; + [_authSwitchButton setTitle:[MatrixKitL10n createAccount] forState:UIControlStateNormal]; + [_authSwitchButton setTitle:[MatrixKitL10n createAccount] forState:UIControlStateHighlighted]; + + // Update supported authentication flow and associated information (defined in authentication session) + [self refreshAuthenticationSession]; + } + else if (authType == MXKAuthenticationTypeRegister) + { + _subTitleLabel.hidden = NO; + _subTitleLabel.text = [MatrixKitL10n loginCreateAccount]; + [_submitButton setTitle:[MatrixKitL10n signUp] forState:UIControlStateNormal]; + [_submitButton setTitle:[MatrixKitL10n signUp] forState:UIControlStateHighlighted]; + [_authSwitchButton setTitle:[MatrixKitL10n back] forState:UIControlStateNormal]; + [_authSwitchButton setTitle:[MatrixKitL10n back] forState:UIControlStateHighlighted]; + + // Update supported authentication flow and associated information (defined in authentication session) + [self refreshAuthenticationSession]; + } + else if (authType == MXKAuthenticationTypeForgotPassword) + { + _subTitleLabel.hidden = YES; + + if (isPasswordReseted) + { + [_submitButton setTitle:[MatrixKitL10n back] forState:UIControlStateNormal]; + [_submitButton setTitle:[MatrixKitL10n back] forState:UIControlStateHighlighted]; + } + else + { + [_submitButton setTitle:[MatrixKitL10n submit] forState:UIControlStateNormal]; + [_submitButton setTitle:[MatrixKitL10n submit] forState:UIControlStateHighlighted]; + + [self refreshForgotPasswordSession]; + } + + [_authSwitchButton setTitle:[MatrixKitL10n back] forState:UIControlStateNormal]; + [_authSwitchButton setTitle:[MatrixKitL10n back] forState:UIControlStateHighlighted]; + } + + [self checkIdentityServer]; +} + +- (void)setAuthInputsView:(MXKAuthInputsView *)authInputsView +{ + // Here a new view will be loaded, hide first subviews which depend on auth flow + _submitButton.hidden = YES; + _noFlowLabel.hidden = YES; + _retryButton.hidden = YES; + + if (_authInputsView) + { + [_authInputsView removeObserver:self forKeyPath:@"viewHeightConstraint.constant"]; + + [NSLayoutConstraint deactivateConstraints:_authInputsView.constraints]; + [_authInputsView removeFromSuperview]; + _authInputsView.delegate = nil; + [_authInputsView destroy]; + _authInputsView = nil; + } + + _authInputsView = authInputsView; + + CGFloat previousInputsContainerViewHeight = _authInputContainerViewHeightConstraint.constant; + + if (_authInputsView) + { + _authInputsView.translatesAutoresizingMaskIntoConstraints = NO; + [_authInputsContainerView addSubview:_authInputsView]; + + _authInputsView.delegate = self; + + _submitButton.hidden = NO; + _authInputsView.hidden = NO; + + _authInputContainerViewHeightConstraint.constant = _authInputsView.viewHeightConstraint.constant; + + NSLayoutConstraint* topConstraint = [NSLayoutConstraint constraintWithItem:_authInputsContainerView + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:_authInputsView + attribute:NSLayoutAttributeTop + multiplier:1.0f + constant:0.0f]; + + + NSLayoutConstraint* leadingConstraint = [NSLayoutConstraint constraintWithItem:_authInputsContainerView + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:_authInputsView + attribute:NSLayoutAttributeLeading + multiplier:1.0f + constant:0.0f]; + + NSLayoutConstraint* trailingConstraint = [NSLayoutConstraint constraintWithItem:_authInputsContainerView + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:_authInputsView + attribute:NSLayoutAttributeTrailing + multiplier:1.0f + constant:0.0f]; + + + [NSLayoutConstraint activateConstraints:@[topConstraint, leadingConstraint, trailingConstraint]]; + + [_authInputsView addObserver:self forKeyPath:@"viewHeightConstraint.constant" options:0 context:nil]; + } + else + { + // No input fields are displayed + _authInputContainerViewHeightConstraint.constant = _authInputContainerViewMinHeightConstraint.constant; + } + + [self.view layoutIfNeeded]; + + // Refresh content view height by considering the updated height of inputs container + _contentViewHeightConstraint.constant += (_authInputContainerViewHeightConstraint.constant - previousInputsContainerViewHeight); +} + +- (void)setDefaultHomeServerUrl:(NSString *)defaultHomeServerUrl +{ + _defaultHomeServerUrl = defaultHomeServerUrl; + + if (!_homeServerTextField.text.length) + { + [self setHomeServerTextFieldText:defaultHomeServerUrl]; + } +} + +- (void)setDefaultIdentityServerUrl:(NSString *)defaultIdentityServerUrl +{ + _defaultIdentityServerUrl = defaultIdentityServerUrl; + + if (!_identityServerTextField.text.length) + { + [self setIdentityServerTextFieldText:defaultIdentityServerUrl]; + } +} + +- (void)setHomeServerTextFieldText:(NSString *)homeServerUrl +{ + if (!homeServerUrl.length) + { + // Force refresh with default value + homeServerUrl = _defaultHomeServerUrl; + } + + _homeServerTextField.text = homeServerUrl; + + if (!mxRestClient || ![mxRestClient.homeserver isEqualToString:homeServerUrl]) + { + [self updateRESTClient]; + + if (_authType == MXKAuthenticationTypeLogin || _authType == MXKAuthenticationTypeRegister) + { + // Restore default UI + self.authType = _authType; + } + else + { + // Refresh the IS anyway + [self checkIdentityServer]; + } + } +} + +- (void)setIdentityServerTextFieldText:(NSString *)identityServerUrl +{ + _identityServerTextField.text = identityServerUrl; + + [self updateIdentityServerURL:identityServerUrl]; +} + +- (void)updateIdentityServerURL:(NSString*)url +{ + if (![self.identityService.identityServer isEqualToString:url]) + { + if (url.length) + { + self.identityService = [[MXIdentityService alloc] initWithIdentityServer:url accessToken:nil andHomeserverRestClient:mxRestClient]; + } + else + { + self.identityService = nil; + } + } + + [mxRestClient setIdentityServer:url.length ? url : nil]; +} + +- (void)setIdentityServerHidden:(BOOL)hidden +{ + _identityServerContainer.hidden = hidden; +} + +- (void)checkIdentityServer +{ + [self cancelIdentityServerCheck]; + + // Hide the field while checking data + [self setIdentityServerHidden:YES]; + + NSString *homeserver = mxRestClient.homeserver; + + // First, fetch the IS advertised by the HS + if (homeserver) + { + MXLogDebug(@"[MXKAuthenticationVC] checkIdentityServer for homeserver %@", homeserver); + + autoDiscovery = [[MXAutoDiscovery alloc] initWithUrl:homeserver]; + + MXWeakify(self); + checkIdentityServerOperation = [autoDiscovery findClientConfig:^(MXDiscoveredClientConfig * _Nonnull discoveredClientConfig) { + MXStrongifyAndReturnIfNil(self); + + NSString *identityServer = discoveredClientConfig.wellKnown.identityServer.baseUrl; + MXLogDebug(@"[MXKAuthenticationVC] checkIdentityServer: Identity server: %@", identityServer); + + if (identityServer) + { + // Apply the provided IS + [self setIdentityServerTextFieldText:identityServer]; + } + + // Then, check if the HS needs an IS for running + MXWeakify(self); + MXHTTPOperation *operation = [self checkIdentityServerRequirementWithCompletion:^(BOOL identityServerRequired) { + + MXStrongifyAndReturnIfNil(self); + + self->checkIdentityServerOperation = nil; + + // Show the field only if an IS is required so that the user can customise it + [self setIdentityServerHidden:!identityServerRequired]; + }]; + + if (operation) + { + [self->checkIdentityServerOperation mutateTo:operation]; + } + else + { + self->checkIdentityServerOperation = nil; + } + + self->autoDiscovery = nil; + + } failure:^(NSError *error) { + MXStrongifyAndReturnIfNil(self); + + // No need to report this error to the end user + // There will be already an error about failing to get the auth flow from the HS + MXLogDebug(@"[MXKAuthenticationVC] checkIdentityServer. Error: %@", error); + + self->autoDiscovery = nil; + }]; + } +} + +- (void)cancelIdentityServerCheck +{ + if (checkIdentityServerOperation) + { + [checkIdentityServerOperation cancel]; + checkIdentityServerOperation = nil; + } +} + +- (MXHTTPOperation*)checkIdentityServerRequirementWithCompletion:(void (^)(BOOL identityServerRequired))completion +{ + MXHTTPOperation *operation; + + if (_authType == MXKAuthenticationTypeLogin) + { + // The identity server is only required for registration and password reset + // It is then stored in the user account data + completion(NO); + } + else + { + operation = [mxRestClient supportedMatrixVersions:^(MXMatrixVersions *matrixVersions) { + + MXLogDebug(@"[MXKAuthenticationVC] checkIdentityServerRequirement: %@", matrixVersions.doesServerRequireIdentityServerParam ? @"YES": @"NO"); + completion(matrixVersions.doesServerRequireIdentityServerParam); + + } failure:^(NSError *error) { + // No need to report this error to the end user + // There will be already an error about failing to get the auth flow from the HS + MXLogDebug(@"[MXKAuthenticationVC] checkIdentityServerRequirement. Error: %@", error); + }]; + } + + return operation; +} + +- (void)setUserInteractionEnabled:(BOOL)userInteractionEnabled +{ + _submitButton.enabled = (userInteractionEnabled && _authInputsView.areAllRequiredFieldsSet); + _authSwitchButton.enabled = userInteractionEnabled; + + _homeServerTextField.enabled = userInteractionEnabled; + _identityServerTextField.enabled = userInteractionEnabled; + + _userInteractionEnabled = userInteractionEnabled; +} + +- (void)refreshAuthenticationSession +{ + // Remove reachability observer + [[NSNotificationCenter defaultCenter] removeObserver:self name:AFNetworkingReachabilityDidChangeNotification object:nil]; + + // Cancel potential request in progress + [mxCurrentOperation cancel]; + mxCurrentOperation = nil; + + // Reset potential authentication fallback url + authenticationFallback = nil; + + if (mxRestClient) + { + if (_authType == MXKAuthenticationTypeLogin) + { + mxCurrentOperation = [mxRestClient getLoginSession:^(MXAuthenticationSession* authSession) { + + [self handleAuthenticationSession:authSession]; + + } failure:^(NSError *error) { + + [self onFailureDuringMXOperation:error]; + + }]; + } + else if (_authType == MXKAuthenticationTypeRegister) + { + mxCurrentOperation = [mxRestClient getRegisterSession:^(MXAuthenticationSession* authSession){ + + [self handleAuthenticationSession:authSession]; + + } failure:^(NSError *error){ + + [self onFailureDuringMXOperation:error]; + + }]; + } + else + { + // Not supported for other types + MXLogDebug(@"[MXKAuthenticationVC] refreshAuthenticationSession is ignored"); + } + } +} + +- (void)handleAuthenticationSession:(MXAuthenticationSession *)authSession +{ + mxCurrentOperation = nil; + + [_authenticationActivityIndicator stopAnimating]; + + // Check whether fallback is defined, and retrieve the right input view class. + Class authInputsViewClass; + if (_authType == MXKAuthenticationTypeLogin) + { + authenticationFallback = [mxRestClient loginFallback]; + authInputsViewClass = loginAuthInputsViewClass; + + } + else if (_authType == MXKAuthenticationTypeRegister) + { + authenticationFallback = [mxRestClient registerFallback]; + authInputsViewClass = registerAuthInputsViewClass; + } + else + { + // Not supported for other types + MXLogDebug(@"[MXKAuthenticationVC] handleAuthenticationSession is ignored"); + return; + } + + MXKAuthInputsView *authInputsView = nil; + if (authInputsViewClass) + { + // Instantiate a new auth inputs view, except if the current one is already an instance of this class. + if (self.authInputsView && self.authInputsView.class == authInputsViewClass) + { + // Use the current view + authInputsView = self.authInputsView; + } + else + { + authInputsView = [authInputsViewClass authInputsView]; + } + } + + if (authInputsView) + { + // Apply authentication session on inputs view + if ([authInputsView setAuthSession:authSession withAuthType:_authType] == NO) + { + MXLogDebug(@"[MXKAuthenticationVC] Received authentication settings are not supported"); + authInputsView = nil; + } + else if (!_softLogoutCredentials) + { + // If all listed flows in this authentication session are not supported we suggest using the fallback page. + if (authenticationFallback.length && authInputsView.authSession.flows.count == 0) + { + MXLogDebug(@"[MXKAuthenticationVC] No supported flow, suggest using fallback page"); + authInputsView = nil; + } + else if (authInputsView.authSession.flows.count != authSession.flows.count) + { + MXLogDebug(@"[MXKAuthenticationVC] The authentication session contains at least one unsupported flow"); + } + } + } + + if (authInputsView) + { + // Check whether the current view must be replaced + if (self.authInputsView != authInputsView) + { + // Refresh layout + self.authInputsView = authInputsView; + } + + // Refresh user interaction + self.userInteractionEnabled = _userInteractionEnabled; + + // Check whether an external set of parameters have been defined to pursue a registration + if (self.externalRegistrationParameters) + { + if ([authInputsView setExternalRegistrationParameters:self.externalRegistrationParameters]) + { + // Launch authentication now + [self onButtonPressed:_submitButton]; + } + else + { + [self onFailureDuringAuthRequest:[NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:[MatrixKitL10n notSupportedYet]}]]; + + _externalRegistrationParameters = nil; + + // Restore login screen on failure + self.authType = MXKAuthenticationTypeLogin; + } + } + + if (_softLogoutCredentials) + { + [authInputsView setSoftLogoutCredentials:_softLogoutCredentials]; + } + } + else + { + // Remove the potential auth inputs view + self.authInputsView = nil; + + // Cancel external registration parameters if any + _externalRegistrationParameters = nil; + + // Notify user that no flow is supported + if (_authType == MXKAuthenticationTypeLogin) + { + _noFlowLabel.text = [MatrixKitL10n loginErrorDoNotSupportLoginFlows]; + } + else + { + _noFlowLabel.text = [MatrixKitL10n loginErrorRegistrationIsNotSupported]; + } + MXLogDebug(@"[MXKAuthenticationVC] Warning: %@", _noFlowLabel.text); + + if (authenticationFallback.length) + { + [_retryButton setTitle:[MatrixKitL10n loginUseFallback] forState:UIControlStateNormal]; + [_retryButton setTitle:[MatrixKitL10n loginUseFallback] forState:UIControlStateNormal]; + } + else + { + [_retryButton setTitle:[MatrixKitL10n retry] forState:UIControlStateNormal]; + [_retryButton setTitle:[MatrixKitL10n retry] forState:UIControlStateNormal]; + } + + _noFlowLabel.hidden = NO; + _retryButton.hidden = NO; + } +} + +- (void)setExternalRegistrationParameters:(NSDictionary*)parameters +{ + if (parameters.count) + { + MXLogDebug(@"[MXKAuthenticationVC] setExternalRegistrationParameters"); + + // Cancel the current operation if any. + [self cancel]; + + // Load the view controller’s view if it has not yet been loaded. + // This is required before updating view's textfields (homeserver url...) + [self loadViewIfNeeded]; + + // Force register mode + self.authType = MXKAuthenticationTypeRegister; + + // Apply provided homeserver if any + id hs_url = parameters[@"hs_url"]; + NSString *homeserverURL = nil; + if (hs_url && [hs_url isKindOfClass:NSString.class]) + { + homeserverURL = hs_url; + } + [self setHomeServerTextFieldText:homeserverURL]; + + // Apply provided identity server if any + id is_url = parameters[@"is_url"]; + NSString *identityURL = nil; + if (is_url && [is_url isKindOfClass:NSString.class]) + { + identityURL = is_url; + } + [self setIdentityServerTextFieldText:identityURL]; + + // Disable user interaction + self.userInteractionEnabled = NO; + + // Cancel potential request in progress + [mxCurrentOperation cancel]; + mxCurrentOperation = nil; + + // Remove the current auth inputs view + self.authInputsView = nil; + + // Set external parameters and trigger a refresh (the parameters will be taken into account during [handleAuthenticationSession:]) + _externalRegistrationParameters = parameters; + [self refreshAuthenticationSession]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] reset externalRegistrationParameters"); + _externalRegistrationParameters = nil; + + // Restore default UI + self.authType = _authType; + } +} + +- (void)setSoftLogoutCredentials:(MXCredentials *)softLogoutCredentials +{ + MXLogDebug(@"[MXKAuthenticationVC] setSoftLogoutCredentials"); + + // Cancel the current operation if any. + [self cancel]; + + // Load the view controller’s view if it has not yet been loaded. + // This is required before updating view's textfields (homeserver url...) + [self loadViewIfNeeded]; + + // Force register mode + self.authType = MXKAuthenticationTypeLogin; + + [self setHomeServerTextFieldText:softLogoutCredentials.homeServer]; + [self setIdentityServerTextFieldText:softLogoutCredentials.identityServer]; + + // Cancel potential request in progress + [mxCurrentOperation cancel]; + mxCurrentOperation = nil; + + // Remove the current auth inputs view + self.authInputsView = nil; + + // Set parameters and trigger a refresh (the parameters will be taken into account during [handleAuthenticationSession:]) + _softLogoutCredentials = softLogoutCredentials; + [self refreshAuthenticationSession]; +} + +- (void)setOnUnrecognizedCertificateBlock:(MXHTTPClientOnUnrecognizedCertificate)onUnrecognizedCertificateBlock +{ + onUnrecognizedCertificateCustomBlock = onUnrecognizedCertificateBlock; +} + +- (void)isUserNameInUse:(void (^)(BOOL isUserNameInUse))callback +{ + mxCurrentOperation = [mxRestClient isUserNameInUse:self.authInputsView.userId callback:^(BOOL isUserNameInUse) { + + self->mxCurrentOperation = nil; + + if (callback) + { + callback (isUserNameInUse); + } + + }]; +} + +- (void)testUserRegistration:(void (^)(MXError *mxError))callback +{ + mxCurrentOperation = [mxRestClient testUserRegistration:self.authInputsView.userId callback:callback]; +} + +- (IBAction)onButtonPressed:(id)sender +{ + [self dismissKeyboard]; + + if (sender == _submitButton) + { + // Disable user interaction to prevent multiple requests + self.userInteractionEnabled = NO; + + // Check parameters validity + NSString *errorMsg = [self.authInputsView validateParameters]; + if (errorMsg) + { + [self onFailureDuringAuthRequest:[NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:errorMsg}]]; + } + else + { + [self.authInputsContainerView bringSubviewToFront: _authenticationActivityIndicator]; + + // Launch the authentication according to its type + if (_authType == MXKAuthenticationTypeLogin) + { + // Prepare the parameters dict + [self.authInputsView prepareParameters:^(NSDictionary *parameters, NSError *error) { + + if (parameters && self->mxRestClient) + { + [self->_authenticationActivityIndicator startAnimating]; + [self loginWithParameters:parameters]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] Failed to prepare parameters"); + [self onFailureDuringAuthRequest:error]; + } + + }]; + } + else if (_authType == MXKAuthenticationTypeRegister) + { + // Check here the availability of the userId + if (self.authInputsView.userId.length) + { + [_authenticationActivityIndicator startAnimating]; + + if (self.authInputsView.password.length) + { + // Trigger here a register request in order to associate the filled userId and password to the current session id + // This will check the availability of the userId at the same time + NSDictionary *parameters = @{@"auth": @{}, + @"username": self.authInputsView.userId, + @"password": self.authInputsView.password, + @"bind_email": @(NO), + @"initial_device_display_name":self.deviceDisplayName + }; + + mxCurrentOperation = [mxRestClient registerWithParameters:parameters success:^(NSDictionary *JSONResponse) { + + // Unexpected case where the registration succeeds without any other stages + MXLoginResponse *loginResponse; + MXJSONModelSetMXJSONModel(loginResponse, MXLoginResponse, JSONResponse); + + MXCredentials *credentials = [[MXCredentials alloc] initWithLoginResponse:loginResponse + andDefaultCredentials:self->mxRestClient.credentials]; + + // Sanity check + if (!credentials.userId || !credentials.accessToken) + { + [self onFailureDuringAuthRequest:[NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:[MatrixKitL10n notSupportedYet]}]]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] Registration succeeded"); + + // Report the certificate trusted by user (if any) + credentials.allowedCertificate = self->mxRestClient.allowedCertificate; + + [self onSuccessfulLogin:credentials]; + } + + } failure:^(NSError *error) { + + self->mxCurrentOperation = nil; + + // An updated authentication session should be available in response data in case of unauthorized request. + NSDictionary *JSONResponse = nil; + if (error.userInfo[MXHTTPClientErrorResponseDataKey]) + { + JSONResponse = error.userInfo[MXHTTPClientErrorResponseDataKey]; + } + + if (JSONResponse) + { + MXAuthenticationSession *authSession = [MXAuthenticationSession modelFromJSON:JSONResponse]; + + [self->_authenticationActivityIndicator stopAnimating]; + + // Update session identifier + self.authInputsView.authSession.session = authSession.session; + + // Launch registration by preparing parameters dict + [self.authInputsView prepareParameters:^(NSDictionary *parameters, NSError *error) { + + if (parameters && self->mxRestClient) + { + [self->_authenticationActivityIndicator startAnimating]; + [self registerWithParameters:parameters]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] Failed to prepare parameters"); + [self onFailureDuringAuthRequest:error]; + } + + }]; + } + else + { + [self onFailureDuringAuthRequest:error]; + } + }]; + } + else + { + [self isUserNameInUse:^(BOOL isUserNameInUse) { + + if (isUserNameInUse) + { + MXLogDebug(@"[MXKAuthenticationVC] User name is already use"); + [self onFailureDuringAuthRequest:[NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:[MatrixKitL10n authUsernameInUse]}]]; + } + else + { + [self->_authenticationActivityIndicator stopAnimating]; + + // Launch registration by preparing parameters dict + [self.authInputsView prepareParameters:^(NSDictionary *parameters, NSError *error) { + + if (parameters && self->mxRestClient) + { + [self->_authenticationActivityIndicator startAnimating]; + [self registerWithParameters:parameters]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] Failed to prepare parameters"); + [self onFailureDuringAuthRequest:error]; + } + + }]; + } + + }]; + } + } + else if (self.externalRegistrationParameters) + { + // Launch registration by preparing parameters dict + [self.authInputsView prepareParameters:^(NSDictionary *parameters, NSError *error) { + + if (parameters && self->mxRestClient) + { + [self->_authenticationActivityIndicator startAnimating]; + [self registerWithParameters:parameters]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] Failed to prepare parameters"); + [self onFailureDuringAuthRequest:error]; + } + + }]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] User name is missing"); + [self onFailureDuringAuthRequest:[NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:[MatrixKitL10n authInvalidUserName]}]]; + } + } + else if (_authType == MXKAuthenticationTypeForgotPassword) + { + // Check whether the password has been reseted + if (isPasswordReseted) + { + // Return to login screen + self.authType = MXKAuthenticationTypeLogin; + } + else + { + // Prepare the parameters dict + [self.authInputsView prepareParameters:^(NSDictionary *parameters, NSError *error) { + + if (parameters && self->mxRestClient) + { + [self->_authenticationActivityIndicator startAnimating]; + [self resetPasswordWithParameters:parameters]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] Failed to prepare parameters"); + [self onFailureDuringAuthRequest:error]; + } + + }]; + } + } + } + } + else if (sender == _authSwitchButton) + { + if (_authType == MXKAuthenticationTypeLogin) + { + self.authType = MXKAuthenticationTypeRegister; + } + else + { + self.authType = MXKAuthenticationTypeLogin; + } + } + else if (sender == _retryButton) + { + if (authenticationFallback) + { + [self showAuthenticationFallBackView:authenticationFallback]; + } + else + { + [self refreshAuthenticationSession]; + } + } + else if (sender == _cancelAuthFallbackButton) + { + // Hide fallback webview + [self hideRegistrationFallbackView]; + } +} + +- (void)cancel +{ + MXLogDebug(@"[MXKAuthenticationVC] cancel"); + + // Cancel external registration parameters if any + _externalRegistrationParameters = nil; + + if (registrationTimer) + { + [registrationTimer invalidate]; + registrationTimer = nil; + } + + // Cancel request in progress + if (mxCurrentOperation) + { + [mxCurrentOperation cancel]; + mxCurrentOperation = nil; + } + + [_authenticationActivityIndicator stopAnimating]; + self.userInteractionEnabled = YES; + + // Reset potential completed stages + self.authInputsView.authSession.completed = nil; + + // Update authentication inputs view to return in initial step + [self.authInputsView setAuthSession:self.authInputsView.authSession withAuthType:_authType]; +} + +- (void)onFailureDuringAuthRequest:(NSError *)error +{ + mxCurrentOperation = nil; + [_authenticationActivityIndicator stopAnimating]; + self.userInteractionEnabled = YES; + + // Ignore connection cancellation error + if (([error.domain isEqualToString:NSURLErrorDomain] && error.code == NSURLErrorCancelled)) + { + MXLogDebug(@"[MXKAuthenticationVC] Auth request cancelled"); + return; + } + + MXLogDebug(@"[MXKAuthenticationVC] Auth request failed: %@", error); + + // Cancel external registration parameters if any + _externalRegistrationParameters = nil; + + // Translate the error code to a human message + NSString *title = error.localizedFailureReason; + if (!title) + { + if (self.authType == MXKAuthenticationTypeLogin) + { + title = [MatrixKitL10n loginErrorTitle]; + } + else if (self.authType == MXKAuthenticationTypeRegister) + { + title = [MatrixKitL10n registerErrorTitle]; + } + else + { + title = [MatrixKitL10n error]; + } + } + NSString* message = error.localizedDescription; + NSDictionary* dict = error.userInfo; + + // detect if it is a Matrix SDK issue + if (dict) + { + NSString* localizedError = [dict valueForKey:@"error"]; + NSString* errCode = [dict valueForKey:@"errcode"]; + + if (localizedError.length > 0) + { + message = localizedError; + } + + if (errCode) + { + if ([errCode isEqualToString:kMXErrCodeStringForbidden]) + { + message = [MatrixKitL10n loginErrorForbidden]; + } + else if ([errCode isEqualToString:kMXErrCodeStringUnknownToken]) + { + message = [MatrixKitL10n loginErrorUnknownToken]; + } + else if ([errCode isEqualToString:kMXErrCodeStringBadJSON]) + { + message = [MatrixKitL10n loginErrorBadJson]; + } + else if ([errCode isEqualToString:kMXErrCodeStringNotJSON]) + { + message = [MatrixKitL10n loginErrorNotJson]; + } + else if ([errCode isEqualToString:kMXErrCodeStringLimitExceeded]) + { + message = [MatrixKitL10n loginErrorLimitExceeded]; + } + else if ([errCode isEqualToString:kMXErrCodeStringUserInUse]) + { + message = [MatrixKitL10n loginErrorUserInUse]; + } + else if ([errCode isEqualToString:kMXErrCodeStringLoginEmailURLNotYet]) + { + message = [MatrixKitL10n loginErrorLoginEmailNotYet]; + } + else if ([errCode isEqualToString:kMXErrCodeStringResourceLimitExceeded]) + { + [self showResourceLimitExceededError:dict onAdminContactTapped:nil]; + return; + } + else if (!message.length) + { + message = errCode; + } + } + } + + // Alert user + if (alert) + { + [alert dismissViewControllerAnimated:NO completion:nil]; + } + + alert = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + self->alert = nil; + + }]]; + + + [self presentViewController:alert animated:YES completion:nil]; + + // Update authentication inputs view to return in initial step + [self.authInputsView setAuthSession:self.authInputsView.authSession withAuthType:_authType]; + if (self.softLogoutCredentials) + { + self.authInputsView.softLogoutCredentials = self.softLogoutCredentials; + } +} + +- (void)showResourceLimitExceededError:(NSDictionary *)errorDict onAdminContactTapped:(void (^)(NSURL *adminContact))onAdminContactTapped +{ + mxCurrentOperation = nil; + [_authenticationActivityIndicator stopAnimating]; + self.userInteractionEnabled = YES; + + // Alert user + if (alert) + { + [alert dismissViewControllerAnimated:NO completion:nil]; + } + + // Parse error data + NSString *limitType, *adminContactString; + NSURL *adminContact; + + MXJSONModelSetString(limitType, errorDict[kMXErrorResourceLimitExceededLimitTypeKey]); + MXJSONModelSetString(adminContactString, errorDict[kMXErrorResourceLimitExceededAdminContactKey]); + + if (adminContactString) + { + adminContact = [NSURL URLWithString:adminContactString]; + } + + NSString *title = [MatrixKitL10n loginErrorResourceLimitExceededTitle]; + + // Build the message content + NSMutableString *message = [NSMutableString new]; + if ([limitType isEqualToString:kMXErrorResourceLimitExceededLimitTypeMonthlyActiveUserValue]) + { + [message appendString:[MatrixKitL10n loginErrorResourceLimitExceededMessageMonthlyActiveUser]]; + } + else + { + [message appendString:[MatrixKitL10n loginErrorResourceLimitExceededMessageDefault]]; + } + + [message appendString:[MatrixKitL10n loginErrorResourceLimitExceededMessageContact]]; + + // Build the alert + alert = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert]; + + MXWeakify(self); + if (adminContact && onAdminContactTapped) + { + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n loginErrorResourceLimitExceededContactButton] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) + { + MXStrongifyAndReturnIfNil(self); + self->alert = nil; + + // Let the system handle the URI + // It could be something like "mailto: server.admin@example.com" + onAdminContactTapped(adminContact); + }]]; + } + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) + { + MXStrongifyAndReturnIfNil(self); + self->alert = nil; + }]]; + + [self presentViewController:alert animated:YES completion:nil]; + + // Update authentication inputs view to return in initial step + [self.authInputsView setAuthSession:self.authInputsView.authSession withAuthType:_authType]; +} + +- (void)onSuccessfulLogin:(MXCredentials*)credentials +{ + mxCurrentOperation = nil; + [_authenticationActivityIndicator stopAnimating]; + self.userInteractionEnabled = YES; + + if (self.softLogoutCredentials) + { + // Hydrate the account with the new access token + MXKAccount *account = [[MXKAccountManager sharedManager] accountForUserId:self.softLogoutCredentials.userId]; + [[MXKAccountManager sharedManager] hydrateAccount:account withCredentials:credentials]; + + if (_delegate) + { + [_delegate authenticationViewController:self didLogWithUserId:credentials.userId]; + } + } + // Sanity check: check whether the user is not already logged in with this id + else if ([[MXKAccountManager sharedManager] accountForUserId:credentials.userId]) + { + //Alert user + __weak typeof(self) weakSelf = self; + + if (alert) + { + [alert dismissViewControllerAnimated:NO completion:nil]; + } + + alert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n loginErrorAlreadyLoggedIn] message:nil preferredStyle:UIAlertControllerStyleAlert]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + // We remove the authentication view controller. + typeof(self) self = weakSelf; + self->alert = nil; + [self withdrawViewControllerAnimated:YES completion:nil]; + + }]]; + + + [self presentViewController:alert animated:YES completion:nil]; + } + else + { + // Report the new account in account manager + if (!credentials.identityServer) + { + credentials.identityServer = _identityServerTextField.text; + } + + [self createAccountWithCredentials:credentials]; + } +} + +- (MXHTTPOperation *)currentHttpOperation +{ + return mxCurrentOperation; +} + +#pragma mark - Privates + +// Hook point for triggering device rehydration in subclasses +// Avoid cycles by using a separate private method do to the actual work +- (void)createAccountWithCredentials:(MXCredentials *)credentials +{ + [self _createAccountWithCredentials:credentials]; +} + +- (void)attemptDeviceRehydrationWithKeyData:(NSData *)keyData + credentials:(MXCredentials *)credentials +{ + [self attemptDeviceRehydrationWithKeyData:keyData + credentials:credentials + retry:YES]; +} + +- (void)attemptDeviceRehydrationWithKeyData:(NSData *)keyData + credentials:(MXCredentials *)credentials + retry:(BOOL)retry +{ + MXLogDebug(@"[MXKAuthenticationViewController] attemptDeviceRehydration: starting device rehydration"); + + if (keyData == nil) + { + MXLogError(@"[MXKAuthenticationViewController] attemptDeviceRehydration: no key provided for device rehydration"); + [self _createAccountWithCredentials:credentials]; + return; + } + + MXRestClient *mxRestClient = [[MXRestClient alloc] initWithCredentials:credentials andOnUnrecognizedCertificateBlock:^BOOL(NSData *certificate) { + return NO; + }]; + + MXWeakify(self); + [[MXKAccountManager sharedManager].dehydrationService rehydrateDeviceWithMatrixRestClient:mxRestClient dehydrationKey:keyData success:^(NSString * deviceId) { + MXStrongifyAndReturnIfNil(self); + + if (deviceId) + { + MXLogDebug(@"[MXKAuthenticationViewController] attemptDeviceRehydration: device %@ rehydrated successfully.", deviceId); + credentials.deviceId = deviceId; + } + else + { + MXLogDebug(@"[MXKAuthenticationViewController] attemptDeviceRehydration: device rehydration has been canceled."); + } + + [self _createAccountWithCredentials:credentials]; + } failure:^(NSError *error) { + MXStrongifyAndReturnIfNil(self); + + if (retry) + { + MXLogError(@"[MXKAuthenticationViewController] attemptDeviceRehydration: device rehydration failed due to error: %@. Retrying", error); + [self attemptDeviceRehydrationWithKeyData:keyData credentials:credentials retry:NO]; + return; + } + + MXLogError(@"[MXKAuthenticationViewController] attemptDeviceRehydration: device rehydration failed due to error: %@", error); + + [self _createAccountWithCredentials:credentials]; + }]; +} + +- (void)_createAccountWithCredentials:(MXCredentials *)credentials +{ + MXKAccount *account = [[MXKAccount alloc] initWithCredentials:credentials]; + account.identityServerURL = credentials.identityServer; + + [[MXKAccountManager sharedManager] addAccount:account andOpenSession:YES]; + + if (_delegate) + { + [_delegate authenticationViewController:self didLogWithUserId:credentials.userId]; + } +} + +- (NSString *)deviceDisplayName +{ + if (_deviceDisplayName) + { + return _deviceDisplayName; + } + +#if TARGET_OS_IPHONE + NSString *deviceName = [[UIDevice currentDevice].model isEqualToString:@"iPad"] ? [MatrixKitL10n loginTabletDevice] : [MatrixKitL10n loginMobileDevice]; +#elif TARGET_OS_OSX + NSString *deviceName = [MatrixKitL10n loginDesktopDevice]; +#endif + + return deviceName; +} + +- (void)refreshForgotPasswordSession +{ + [_authenticationActivityIndicator stopAnimating]; + + MXKAuthInputsView *authInputsView = nil; + if (forgotPasswordAuthInputsViewClass) + { + // Instantiate a new auth inputs view, except if the current one is already an instance of this class. + if (self.authInputsView && self.authInputsView.class == forgotPasswordAuthInputsViewClass) + { + // Use the current view + authInputsView = self.authInputsView; + } + else + { + authInputsView = [forgotPasswordAuthInputsViewClass authInputsView]; + } + } + + if (authInputsView) + { + // Update authentication inputs view to return in initial step + [authInputsView setAuthSession:nil withAuthType:MXKAuthenticationTypeForgotPassword]; + + // Check whether the current view must be replaced + if (self.authInputsView != authInputsView) + { + // Refresh layout + self.authInputsView = authInputsView; + } + + // Refresh user interaction + self.userInteractionEnabled = _userInteractionEnabled; + } + else + { + // Remove the potential auth inputs view + self.authInputsView = nil; + + _noFlowLabel.text = [MatrixKitL10n loginErrorForgotPasswordIsNotSupported]; + + MXLogDebug(@"[MXKAuthenticationVC] Warning: %@", _noFlowLabel.text); + + _noFlowLabel.hidden = NO; + } +} + +- (void)updateRESTClient +{ + NSString *homeserverURL = _homeServerTextField.text; + + if (homeserverURL.length) + { + // Check change + if ([homeserverURL isEqualToString:mxRestClient.homeserver] == NO) + { + mxRestClient = [[MXRestClient alloc] initWithHomeServer:homeserverURL andOnUnrecognizedCertificateBlock:^BOOL(NSData *certificate) { + + // Check first if the app developer provided its own certificate handler. + if (self->onUnrecognizedCertificateCustomBlock) + { + return self->onUnrecognizedCertificateCustomBlock (certificate); + } + + // Else prompt the user by displaying a fingerprint (SHA256) of the certificate. + __block BOOL isTrusted; + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + + NSString *title = [MatrixKitL10n sslCouldNotVerify]; + NSString *homeserverURLStr = [MatrixKitL10n sslHomeserverUrl:homeserverURL]; + NSString *fingerprint = [MatrixKitL10n sslFingerprintHash:@"SHA256"]; + NSString *certFingerprint = [certificate mx_SHA256AsHexString]; + + NSString *msg = [NSString stringWithFormat:@"%@\n\n%@\n\n%@\n\n%@\n\n%@\n\n%@", [MatrixKitL10n sslCertNotTrust], [MatrixKitL10n sslCertNewAccountExpl], homeserverURLStr, fingerprint, certFingerprint, [MatrixKitL10n sslOnlyAccept]]; + + if (self->alert) + { + [self->alert dismissViewControllerAnimated:NO completion:nil]; + } + + self->alert = [UIAlertController alertControllerWithTitle:title message:msg preferredStyle:UIAlertControllerStyleAlert]; + + [self->alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + self->alert = nil; + isTrusted = NO; + dispatch_semaphore_signal(semaphore); + + }]]; + + [self->alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n sslTrust] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + self->alert = nil; + isTrusted = YES; + dispatch_semaphore_signal(semaphore); + + }]]; + + dispatch_async(dispatch_get_main_queue(), ^{ + [self presentViewController:self->alert animated:YES completion:nil]; + }); + + dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); + + if (!isTrusted) + { + // Cancel request in progress + [self->mxCurrentOperation cancel]; + self->mxCurrentOperation = nil; + [[NSNotificationCenter defaultCenter] removeObserver:self name:AFNetworkingReachabilityDidChangeNotification object:nil]; + + [self->_authenticationActivityIndicator stopAnimating]; + } + + return isTrusted; + }]; + + if (_identityServerTextField.text.length) + { + [self updateIdentityServerURL:self.identityServerTextField.text]; + } + } + } + else + { + [mxRestClient close]; + mxRestClient = nil; + } +} + +- (void)loginWithParameters:(NSDictionary*)parameters +{ + // Add the device name + NSMutableDictionary *theParameters = [NSMutableDictionary dictionaryWithDictionary:parameters]; + theParameters[@"initial_device_display_name"] = self.deviceDisplayName; + + mxCurrentOperation = [mxRestClient login:theParameters success:^(NSDictionary *JSONResponse) { + + MXLoginResponse *loginResponse; + MXJSONModelSetMXJSONModel(loginResponse, MXLoginResponse, JSONResponse); + + MXCredentials *credentials = [[MXCredentials alloc] initWithLoginResponse:loginResponse + andDefaultCredentials:self->mxRestClient.credentials]; + + // Sanity check + if (!credentials.userId || !credentials.accessToken) + { + [self onFailureDuringAuthRequest:[NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:[MatrixKitL10n notSupportedYet]}]]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] Login process succeeded"); + + // Report the certificate trusted by user (if any) + credentials.allowedCertificate = self->mxRestClient.allowedCertificate; + + [self onSuccessfulLogin:credentials]; + } + + } failure:^(NSError *error) { + + [self onFailureDuringAuthRequest:error]; + + }]; +} + +- (void)registerWithParameters:(NSDictionary*)parameters +{ + if (registrationTimer) + { + [registrationTimer invalidate]; + registrationTimer = nil; + } + + // Add the device name + NSMutableDictionary *theParameters = [NSMutableDictionary dictionaryWithDictionary:parameters]; + theParameters[@"initial_device_display_name"] = self.deviceDisplayName; + + mxCurrentOperation = [mxRestClient registerWithParameters:theParameters success:^(NSDictionary *JSONResponse) { + + MXLoginResponse *loginResponse; + MXJSONModelSetMXJSONModel(loginResponse, MXLoginResponse, JSONResponse); + + MXCredentials *credentials = [[MXCredentials alloc] initWithLoginResponse:loginResponse + andDefaultCredentials:self->mxRestClient.credentials]; + + // Sanity check + if (!credentials.userId || !credentials.accessToken) + { + [self onFailureDuringAuthRequest:[NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:[MatrixKitL10n notSupportedYet]}]]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] Registration succeeded"); + + // Report the certificate trusted by user (if any) + credentials.allowedCertificate = self->mxRestClient.allowedCertificate; + + [self onSuccessfulLogin:credentials]; + } + + } failure:^(NSError *error) { + + self->mxCurrentOperation = nil; + + // Check whether the authentication is pending (for example waiting for email validation) + MXError *mxError = [[MXError alloc] initWithNSError:error]; + if (mxError && [mxError.errcode isEqualToString:kMXErrCodeStringUnauthorized]) + { + MXLogDebug(@"[MXKAuthenticationVC] Wait for email validation"); + + // Postpone a new attempt in 10 sec + self->registrationTimer = [NSTimer scheduledTimerWithTimeInterval:10 target:self selector:@selector(registrationTimerFireMethod:) userInfo:parameters repeats:NO]; + } + else + { + // The completed stages should be available in response data in case of unauthorized request. + NSDictionary *JSONResponse = nil; + if (error.userInfo[MXHTTPClientErrorResponseDataKey]) + { + JSONResponse = error.userInfo[MXHTTPClientErrorResponseDataKey]; + } + + if (JSONResponse) + { + MXAuthenticationSession *authSession = [MXAuthenticationSession modelFromJSON:JSONResponse]; + + if (authSession.completed) + { + [self->_authenticationActivityIndicator stopAnimating]; + + // Update session identifier in case of change + self.authInputsView.authSession.session = authSession.session; + + [self.authInputsView updateAuthSessionWithCompletedStages:authSession.completed didUpdateParameters:^(NSDictionary *parameters, NSError *error) { + + if (parameters) + { + MXLogDebug(@"[MXKAuthenticationVC] Pursue registration"); + + [self->_authenticationActivityIndicator startAnimating]; + [self registerWithParameters:parameters]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] Failed to update parameters"); + + [self onFailureDuringAuthRequest:error]; + } + + }]; + + return; + } + + [self onFailureDuringAuthRequest:[NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:[MatrixKitL10n notSupportedYet]}]]; + } + else + { + [self onFailureDuringAuthRequest:error]; + } + } + }]; +} + +- (void)registrationTimerFireMethod:(NSTimer *)timer +{ + if (timer == registrationTimer && timer.isValid) + { + MXLogDebug(@"[MXKAuthenticationVC] Retry registration"); + [self registerWithParameters:registrationTimer.userInfo]; + } +} + +- (void)resetPasswordWithParameters:(NSDictionary*)parameters +{ + mxCurrentOperation = [mxRestClient resetPasswordWithParameters:parameters success:^() { + + MXLogDebug(@"[MXKAuthenticationVC] Reset password succeeded"); + + self->mxCurrentOperation = nil; + [self->_authenticationActivityIndicator stopAnimating]; + + self->isPasswordReseted = YES; + + // Force UI update to refresh submit button title. + self.authType = self->_authType; + + // Refresh the authentication inputs view on success. + [self.authInputsView nextStep]; + + } failure:^(NSError *error) { + + MXError *mxError = [[MXError alloc] initWithNSError:error]; + if (mxError && [mxError.errcode isEqualToString:kMXErrCodeStringUnauthorized]) + { + MXLogDebug(@"[MXKAuthenticationVC] Forgot Password: wait for email validation"); + + self->mxCurrentOperation = nil; + [self->_authenticationActivityIndicator stopAnimating]; + + if (self->alert) + { + [self->alert dismissViewControllerAnimated:NO completion:nil]; + } + + self->alert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n error] message:[MatrixKitL10n authResetPasswordErrorUnauthorized] preferredStyle:UIAlertControllerStyleAlert]; + + [self->alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + self->alert = nil; + + }]]; + + + [self presentViewController:self->alert animated:YES completion:nil]; + } + else if (mxError && [mxError.errcode isEqualToString:kMXErrCodeStringNotFound]) + { + MXLogDebug(@"[MXKAuthenticationVC] Forgot Password: not found"); + + NSMutableDictionary *userInfo; + if (error.userInfo) + { + userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo]; + } + else + { + userInfo = [NSMutableDictionary dictionary]; + } + userInfo[NSLocalizedDescriptionKey] = [MatrixKitL10n authResetPasswordErrorNotFound]; + + [self onFailureDuringAuthRequest:[NSError errorWithDomain:kMXNSErrorDomain code:0 userInfo:userInfo]]; + } + else + { + [self onFailureDuringAuthRequest:error]; + } + + }]; +} + +- (void)onFailureDuringMXOperation:(NSError*)error +{ + mxCurrentOperation = nil; + + [_authenticationActivityIndicator stopAnimating]; + + if ([error.domain isEqualToString:NSURLErrorDomain] && error.code == NSURLErrorCancelled) + { + // Ignore this error + MXLogDebug(@"[MXKAuthenticationVC] flows request cancelled"); + return; + } + + MXLogDebug(@"[MXKAuthenticationVC] Failed to get %@ flows: %@", (_authType == MXKAuthenticationTypeLogin ? @"Login" : @"Register"), error); + + // Cancel external registration parameters if any + _externalRegistrationParameters = nil; + + // Alert user + NSString *title = [error.userInfo valueForKey:NSLocalizedFailureReasonErrorKey]; + if (!title) + { + title = [MatrixKitL10n error]; + } + NSString *msg = [error.userInfo valueForKey:NSLocalizedDescriptionKey]; + + if (alert) + { + [alert dismissViewControllerAnimated:NO completion:nil]; + } + + alert = [UIAlertController alertControllerWithTitle:title message:msg preferredStyle:UIAlertControllerStyleAlert]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n dismiss] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + self->alert = nil; + + }]]; + + + [self presentViewController:alert animated:YES completion:nil]; + + // Handle specific error code here + if ([error.domain isEqualToString:NSURLErrorDomain]) + { + // Check network reachability + if (error.code == NSURLErrorNotConnectedToInternet) + { + // Add reachability observer in order to launch a new request when network will be available + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onReachabilityStatusChange:) name:AFNetworkingReachabilityDidChangeNotification object:nil]; + } + else if (error.code == kCFURLErrorTimedOut) + { + // Send a new request in 2 sec + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self refreshAuthenticationSession]; + }); + } + else + { + // Remove the potential auth inputs view + self.authInputsView = nil; + } + } + else + { + // Remove the potential auth inputs view + self.authInputsView = nil; + } + + if (!_authInputsView) + { + // Display failure reason + _noFlowLabel.hidden = NO; + _noFlowLabel.text = [error.userInfo valueForKey:NSLocalizedDescriptionKey]; + if (!_noFlowLabel.text.length) + { + _noFlowLabel.text = [MatrixKitL10n loginErrorNoLoginFlow]; + } + [_retryButton setTitle:[MatrixKitL10n retry] forState:UIControlStateNormal]; + [_retryButton setTitle:[MatrixKitL10n retry] forState:UIControlStateNormal]; + _retryButton.hidden = NO; + } +} + +- (void)onReachabilityStatusChange:(NSNotification *)notif +{ + AFNetworkReachabilityManager *reachabilityManager = [AFNetworkReachabilityManager sharedManager]; + AFNetworkReachabilityStatus status = reachabilityManager.networkReachabilityStatus; + + if (status == AFNetworkReachabilityStatusReachableViaWiFi || status == AFNetworkReachabilityStatusReachableViaWWAN) + { + dispatch_async(dispatch_get_main_queue(), ^{ + [self refreshAuthenticationSession]; + }); + } + else if (status == AFNetworkReachabilityStatusNotReachable) + { + _noFlowLabel.text = [MatrixKitL10n networkErrorNotReachable]; + } +} + +#pragma mark - Keyboard handling + +- (void)dismissKeyboard +{ + // Hide the keyboard + [_authInputsView dismissKeyboard]; + [_homeServerTextField resignFirstResponder]; + [_identityServerTextField resignFirstResponder]; +} + +#pragma mark - UITextField delegate + +- (void)onTextFieldChange:(NSNotification *)notif +{ + _submitButton.enabled = _authInputsView.areAllRequiredFieldsSet; + + if (notif.object == _homeServerTextField) + { + // If any, the current request is obsolete + [self cancelIdentityServerCheck]; + + [self setIdentityServerHidden:YES]; + } +} + +- (BOOL)textFieldShouldBeginEditing:(UITextField *)textField +{ + if (textField == _homeServerTextField) + { + // Cancel supported AuthFlow refresh if a request is in progress + [[NSNotificationCenter defaultCenter] removeObserver:self name:AFNetworkingReachabilityDidChangeNotification object:nil]; + + if (mxCurrentOperation) + { + // Cancel potential request in progress + [mxCurrentOperation cancel]; + mxCurrentOperation = nil; + } + } + + return YES; +} + +- (void)textFieldDidEndEditing:(UITextField *)textField +{ + if (textField == _homeServerTextField) + { + [self setHomeServerTextFieldText:textField.text]; + } + else if (textField == _identityServerTextField) + { + [self setIdentityServerTextFieldText:textField.text]; + } +} + +- (BOOL)textFieldShouldReturn:(UITextField*)textField +{ + if (textField.returnKeyType == UIReturnKeyDone) + { + // "Done" key has been pressed + [textField resignFirstResponder]; + } + return YES; +} + +#pragma mark - AuthInputsViewDelegate delegate + +- (void)authInputsView:(MXKAuthInputsView*)authInputsView presentAlertController:(UIAlertController*)inputsAlert +{ + [self dismissKeyboard]; + [self presentViewController:inputsAlert animated:YES completion:nil]; +} + +- (void)authInputsViewDidPressDoneKey:(MXKAuthInputsView *)authInputsView +{ + if (_submitButton.isEnabled) + { + // Launch authentication now + [self onButtonPressed:_submitButton]; + } +} + +- (MXRestClient *)authInputsViewThirdPartyIdValidationRestClient:(MXKAuthInputsView *)authInputsView +{ + return mxRestClient; +} + +- (MXIdentityService *)authInputsViewThirdPartyIdValidationIdentityService:(MXIdentityService *)authInputsView +{ + return self.identityService; +} + +#pragma mark - Authentication Fallback + +- (void)showAuthenticationFallBackView +{ + [self showAuthenticationFallBackView:authenticationFallback]; +} + +- (void)showAuthenticationFallBackView:(NSString*)fallbackPage +{ + _authenticationScrollView.hidden = YES; + _authFallbackContentView.hidden = NO; + + // Add a cancel button in case of navigation controller use. + if (self.navigationController) + { + if (!cancelFallbackBarButton) + { + cancelFallbackBarButton = [[UIBarButtonItem alloc] initWithTitle:[MatrixKitL10n loginLeaveFallback] style:UIBarButtonItemStylePlain target:self action:@selector(hideRegistrationFallbackView)]; + } + + // Add cancel button in right bar items + NSArray *rightBarButtonItems = self.navigationItem.rightBarButtonItems; + self.navigationItem.rightBarButtonItems = rightBarButtonItems ? [rightBarButtonItems arrayByAddingObject:cancelFallbackBarButton] : @[cancelFallbackBarButton]; + } + + if (self.softLogoutCredentials) + { + // Add device_id as query param of the fallback + NSURLComponents *components = [[NSURLComponents alloc] initWithString:fallbackPage]; + + NSMutableArray *queryItems = [components.queryItems mutableCopy]; + if (!queryItems) + { + queryItems = [NSMutableArray array]; + } + + [queryItems addObject:[NSURLQueryItem queryItemWithName:@"device_id" + value:self.softLogoutCredentials.deviceId]]; + + components.queryItems = queryItems; + + fallbackPage = components.URL.absoluteString; + } + + [_authFallbackWebView openFallbackPage:fallbackPage success:^(MXLoginResponse *loginResponse) { + + MXCredentials *credentials = [[MXCredentials alloc] initWithLoginResponse:loginResponse andDefaultCredentials:self->mxRestClient.credentials]; + + // TODO handle unrecognized certificate (if any) during registration through fallback webview. + + [self onSuccessfulLogin:credentials]; + }]; +} + +- (void)hideRegistrationFallbackView +{ + if (cancelFallbackBarButton) + { + NSMutableArray *rightBarButtonItems = [NSMutableArray arrayWithArray: self.navigationItem.rightBarButtonItems]; + [rightBarButtonItems removeObject:cancelFallbackBarButton]; + self.navigationItem.rightBarButtonItems = rightBarButtonItems; + } + + [_authFallbackWebView stopLoading]; + _authenticationScrollView.hidden = NO; + _authFallbackContentView.hidden = YES; +} + +#pragma mark - KVO + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context +{ + if ([@"viewHeightConstraint.constant" isEqualToString:keyPath]) + { + // Refresh the height of the auth inputs view container. + CGFloat previousInputsContainerViewHeight = _authInputContainerViewHeightConstraint.constant; + _authInputContainerViewHeightConstraint.constant = _authInputsView.viewHeightConstraint.constant; + + // Force to render the view + [self.view layoutIfNeeded]; + + // Refresh content view height by considering the updated height of inputs container + _contentViewHeightConstraint.constant += (_authInputContainerViewHeightConstraint.constant - previousInputsContainerViewHeight); + } + else + { + [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.xib new file mode 100644 index 000000000..807f6382d --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.xib @@ -0,0 +1,298 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKCallViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKCallViewController.h new file mode 100644 index 000000000..7e8e8ad92 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKCallViewController.h @@ -0,0 +1,243 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import + +#import "MXKViewController.h" + +#import "MXKImageView.h" + +@class MXKCallViewController; + +/** + Delegate for `MXKCallViewController` object + */ +@protocol MXKCallViewControllerDelegate + +/** + Tells the delegate to dismiss the call view controller. + This callback is called when the user wants to go back into the app during a call or when the call is ended. + The delegate should check the state of the associated call to know the actual reason. + + @param callViewController the call view controller. + @param completion the block to execute at the end of the operation. + */ +- (void)dismissCallViewController:(MXKCallViewController *)callViewController completion:(void (^)(void))completion; + +/** + Tells the delegate that user tapped on hold call. + @param callViewController the call view controller. + */ +- (void)callViewControllerDidTapOnHoldCall:(MXKCallViewController *)callViewController; + +@end + +extern NSString *const kMXKCallViewControllerWillAppearNotification; +extern NSString *const kMXKCallViewControllerAppearedNotification; +extern NSString *const kMXKCallViewControllerWillDisappearNotification; +extern NSString *const kMXKCallViewControllerDisappearedNotification; +extern NSString *const kMXKCallViewControllerBackToAppNotification; + +/** + 'MXKCallViewController' instance displays a call. Only one matrix session is supported by this view controller. + */ +@interface MXKCallViewController : MXKViewController + +@property (weak, nonatomic) IBOutlet MXKImageView *backgroundImageView; + +@property (weak, nonatomic, readonly) IBOutlet UIView *localPreviewContainerView; +@property (weak, nonatomic, readonly) IBOutlet UIView *localPreviewVideoView; +@property (weak, nonatomic) IBOutlet UIActivityIndicatorView *localPreviewActivityView; + +@property (weak, nonatomic, readonly) IBOutlet UIView *onHoldCallContainerView; +@property (weak, nonatomic) IBOutlet MXKImageView *onHoldCallerImageView; + +@property (weak, nonatomic, readonly) IBOutlet UIView *remotePreviewContainerView; + +@property (weak, nonatomic) IBOutlet UIView *overlayContainerView; +@property (weak, nonatomic) IBOutlet UIView *callContainerView; +@property (weak, nonatomic) IBOutlet MXKImageView *callerImageView; +@property (weak, nonatomic) IBOutlet UIImageView *pausedIcon; +@property (weak, nonatomic) IBOutlet UILabel *callerNameLabel; +@property (weak, nonatomic) IBOutlet UILabel *callStatusLabel; +@property (weak, nonatomic) IBOutlet UIButton *resumeButton; + +@property (weak, nonatomic) IBOutlet UIView *callToolBar; +@property (weak, nonatomic) IBOutlet UIButton *rejectCallButton; +@property (weak, nonatomic) IBOutlet UIButton *answerCallButton; +@property (weak, nonatomic) IBOutlet UIButton *endCallButton; + +@property (weak, nonatomic) IBOutlet UIView *callControlContainerView; +@property (weak, nonatomic) IBOutlet UIButton *speakerButton; +@property (weak, nonatomic) IBOutlet UIButton *audioMuteButton; +@property (weak, nonatomic) IBOutlet UIButton *videoMuteButton; +@property (weak, nonatomic) IBOutlet UIButton *moreButtonForVoice; +@property (weak, nonatomic) IBOutlet UIButton *moreButtonForVideo; + +@property (weak, nonatomic) IBOutlet UIButton *backToAppButton; +@property (weak, nonatomic) IBOutlet UIButton *cameraSwitchButton; + +@property (unsafe_unretained, nonatomic) IBOutlet NSLayoutConstraint *localPreviewContainerViewLeadingConstraint; +@property (unsafe_unretained, nonatomic) IBOutlet NSLayoutConstraint *localPreviewContainerViewTopConstraint; +@property (unsafe_unretained, nonatomic) IBOutlet NSLayoutConstraint *localPreviewContainerViewHeightConstraint; +@property (unsafe_unretained, nonatomic) IBOutlet NSLayoutConstraint *localPreviewContainerViewWidthConstraint; + +@property (weak, nonatomic) IBOutlet UIButton *transferButton; + +/** + The default picture displayed when no picture is available. + */ +@property (nonatomic) UIImage *picturePlaceholder; + +/** + The call status bar displayed on the top of the app during a call. + */ +@property (nonatomic, readonly) UIWindow *backToAppStatusWindow; + +/** + Flag whether this call screen is displaying an alert right now. + */ +@property (nonatomic, readonly, getter=isDisplayingAlert) BOOL displayingAlert; + +/** + The current call + */ +@property (nonatomic) MXCall *mxCall; + +/** + The current call on hold + */ +@property (nonatomic) MXCall *mxCallOnHold; + +/** + The current peer + */ +@property (nonatomic, readonly) MXUser *peer; + +/** + The current peer of the call on hold + */ +@property (nonatomic, readonly) MXUser *peerOnHold; + +/** + The delegate. + */ +@property (nonatomic, weak) id delegate; + +/* + Specifies whether a ringtone must be played on incoming call. + It's important to set this value before you will set `mxCall` otherwise value of this property can has no effect. + + Defaults to YES. + */ +@property (nonatomic) BOOL playRingtone; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKCallViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `roomViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKCallViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + + @param call a MXCall instance. + @return An initialized `MXKRoomViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)callViewController:(MXCall *)call; + +/** + Return an audio file url based on the provided name. + + @param soundName audio file name without extension. + @return a NSURL instance. + */ +- (NSURL*)audioURLWithName:(NSString *)soundName; + +/** + Refresh the peer information in the call viewcontroller's view. + */ +- (void)updatePeerInfoDisplay; + +/** + Adjust the layout of the preview container. + */ +- (void)updateLocalPreviewLayout; + +/** + Show/Hide the overlay view. + + @param isShown tell whether the overlay is shown or not. + */ +- (void)showOverlayContainer:(BOOL)isShown; + +/** + Set up or teardown the promixity monitoring and enable/disable the idle timer according to call type, state & audio route. + */ +- (void)updateProximityAndSleep; + +/** + Prepare and return the optional view displayed during incoming call notification. + Return nil by default + + Subclasses may override this method to provide appropriate for their app view. + When this method is called peer and mxCall are valid so you can use them. + */ +- (UIView *)createIncomingCallView; + +/** + Action registered on the event 'UIControlEventTouchUpInside' for each UIButton instance. + */ +- (IBAction)onButtonPressed:(id)sender; + +/** + Default implementation presents an action sheet with proper options. Override to change the user interface. + */ +- (void)showAudioDeviceOptions; + +/** + Default implementation makes the button selected for loud speakers and external device options, non-selected for built-in device. + */ +- (void)configureSpeakerButton; + +#pragma mark - DTMF + +/** + Default implementation does nothing. Override to show a dial pad and then use MXCall methods to send DTMF tones. + */ +- (void)openDialpad; + +#pragma mark - Call Transfer + +/** + Default implementation does nothing. Override to show a contact selection screen and then use MXCallManager methods to start the transfer. + */ +- (void)openCallTransfer; + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKCallViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKCallViewController.m new file mode 100644 index 000000000..55781b106 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKCallViewController.m @@ -0,0 +1,1547 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKCallViewController.h" + +@import MatrixSDK; + +#import "MXKAppSettings.h" +#import "MXKSoundPlayer.h" +#import "MXKTools.h" +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +NSString *const kMXKCallViewControllerWillAppearNotification = @"kMXKCallViewControllerWillAppearNotification"; +NSString *const kMXKCallViewControllerAppearedNotification = @"kMXKCallViewControllerAppearedNotification"; +NSString *const kMXKCallViewControllerWillDisappearNotification = @"kMXKCallViewControllerWillDisappearNotification"; +NSString *const kMXKCallViewControllerDisappearedNotification = @"kMXKCallViewControllerDisappearedNotification"; +NSString *const kMXKCallViewControllerBackToAppNotification = @"kMXKCallViewControllerBackToAppNotification"; + +static const CGFloat kLocalPreviewMargin = 20; + +@interface MXKCallViewController () +{ + NSTimer *hideOverlayTimer; + NSTimer *updateStatusTimer; + + Boolean isMovingLocalPreview; + Boolean isSelectingLocalPreview; + + CGPoint startNewLocalMove; + + /** + The popup showed in case of call stack error. + */ + UIAlertController *errorAlert; + + // the room events listener + id roomListener; + + // Observe kMXRoomDidFlushDataNotification to take into account the updated room members when the room history is flushed. + id roomDidFlushDataNotificationObserver; + + // Observe AVAudioSessionRouteChangeNotification + id audioSessionRouteChangeNotificationObserver; + + // Current alert (if any). + UIAlertController *currentAlert; + + // Current peer display name + NSString *peerDisplayName; +} + +@property (nonatomic, assign) Boolean isRinging; + +@property (nonatomic, nullable) UIView *incomingCallView; + +@property (nonatomic, strong) UITapGestureRecognizer *onHoldCallContainerTapRecognizer; + +@end + +@implementation MXKCallViewController +@synthesize backgroundImageView; +@synthesize localPreviewContainerView, localPreviewVideoView, localPreviewActivityView, remotePreviewContainerView; +@synthesize overlayContainerView, callContainerView, callerImageView, callerNameLabel, callStatusLabel; +@synthesize callToolBar, rejectCallButton, answerCallButton, endCallButton; +@synthesize callControlContainerView, speakerButton, audioMuteButton, videoMuteButton; +@synthesize backToAppButton, cameraSwitchButton; +@synthesize backToAppStatusWindow; +@synthesize mxCall; +@synthesize mxCallOnHold; +@synthesize onHoldCallerImageView; +@synthesize onHoldCallContainerView; + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass(self.class) + bundle:[NSBundle bundleForClass:self.class]]; +} + ++ (instancetype)callViewController:(MXCall*)call +{ + MXKCallViewController *instance = [[[self class] alloc] initWithNibName:NSStringFromClass(self.class) + bundle:[NSBundle bundleForClass:self.class]]; + + // Load the view controller's view now (buttons and views will then be available). + if ([instance respondsToSelector:@selector(loadViewIfNeeded)]) + { + // iOS 9 and later + [instance loadViewIfNeeded]; + } + else if (instance.view) + { + // Patch: on iOS < 9.0, we load the view by calling its getter. + } + + instance.mxCall = call; + + return instance; +} + +#pragma mark - + +- (void)finalizeInit +{ + [super finalizeInit]; + + _playRingtone = YES; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + updateStatusTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(updateTimeStatusLabel) userInfo:nil repeats:YES]; + + self.callerImageView.defaultBackgroundColor = [UIColor clearColor]; + self.backToAppButton.backgroundColor = [UIColor clearColor]; + self.audioMuteButton.backgroundColor = [UIColor clearColor]; + self.videoMuteButton.backgroundColor = [UIColor clearColor]; + self.resumeButton.backgroundColor = [UIColor clearColor]; + self.moreButton.backgroundColor = [UIColor clearColor]; + self.speakerButton.backgroundColor = [UIColor clearColor]; + self.transferButton.backgroundColor = [UIColor clearColor]; + + [self.backToAppButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_backtoapp"] forState:UIControlStateNormal]; + [self.backToAppButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_backtoapp"] forState:UIControlStateHighlighted]; + [self.audioMuteButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_audio_unmute"] forState:UIControlStateNormal]; + [self.audioMuteButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_audio_mute"] forState:UIControlStateSelected]; + [self.videoMuteButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_video_unmute"] forState:UIControlStateNormal]; + [self.videoMuteButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_video_mute"] forState:UIControlStateSelected]; + [self.moreButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_call_more"] forState:UIControlStateNormal]; + [self.moreButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_call_more"] forState:UIControlStateSelected]; + [self.speakerButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_speaker_off"] forState:UIControlStateNormal]; + [self.speakerButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_speaker_on"] forState:UIControlStateSelected]; + + // Localize string + [answerCallButton setTitle:[MatrixKitL10n answerCall] forState:UIControlStateNormal]; + [answerCallButton setTitle:[MatrixKitL10n answerCall] forState:UIControlStateHighlighted]; + [rejectCallButton setTitle:[MatrixKitL10n rejectCall] forState:UIControlStateNormal]; + [rejectCallButton setTitle:[MatrixKitL10n rejectCall] forState:UIControlStateHighlighted]; + [endCallButton setTitle:[MatrixKitL10n endCall] forState:UIControlStateNormal]; + [endCallButton setTitle:[MatrixKitL10n endCall] forState:UIControlStateHighlighted]; + [_resumeButton setTitle:[MatrixKitL10n resumeCall] forState:UIControlStateNormal]; + [_resumeButton setTitle:[MatrixKitL10n resumeCall] forState:UIControlStateHighlighted]; + + // Refresh call information + self.mxCall = mxCall; + + // Listen to AVAudioSession activation notification if CallKit is available and enabled + BOOL isCallKitAvailable = [MXCallKitAdapter callKitAvailable] && [MXKAppSettings standardAppSettings].isCallKitEnabled; + if (isCallKitAvailable) + { + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleAudioSessionActivationNotification) + name:kMXCallKitAdapterAudioSessionDidActive + object:nil]; + } +} + +- (void)didReceiveMemoryWarning +{ + [super didReceiveMemoryWarning]; +} + +- (void)dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXCallKitAdapterAudioSessionDidActive object:nil]; + + [self removeObservers]; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKCallViewControllerWillAppearNotification object:nil]; + + [self updateLocalPreviewLayout]; + [self showOverlayContainer:YES]; + + if (mxCall) + { + // Refresh call display according to the call room state. + [self callRoomStateDidChange:^{ + // Refresh call status + [self call:self->mxCall stateDidChange:self->mxCall.state reason:nil]; + }]; + + } + + if (_delegate) + { + backToAppButton.hidden = NO; + } + else + { + backToAppButton.hidden = YES; + } +} + +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKCallViewControllerAppearedNotification object:nil]; + + // trick to hide the volume at launch + // as the mininum volume is forced by the application + // the volume popup can be displayed + // volumeView = [[MPVolumeView alloc] initWithFrame: CGRectMake(5000, 5000, 0, 0)]; + // [self.view addSubview: volumeView]; + // + // dispatch_after(dispatch_walltime(DISPATCH_TIME_NOW, 0.5 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ + // [volumeView removeFromSuperview]; + // }); +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKCallViewControllerWillDisappearNotification object:nil]; +} + +- (void)viewDidDisappear:(BOOL)animated +{ + [super viewDidDisappear:animated]; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKCallViewControllerDisappearedNotification object:nil]; +} + +- (void)dismiss +{ + if (_delegate) + { + [_delegate dismissCallViewController:self completion:nil]; + } + else + { + // Auto dismiss after few seconds + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self dismissViewControllerAnimated:YES completion:nil]; + }); + } +} + +#pragma mark - override MXKViewController + +- (void)destroy +{ + self.peer = nil; + + self.mxCall = nil; + + _delegate = nil; + + self.isRinging = NO; + + [hideOverlayTimer invalidate]; + [updateStatusTimer invalidate]; + + _incomingCallView = nil; + + _onHoldCallContainerTapRecognizer = nil; + + [super destroy]; +} + +#pragma mark - Properties + +- (UIImage *)picturePlaceholder +{ + return [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"default-profile"]; +} + +- (void)setMxCall:(MXCall *)call +{ + // Remove previous call (if any) + if (mxCall) + { + mxCall.delegate = nil; + mxCall.selfVideoView = nil; + mxCall.remoteVideoView = nil; + [self removeMatrixSession:self.mainSession]; + + [self removeObservers]; + + mxCall = nil; + } + + if (call && call.room) + { + mxCall = call; + + [self addMatrixSession:mxCall.room.mxSession]; + + MXWeakify(self); + + // Register a listener to handle messages related to room name, members... + roomListener = [mxCall.room listenToEventsOfTypes:@[kMXEventTypeStringRoomName, kMXEventTypeStringRoomTopic, kMXEventTypeStringRoomAliases, kMXEventTypeStringRoomAvatar, kMXEventTypeStringRoomCanonicalAlias, kMXEventTypeStringRoomMember] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + MXStrongifyAndReturnIfNil(self); + + // Consider only live events + if (self->mxCall && direction == MXTimelineDirectionForwards) + { + // The room state has been changed + [self callRoomStateDidChange:nil]; + } + }]; + + // Observe room history flush (sync with limited timeline, or state event redaction) + roomDidFlushDataNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXRoomDidFlushDataNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + MXStrongifyAndReturnIfNil(self); + + MXRoom *room = notif.object; + if (self->mxCall && self.mainSession == room.mxSession && [self->mxCall.room.roomId isEqualToString:room.roomId]) + { + // The existing room history has been flushed during server sync. + // Take into account the updated room state + [self callRoomStateDidChange:nil]; + } + + }]; + + audioSessionRouteChangeNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:AVAudioSessionRouteChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + [self updateProximityAndSleep]; + + }]; + + // Hide video mute on voice call + self.videoMuteButton.hidden = !call.isVideoCall; + + // Hide camera switch on voice call + self.cameraSwitchButton.hidden = !call.isVideoCall; + + _moreButtonForVideo.hidden = !call.isVideoCall; + _moreButtonForVoice.hidden = call.isVideoCall; + + // Observe call state change + call.delegate = self; + + // Display room call information + [self callRoomStateDidChange:^{ + [self call:call stateDidChange:call.state reason:nil]; + }]; + + if (call.isVideoCall && localPreviewContainerView) + { + // Access to the camera is mandatory to display the self view + // Check the permission right now + NSString *appDisplayName = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleDisplayName"]; + [MXKTools checkAccessForMediaType:AVMediaTypeVideo + manualChangeMessage:[MatrixKitL10n cameraAccessNotGrantedForCall:appDisplayName] + + showPopUpInViewController:self completionHandler:^(BOOL granted) { + + if (granted) + { + self->localPreviewContainerView.hidden = NO; + self->remotePreviewContainerView.hidden = NO; + + call.selfVideoView = self->localPreviewVideoView; + call.remoteVideoView = self->remotePreviewContainerView; + [self applyDeviceOrientation:YES]; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(deviceOrientationDidChange) + name:UIDeviceOrientationDidChangeNotification + object:nil]; + } + }]; + } + else + { + localPreviewContainerView.hidden = YES; + remotePreviewContainerView.hidden = YES; + } + } +} + +- (void)setMxCallOnHold:(MXCall *)callOnHold +{ + if (mxCallOnHold == callOnHold) + { + // setting same property, return + return; + } + + mxCallOnHold = callOnHold; + + if (mxCallOnHold) + { + self.onHoldCallContainerView.hidden = NO; + [self.onHoldCallContainerView addGestureRecognizer:self.onHoldCallContainerTapRecognizer]; + [self.onHoldCallContainerView setUserInteractionEnabled:YES]; + + // Handle peer here + if (mxCallOnHold.isIncoming) + { + self.peerOnHold = [mxCallOnHold.room.mxSession getOrCreateUser:mxCallOnHold.callerId]; + } + else + { + // For 1:1 call, find the other peer + // Else, the room information will be used to display information about the call + MXWeakify(self); + [mxCallOnHold.room state:^(MXRoomState *roomState) { + MXStrongifyAndReturnIfNil(self); + + MXUser *theMember = nil; + NSArray *members = roomState.members.joinedMembers; + for (MXUser *member in members) + { + if (![member.userId isEqualToString:self->mxCallOnHold.callerId]) + { + theMember = member; + break; + } + } + + self.peerOnHold = theMember; + }]; + } + } + else + { + [self.onHoldCallContainerView removeGestureRecognizer:self.onHoldCallContainerTapRecognizer]; + [self.onHoldCallContainerView setUserInteractionEnabled:NO]; + self.onHoldCallContainerView.hidden = YES; + self.peerOnHold = nil; + } +} + +- (void)setPeer:(MXUser *)peer +{ + _peer = peer; + + [self updatePeerInfoDisplay]; +} + +- (void)setPeerOnHold:(MXUser *)peerOnHold +{ + _peerOnHold = peerOnHold; + + NSString *peerAvatarURL; + + if (_peerOnHold) + { + peerAvatarURL = _peerOnHold.avatarUrl; + } + else if (mxCall.isConferenceCall) + { + peerAvatarURL = mxCallOnHold.room.summary.avatar; + } + + onHoldCallerImageView.imageView.contentMode = UIViewContentModeScaleAspectFill; + + if (peerAvatarURL) + { + // Suppose avatar url is a matrix content uri, we use SDK to get the well adapted thumbnail from server + onHoldCallerImageView.mediaFolder = kMXMediaManagerAvatarThumbnailFolder; + onHoldCallerImageView.enableInMemoryCache = YES; + [onHoldCallerImageView setImageURI:peerAvatarURL + withType:nil + andImageOrientation:UIImageOrientationUp + toFitViewSize:onHoldCallerImageView.frame.size + withMethod:MXThumbnailingMethodCrop + previewImage:self.picturePlaceholder + mediaManager:self.mainSession.mediaManager]; + } + else + { + onHoldCallerImageView.image = self.picturePlaceholder; + } +} + +- (void)updatePeerInfoDisplay +{ + NSString *peerAvatarURL; + + if (_peer) + { + peerDisplayName = [_peer displayname]; + if (!peerDisplayName.length) + { + peerDisplayName = _peer.userId; + } + peerAvatarURL = _peer.avatarUrl; + } + else if (mxCall.isConferenceCall) + { + peerDisplayName = mxCall.room.summary.displayname; + peerAvatarURL = mxCall.room.summary.avatar; + } + + if (mxCall.isConsulting) + { + callerNameLabel.text = [MatrixKitL10n callConsultingWithUser:peerDisplayName]; + } + else + { + if (mxCall.isVideoCall) + { + callerNameLabel.text = [MatrixKitL10n callVideoWithUser:peerDisplayName]; + } + else + { + callerNameLabel.text = [MatrixKitL10n callVoiceWithUser:peerDisplayName]; + } + } + + if (peerAvatarURL) + { + // Suppose avatar url is a matrix content uri, we use SDK to get the well adapted thumbnail from server + callerImageView.mediaFolder = kMXMediaManagerAvatarThumbnailFolder; + callerImageView.enableInMemoryCache = YES; + [callerImageView setImageURI:peerAvatarURL + withType:nil + andImageOrientation:UIImageOrientationUp + toFitViewSize:callerImageView.frame.size + withMethod:MXThumbnailingMethodCrop + previewImage:self.picturePlaceholder + mediaManager:self.mainSession.mediaManager]; + } + else + { + callerImageView.image = self.picturePlaceholder; + } + + // Round caller image view + [callerImageView.layer setCornerRadius:callerImageView.frame.size.width / 2]; + callerImageView.clipsToBounds = YES; +} + +- (void)setIsRinging:(Boolean)isRinging +{ + if (_isRinging != isRinging) + { + if (isRinging) + { + NSURL *audioUrl; + if (mxCall.isIncoming) + { + if (self.playRingtone) + audioUrl = [self audioURLWithName:@"ring"]; + } + else + { + audioUrl = [self audioURLWithName:@"ringback"]; + } + + if (audioUrl) + { + [[MXKSoundPlayer sharedInstance] playSoundAt:audioUrl repeat:YES vibrate:mxCall.isIncoming routeToBuiltInReceiver:!mxCall.isIncoming]; + } + } + else + { + [[MXKSoundPlayer sharedInstance] stopPlayingWithAudioSessionDeactivation:NO]; + } + + _isRinging = isRinging; + } +} + +- (void)setDelegate:(id)delegate +{ + _delegate = delegate; + + if (_delegate) + { + backToAppButton.hidden = NO; + } + else + { + backToAppButton.hidden = YES; + } +} + +- (UITapGestureRecognizer *)onHoldCallContainerTapRecognizer +{ + if (_onHoldCallContainerTapRecognizer == nil) + { + _onHoldCallContainerTapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(onHoldCallContainerTapped:)]; + } + return _onHoldCallContainerTapRecognizer; +} + +- (BOOL)isDisplayingAlert +{ + return errorAlert != nil; +} + +- (UIButton *)moreButton +{ + if (mxCall.isVideoCall) + { + return _moreButtonForVideo; + } + return _moreButtonForVoice; +} + +#pragma mark - Sounds + +- (NSURL *)audioURLWithName:(NSString *)soundName +{ + return [NSBundle mxk_audioURLFromMXKAssetsBundleWithName:soundName]; +} + +#pragma mark - Actions + +- (void)onHoldCallContainerTapped:(UITapGestureRecognizer *)recognizer +{ + if ([self.delegate respondsToSelector:@selector(callViewControllerDidTapOnHoldCall:)]) + { + [self.delegate callViewControllerDidTapOnHoldCall:self]; + } +} + +- (IBAction)onButtonPressed:(id)sender +{ + if (sender == answerCallButton) + { + // If we are here, we have access to the camera + // The following check is mainly to check microphone access permission + NSString *appDisplayName = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleDisplayName"]; + + [MXKTools checkAccessForCall:mxCall.isVideoCall + manualChangeMessageForAudio:[MatrixKitL10n microphoneAccessNotGrantedForCall:appDisplayName] + manualChangeMessageForVideo:[MatrixKitL10n cameraAccessNotGrantedForCall:appDisplayName] + showPopUpInViewController:self completionHandler:^(BOOL granted) { + + if (granted) + { + [self->mxCall answer]; + } + }]; + } + else if (sender == rejectCallButton || sender == endCallButton) + { + if (mxCall.state != MXCallStateEnded) + { + [mxCall hangup]; + } + else + { + [self dismiss]; + } + } + else if (sender == audioMuteButton) + { + mxCall.audioMuted = !mxCall.audioMuted; + audioMuteButton.selected = mxCall.audioMuted; + } + else if (sender == videoMuteButton) + { + mxCall.videoMuted = !mxCall.videoMuted; + videoMuteButton.selected = mxCall.videoMuted; + } + else if (sender == _resumeButton) + { + [mxCall hold:NO]; + } + else if (sender == self.moreButton) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + + MXWeakify(self); + + NSMutableArray *actions = [NSMutableArray arrayWithCapacity:4]; + + if (self.speakerButton == nil) + { + // audio device action + UIAlertAction *audioDeviceAction = [UIAlertAction actionWithTitle:[MatrixKitL10n callMoreActionsChangeAudioDevice] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + self->currentAlert = nil; + [self showAudioDeviceOptions]; + + }]; + + [actions addObject:audioDeviceAction]; + } + + // check the call can be up/downgraded + + // check the call can send DTMF tones + if (self.mxCall.supportsDTMF) + { + UIAlertAction *dialpadAction = [UIAlertAction actionWithTitle:[MatrixKitL10n callMoreActionsDialpad] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + self->currentAlert = nil; + [self openDialpad]; + + }]; + + [actions addObject:dialpadAction]; + } + + // check the call be holded/unholded + if (mxCall.supportsHolding) + { + NSString *actionLocKey = (mxCall.state == MXCallStateOnHold) ? [MatrixKitL10n callMoreActionsUnhold] : [MatrixKitL10n callMoreActionsHold]; + + UIAlertAction *holdAction = [UIAlertAction actionWithTitle:actionLocKey + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + self->currentAlert = nil; + [self->mxCall hold:(self.mxCall.state != MXCallStateOnHold)]; + + }]; + + [actions addObject:holdAction]; + } + + // check the call be transferred + if (mxCall.supportsTransferring && self.peer) + { + UIAlertAction *transferAction = [UIAlertAction actionWithTitle:[MatrixKitL10n callMoreActionsTransfer] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + self->currentAlert = nil; + + [self openCallTransfer]; + }]; + + [actions addObject:transferAction]; + } + + if (actions.count > 0) + { + // create the alert + currentAlert = [UIAlertController alertControllerWithTitle:nil + message:nil + preferredStyle:UIAlertControllerStyleActionSheet]; + + // add actions + [actions enumerateObjectsUsingBlock:^(UIAlertAction * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + [currentAlert addAction:obj]; + }]; + + // add cancel action always + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleCancel + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + self->currentAlert = nil; + + }]]; + + [currentAlert popoverPresentationController].sourceView = self.moreButton; + [currentAlert popoverPresentationController].sourceRect = self.moreButton.bounds; + [self presentViewController:currentAlert animated:YES completion:nil]; + } + } + else if (sender == speakerButton) + { + [self showAudioDeviceOptions]; + } + else if (sender == cameraSwitchButton) + { + switch (mxCall.cameraPosition) + { + case AVCaptureDevicePositionFront: + mxCall.cameraPosition = AVCaptureDevicePositionBack; + break; + + default: + mxCall.cameraPosition = AVCaptureDevicePositionFront; + break; + } + } + else if (sender == backToAppButton) + { + if (_delegate) + { + // Dismiss the view controller whereas the call is still running + [_delegate dismissCallViewController:self completion:nil]; + } + } + else if (sender == _transferButton) + { + // actually transfer the call without consulting + [self.mainSession.callManager transferCall:mxCall.callWithTransferee + to:mxCall.transferTarget + withTransferee:mxCall.transferee + consultFirst:NO + success:^(NSString * _Nullable newCallId) { + + } + failure:^(NSError * _Nullable error) { + + }]; + } + + [self updateProximityAndSleep]; +} + +- (void)showAudioDeviceOptions +{ + NSMutableArray *actions = [NSMutableArray new]; + NSArray *availableRoutes = mxCall.audioOutputRouter.availableOutputRoutes; + + for (MXiOSAudioOutputRoute *route in availableRoutes) + { + // route action + NSString *name = route.name; + if (route.routeType == MXiOSAudioOutputRouteTypeLoudSpeakers) + { + name = [MatrixKitL10n callMoreActionsAudioUseDevice]; + } + MXWeakify(self); + UIAlertAction *routeAction = [UIAlertAction actionWithTitle:name + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + self->currentAlert = nil; + [self->mxCall.audioOutputRouter changeCurrentRouteTo:route]; + + }]; + + [actions addObject:routeAction]; + } + + if (actions.count > 0) + { + // create the alert + currentAlert = [UIAlertController alertControllerWithTitle:nil + message:nil + preferredStyle:UIAlertControllerStyleActionSheet]; + + for (UIAlertAction *action in actions) + { + [currentAlert addAction:action]; + } + + // add cancel action + MXWeakify(self); + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleCancel + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + self->currentAlert = nil; + + }]]; + + [currentAlert popoverPresentationController].sourceView = self.moreButton; + [currentAlert popoverPresentationController].sourceRect = self.moreButton.bounds; + [self presentViewController:currentAlert animated:YES completion:nil]; + } +} + +#pragma mark - DTMF + +- (void)openDialpad +{ + // no-op +} + +#pragma mark - Call Transfer + +- (void)openCallTransfer +{ + // no-op +} + +#pragma mark - MXCallDelegate + +- (void)call:(MXCall *)call stateDidChange:(MXCallState)state reason:(MXEvent *)event +{ + // Set default configuration of bottom bar + endCallButton.hidden = NO; + rejectCallButton.hidden = YES; + answerCallButton.hidden = YES; + self.moreButton.enabled = YES; + _resumeButton.hidden = state != MXCallStateOnHold; + _pausedIcon.hidden = state != MXCallStateOnHold && state != MXCallStateRemotelyOnHold; + _transferButton.hidden = YES; + + [localPreviewActivityView stopAnimating]; + + switch (state) + { + case MXCallStateFledgling: + self.isRinging = NO; + callStatusLabel.text = [MatrixKitL10n callConnecting]; + break; + case MXCallStateWaitLocalMedia: + self.isRinging = NO; + [self configureSpeakerButton]; + [localPreviewActivityView startAnimating]; + + // Try to show a special view for incoming view + [self configureIncomingCallViewIfRequiredWith:call]; + + break; + case MXCallStateCreateOffer: + { + // When CallKit is enabled and we have an outgoing call, we need to start playing ringback sound + // only after AVAudioSession will be activated by the system otherwise the sound will be gone. + // We always receive signal about MXCallStateCreateOffer earlier than the system activates AVAudioSession + // so we start playing ringback sound only on AVAudioSession activation in handleAudioSessionActivationNotification + BOOL isCallKitAvailable = [MXCallKitAdapter callKitAvailable] && [MXKAppSettings standardAppSettings].isCallKitEnabled; + if (!isCallKitAvailable) + { + self.isRinging = YES; + } + + callStatusLabel.text = [MatrixKitL10n callConnecting]; + break; + } + case MXCallStateInviteSent: + { + callStatusLabel.text = [MatrixKitL10n callRinging]; + break; + } + case MXCallStateRinging: + self.isRinging = YES; + [self configureSpeakerButton]; + if (call.isVideoCall) + { + callStatusLabel.text = [MatrixKitL10n incomingVideoCall]; + } + else + { + callStatusLabel.text = [MatrixKitL10n incomingVoiceCall]; + } + // Update bottom bar + endCallButton.hidden = YES; + rejectCallButton.hidden = NO; + answerCallButton.hidden = NO; + + // Try to show a special view for incoming view + [self configureIncomingCallViewIfRequiredWith:call]; + + break; + case MXCallStateConnecting: + self.isRinging = NO; + + // User has accepted the call and we can remove incomingCallView + if (self.incomingCallView) + { + [UIView transitionWithView:self.view + duration:0.33 + options:UIViewAnimationOptionTransitionCrossDissolve | UIViewAnimationOptionCurveEaseOut + animations:^{ + [self.incomingCallView removeFromSuperview]; + } + completion:^(BOOL finished) { + self.incomingCallView = nil; + }]; + } + + break; + case MXCallStateConnected: + self.isRinging = NO; + [self updateTimeStatusLabel]; + + if (call.isVideoCall) + { + self.callerImageView.hidden = YES; + + if (call.isConferenceCall) + { + // Do not show self view anymore because it is returned by the conference bridge + self.localPreviewContainerView.hidden = YES; + + // Well, hide does not work. So, shrink the view to nil + self.localPreviewContainerView.frame = CGRectZero; + } + } + audioMuteButton.enabled = YES; + videoMuteButton.enabled = YES; + speakerButton.enabled = YES; + cameraSwitchButton.enabled = YES; + if (call.isConsulting) + { + _transferButton.hidden = NO; + } + + break; + case MXCallStateOnHold: + callStatusLabel.text = [MatrixKitL10n callHolded]; + + break; + case MXCallStateRemotelyOnHold: + audioMuteButton.enabled = NO; + videoMuteButton.enabled = NO; + speakerButton.enabled = NO; + cameraSwitchButton.enabled = NO; + self.moreButton.enabled = NO; + callStatusLabel.text = [MatrixKitL10n callRemoteHolded:peerDisplayName]; + + break; + case MXCallStateInviteExpired: + // MXCallStateInviteExpired state is sent as an notification + // MXCall will move quickly to the MXCallStateEnded state + self.isRinging = NO; + callStatusLabel.text = [MatrixKitL10n callInviteExpired]; + + break; + case MXCallStateEnded: + { + self.isRinging = NO; + callStatusLabel.text = [MatrixKitL10n callEnded]; + + NSString *soundName = [self soundNameForCallEnding]; + if (soundName) + { + NSURL *audioUrl = [self audioURLWithName:soundName]; + [[MXKSoundPlayer sharedInstance] playSoundAt:audioUrl repeat:NO vibrate:NO routeToBuiltInReceiver:YES]; + } + else + { + [[MXKSoundPlayer sharedInstance] stopPlayingWithAudioSessionDeactivation:YES]; + } + + // Except in case of call error, quit the screen right now + if (!errorAlert) + { + [self dismiss]; + } + + break; + } + default: + break; + } + + [self updateProximityAndSleep]; +} + +- (void)call:(MXCall *)call didEncounterError:(NSError *)error reason:(MXCallHangupReason)reason +{ + MXLogDebug(@"[MXKCallViewController] didEncounterError. mxCall.state: %tu. Stop call due to error: %@", mxCall.state, error); + + if (mxCall.state != MXCallStateEnded) + { + // Popup the error to the user + NSString *title = [error.userInfo valueForKey:NSLocalizedFailureReasonErrorKey]; + if (!title) + { + title = [MatrixKitL10n error]; + } + NSString *msg = [error.userInfo valueForKey:NSLocalizedDescriptionKey]; + if (!msg) + { + msg = [MatrixKitL10n errorCommonMessage]; + } + + MXWeakify(self); + errorAlert = [UIAlertController alertControllerWithTitle:title message:msg preferredStyle:UIAlertControllerStyleAlert]; + + [errorAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + self->errorAlert = nil; + [self dismiss]; + + }]]; + + [self presentViewController:errorAlert animated:YES completion:nil]; + + // And interrupt the call + [mxCall hangupWithReason:reason]; + } +} + +- (void)callConsultingStatusDidChange:(MXCall *)call +{ + [self updatePeerInfoDisplay]; + + if (call.isConsulting) + { + NSString *title = [MatrixKitL10n callTransferToUser:call.transferee.displayname]; + [_transferButton setTitle:title forState:UIControlStateNormal]; + _transferButton.hidden = call.state != MXCallStateConnected; + } + else + { + _transferButton.hidden = YES; + } +} + +- (void)callAssertedIdentityDidChange:(MXCall *)call +{ + MXAssertedIdentityModel *assertedIdentity = call.assertedIdentity; + + if (assertedIdentity) + { + // update caller display name and avatar with the asserted identity + NSString *peerAvatarURL = assertedIdentity.avatarUrl; + + if (assertedIdentity.displayname) + { + peerDisplayName = assertedIdentity.displayname; + } + else if (assertedIdentity.userId) + { + peerDisplayName = assertedIdentity.userId; + } + + if (mxCall.isVideoCall) + { + callerNameLabel.text = [MatrixKitL10n callVideoWithUser:peerDisplayName]; + } + else + { + callerNameLabel.text = [MatrixKitL10n callVoiceWithUser:peerDisplayName]; + } + + if (peerAvatarURL) + { + // Suppose avatar url is a matrix content uri, we use SDK to get the well adapted thumbnail from server + callerImageView.mediaFolder = kMXMediaManagerAvatarThumbnailFolder; + callerImageView.enableInMemoryCache = YES; + [callerImageView setImageURI:peerAvatarURL + withType:nil + andImageOrientation:UIImageOrientationUp + toFitViewSize:callerImageView.frame.size + withMethod:MXThumbnailingMethodCrop + previewImage:self.picturePlaceholder + mediaManager:self.mainSession.mediaManager]; + } + else + { + callerImageView.image = self.picturePlaceholder; + } + + [updateStatusTimer fire]; + } + else + { + // go back to the original display name and avatar + [self updatePeerInfoDisplay]; + } +} + +- (void)callAudioOutputRouteTypeDidChange:(MXCall *)call +{ + [self configureSpeakerButton]; +} + +- (void)callAvailableAudioOutputsDidChange:(MXCall *)call +{ + +} + +#pragma mark - Internal + +- (void)removeObservers +{ + if (roomDidFlushDataNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:roomDidFlushDataNotificationObserver]; + roomDidFlushDataNotificationObserver = nil; + } + + if (audioSessionRouteChangeNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:audioSessionRouteChangeNotificationObserver]; + audioSessionRouteChangeNotificationObserver = nil; + } + + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + if (roomListener && mxCall.room) + { + MXWeakify(self); + [mxCall.room liveTimeline:^(MXEventTimeline *liveTimeline) { + MXStrongifyAndReturnIfNil(self); + + [liveTimeline removeListener:self->roomListener]; + self->roomListener = nil; + }]; + } +} + +- (void)callRoomStateDidChange:(dispatch_block_t)onComplete +{ + // Handle peer here + if (mxCall.isIncoming) + { + self.peer = [mxCall.room.mxSession getOrCreateUser:mxCall.callerId]; + if (onComplete) + { + onComplete(); + } + } + else + { + // For 1:1 call, find the other peer + // Else, the room information will be used to display information about the call + if (!mxCall.isConferenceCall) + { + MXWeakify(self); + [mxCall.room state:^(MXRoomState *roomState) { + MXStrongifyAndReturnIfNil(self); + + MXUser *theMember = nil; + NSArray *members = roomState.members.joinedMembers; + for (MXUser *member in members) + { + if (![member.userId isEqualToString:self->mxCall.callerId]) + { + theMember = member; + break; + } + } + + self.peer = theMember; + if (onComplete) + { + onComplete(); + } + }]; + } + else + { + self.peer = nil; + if (onComplete) + { + onComplete(); + } + } + } +} + +- (BOOL)isBuiltInReceiverAudioOuput +{ +#if TARGET_IPHONE_SIMULATOR + return YES; +#endif + BOOL isBuiltInReceiverUsed = NO; + + // Check whether the audio output is the built-in receiver + AVAudioSessionRouteDescription *audioRoute = [[AVAudioSession sharedInstance] currentRoute]; + if (audioRoute.outputs.count) + { + // TODO: handle the case where multiple outputs are returned + AVAudioSessionPortDescription *audioOutputs = audioRoute.outputs.firstObject; + isBuiltInReceiverUsed = ([audioOutputs.portType isEqualToString:AVAudioSessionPortBuiltInReceiver]); + } + + return isBuiltInReceiverUsed; +} + +- (NSString *)soundNameForCallEnding +{ + if (mxCall.endReason == MXCallEndReasonUnknown) + return nil; + + if (mxCall.isEstablished) + return @"callend"; + + if (mxCall.endReason == MXCallEndReasonBusy || (!mxCall.isIncoming && mxCall.endReason == MXCallEndReasonMissed)) + return @"busy"; + + return nil; +} + +- (void)handleAudioSessionActivationNotification +{ + // It's only relevant for outgoing calls which aren't in connected state + if (self.mxCall.state >= MXCallStateCreateOffer && self.mxCall.state != MXCallStateConnected && self.mxCall.state != MXCallStateEnded) + { + self.isRinging = YES; + } +} + +#pragma mark - UI methods + +- (void)configureSpeakerButton +{ + switch (mxCall.audioOutputRouter.currentRoute.routeType) + { + case MXiOSAudioOutputRouteTypeBuiltIn: + self.speakerButton.selected = NO; + break; + case MXiOSAudioOutputRouteTypeLoudSpeakers: + case MXiOSAudioOutputRouteTypeExternalWired: + case MXiOSAudioOutputRouteTypeExternalBluetooth: + case MXiOSAudioOutputRouteTypeExternalCar: + self.speakerButton.selected = YES; + break; + } +} + +- (void)configureIncomingCallViewIfRequiredWith:(MXCall *)call +{ + if (call.isIncoming && !self.incomingCallView) + { + UIView *incomingCallView = [self createIncomingCallView]; + if (incomingCallView) + { + self.incomingCallView = incomingCallView; + [self.view addSubview:incomingCallView]; + + incomingCallView.translatesAutoresizingMaskIntoConstraints = NO; + [incomingCallView.topAnchor constraintEqualToAnchor:self.view.topAnchor constant:0].active = YES; + [incomingCallView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:0].active = YES; + [incomingCallView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor constant:0].active = YES; + [incomingCallView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:0].active = YES; + } + } +} + +- (void)updateLocalPreviewLayout +{ + // On IOS 8 and later, the screen size is oriented. + CGRect bounds = [[UIScreen mainScreen] bounds]; + BOOL isLandscapeOriented = (bounds.size.width > bounds.size.height); + + CGFloat maxPreviewFrameSize, minPreviewFrameSize; + + if (_localPreviewContainerViewWidthConstraint.constant < _localPreviewContainerViewHeightConstraint.constant) + { + maxPreviewFrameSize = _localPreviewContainerViewHeightConstraint.constant; + minPreviewFrameSize = _localPreviewContainerViewWidthConstraint.constant; + } + else + { + minPreviewFrameSize = _localPreviewContainerViewHeightConstraint.constant; + maxPreviewFrameSize = _localPreviewContainerViewWidthConstraint.constant; + } + + if (isLandscapeOriented) + { + _localPreviewContainerViewHeightConstraint.constant = minPreviewFrameSize; + _localPreviewContainerViewWidthConstraint.constant = maxPreviewFrameSize; + } + else + { + _localPreviewContainerViewHeightConstraint.constant = maxPreviewFrameSize; + _localPreviewContainerViewWidthConstraint.constant = minPreviewFrameSize; + } + + CGPoint previewOrigin = self.localPreviewContainerView.frame.origin; + + if (previewOrigin.x != (bounds.size.width - _localPreviewContainerViewWidthConstraint.constant - kLocalPreviewMargin)) + { + CGFloat posX = (bounds.size.width - _localPreviewContainerViewWidthConstraint.constant - kLocalPreviewMargin); + _localPreviewContainerViewLeadingConstraint.constant = posX; + } + + if (previewOrigin.y != kLocalPreviewMargin) + { + CGFloat posY = (bounds.size.height - _localPreviewContainerViewHeightConstraint.constant - kLocalPreviewMargin); + _localPreviewContainerViewTopConstraint.constant = posY; + } +} + +- (void)showOverlayContainer:(BOOL)isShown +{ + if (mxCall && !mxCall.isVideoCall) isShown = YES; + if (mxCall.state != MXCallStateConnected) isShown = YES; + + if (isShown) + { + overlayContainerView.hidden = NO; + if (mxCall && mxCall.isVideoCall) + { + [hideOverlayTimer invalidate]; + hideOverlayTimer = [NSTimer scheduledTimerWithTimeInterval:5.0 target:self selector:@selector(hideOverlay:) userInfo:nil repeats:NO]; + } + } + else + { + overlayContainerView.hidden = YES; + } +} + +- (void)toggleOverlay +{ + [self showOverlayContainer:overlayContainerView.isHidden]; +} + +- (void)hideOverlay:(NSTimer*)theTimer +{ + [self showOverlayContainer:NO]; + hideOverlayTimer = nil; +} + +- (void)updateTimeStatusLabel +{ + if (mxCall.state == MXCallStateConnected) + { + NSUInteger duration = mxCall.duration / 1000; + NSUInteger secs = duration % 60; + NSUInteger mins = (duration - secs) / 60; + callStatusLabel.text = [NSString stringWithFormat:@"%02tu:%02tu", mins, secs]; + } +} + +- (void)updateProximityAndSleep +{ + BOOL inCall = (mxCall.state == MXCallStateConnected || mxCall.state == MXCallStateRinging || mxCall.state == MXCallStateInviteSent || mxCall.state == MXCallStateConnecting || mxCall.state == MXCallStateCreateOffer || mxCall.state == MXCallStateCreateAnswer); + + if (inCall) + { + BOOL isBuiltInReceiverUsed = self.isBuiltInReceiverAudioOuput; + + // Enable the proximity monitoring when the built in receiver is used as the audio output. + BOOL enableProxMonitoring = isBuiltInReceiverUsed; + [[UIDevice currentDevice] setProximityMonitoringEnabled:enableProxMonitoring]; + + // Disable the idle timer during a video call, or during a voice call which is performed with the built-in receiver. + // Note: if the device is locked, VoIP calling get dropped if an incoming GSM call is received. + BOOL disableIdleTimer = mxCall.isVideoCall || isBuiltInReceiverUsed; + + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication) + { + sharedApplication.idleTimerDisabled = disableIdleTimer; + } + } +} + +- (UIView *)createIncomingCallView +{ + return nil; +} + +#pragma mark - UIResponder Touch Events + +- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event +{ + UITouch *touch = [touches anyObject]; + CGPoint point = [touch locationInView:self.view]; + if ((!self.localPreviewContainerView.hidden) && CGRectContainsPoint(self.localPreviewContainerView.frame, point)) + { + // Starting to move the local preview view + if (mxCallOnHold) + { + // if there is a call on hold, do not move local preview for now + // TODO: Instead of wholly avoiding mobility of local preview, just avoid the on hold call's corner here + return; + } + isSelectingLocalPreview = YES; + } +} + +- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event +{ + isMovingLocalPreview = NO; + isSelectingLocalPreview = NO; +} + +- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event +{ + if (isMovingLocalPreview) + { + UITouch *touch = [touches anyObject]; + CGPoint point = [touch locationInView:self.view]; + + CGRect bounds = self.view.bounds; + CGFloat midX = bounds.size.width / 2.0; + CGFloat midY = bounds.size.height / 2.0; + + CGFloat posX = (point.x < midX) ? 20.0 : (bounds.size.width - _localPreviewContainerViewWidthConstraint.constant - 20.0); + CGFloat posY = (point.y < midY) ? 20.0 : (bounds.size.height - _localPreviewContainerViewHeightConstraint.constant - 20.0); + + _localPreviewContainerViewLeadingConstraint.constant = posX; + _localPreviewContainerViewTopConstraint.constant = posY; + + [self.view setNeedsUpdateConstraints]; + } + else + { + [self toggleOverlay]; + } + isMovingLocalPreview = NO; + isSelectingLocalPreview = NO; +} + +- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event +{ + UITouch *touch = [touches anyObject]; + CGPoint point = [touch locationInView:self.view]; + + if (isSelectingLocalPreview) + { + isMovingLocalPreview = YES; + self.localPreviewContainerView.center = point; + } +} + +#pragma mark - UIDeviceOrientationDidChangeNotification + +- (void)deviceOrientationDidChange +{ + [self applyDeviceOrientation:NO]; + + [self showOverlayContainer:YES]; +} + +- (void)applyDeviceOrientation:(BOOL)forcePortrait +{ + if (mxCall) + { + UIDeviceOrientation deviceOrientation = [[UIDevice currentDevice] orientation]; + + // Set the camera orientation according to the orientation supported by the app + if (UIDeviceOrientationPortrait == deviceOrientation || UIDeviceOrientationLandscapeLeft == deviceOrientation || UIDeviceOrientationLandscapeRight == deviceOrientation) + { + mxCall.selfOrientation = deviceOrientation; + [self updateLocalPreviewLayout]; + } + else if (forcePortrait) + { + mxCall.selfOrientation = UIDeviceOrientationPortrait; + [self updateLocalPreviewLayout]; + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKCallViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKCallViewController.xib new file mode 100644 index 000000000..52d4fc0ed --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKCallViewController.xib @@ -0,0 +1,423 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.h new file mode 100644 index 000000000..417f586a7 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.h @@ -0,0 +1,90 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKTableViewController.h" + +#import "MXKContact.h" + +@class MXKContactDetailsViewController; + +/** + `MXKContactDetailsViewController` delegate. + */ +@protocol MXKContactDetailsViewControllerDelegate + +/** + Tells the delegate that the user wants to start chat with the contact by using the selected matrix id. + + @param contactDetailsViewController the `MXKContactDetailsViewController` instance. + @param matrixId the selected matrix id of the contact. + @param completion the block to execute at the end of the operation (independently if it succeeded or not). + */ +- (void)contactDetailsViewController:(MXKContactDetailsViewController *)contactDetailsViewController startChatWithMatrixId:(NSString*)matrixId completion:(void (^)(void))completion; + +@end + +@interface MXKContactDetailsViewController : MXKTableViewController + +@property (weak, nonatomic) IBOutlet UIButton *contactThumbnail; +@property (weak, nonatomic) IBOutlet UITextView *contactDisplayName; + +/** + The default account picture displayed when no picture is defined. + */ +@property (nonatomic) UIImage *picturePlaceholder; + +/** + The displayed contact + */ +@property (strong, nonatomic) MXKContact* contact; + +/** + The delegate for the view controller. + */ +@property (nonatomic, weak) id delegate; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKContactDetailsViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `contactDetailsViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKContactDetailsViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKContactDetailsViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)contactDetailsViewController; + +/** + The contact's thumbnail is displayed inside a button. The following action is registered on + `UIControlEventTouchUpInside` event of this button. + */ +- (IBAction)onContactThumbnailPressed:(id)sender; + +@end + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.m new file mode 100644 index 000000000..6f181b059 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.m @@ -0,0 +1,207 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKContactDetailsViewController.h" + +#import "MXKTableViewCellWithLabelAndButton.h" + +#import "NSBundle+MatrixKit.h" +#import "MXKSwiftHeader.h" + +@interface MXKContactDetailsViewController () +{ + NSArray* matrixIDs; +} + +@end + +@implementation MXKContactDetailsViewController + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKContactDetailsViewController class]) + bundle:[NSBundle bundleForClass:[MXKContactDetailsViewController class]]]; +} + ++ (instancetype)contactDetailsViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKContactDetailsViewController class]) + bundle:[NSBundle bundleForClass:[MXKContactDetailsViewController class]]]; +} + +- (void)finalizeInit +{ + [super finalizeInit]; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!_contactThumbnail) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + [self updatePictureButton:self.picturePlaceholder]; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onThumbnailUpdate:) name:kMXKContactThumbnailUpdateNotification object:nil]; + + // Force refresh + self.contact = _contact; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +- (void)destroy +{ + matrixIDs = nil; + + self.delegate = nil; + + [super destroy]; +} + +#pragma mark - + +- (void)setContact:(MXKContact *)contact +{ + _contact = contact; + + self.contactDisplayName.text = _contact.displayName; + + // set the thumbnail info + [self.contactThumbnail.imageView setContentMode: UIViewContentModeScaleAspectFill]; + [self.contactThumbnail.imageView setClipsToBounds:YES]; + + if (_contact.thumbnail) + { + [self updatePictureButton:_contact.thumbnail]; + } + else + { + [self updatePictureButton:self.picturePlaceholder]; + } +} + +- (UIImage*)picturePlaceholder +{ + return [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"default-profile"]; +} + +- (IBAction)onContactThumbnailPressed:(id)sender +{ + // Do nothing by default +} + +#pragma mark - UITableView datasource + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView +{ + return 1; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + matrixIDs = _contact.matrixIdentifiers; + return matrixIDs.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + NSInteger row = indexPath.row; + + MXKTableViewCellWithLabelAndButton *cell = [tableView dequeueReusableCellWithIdentifier:[MXKTableViewCellWithLabelAndButton defaultReuseIdentifier]]; + if (!cell) + { + cell = [[MXKTableViewCellWithLabelAndButton alloc] init]; + } + + if (row < matrixIDs.count) + { + cell.mxkLabel.text = [matrixIDs objectAtIndex:row]; + } + else + { + // should never happen + cell.mxkLabel.text = @""; + } + + [cell.mxkButton setTitle:[MatrixKitL10n startChat] forState:UIControlStateNormal]; + [cell.mxkButton setTitle:[MatrixKitL10n startChat] forState:UIControlStateHighlighted]; + cell.mxkButton.tag = row; + [cell.mxkButton addTarget:self action:@selector(startChat:) forControlEvents:UIControlEventTouchUpInside]; + + return cell; +} + +#pragma mark - Internals + +- (void)updatePictureButton:(UIImage*)image +{ + [self.contactThumbnail setImage:image forState:UIControlStateNormal]; + [self.contactThumbnail setImage:image forState:UIControlStateHighlighted]; + [self.contactThumbnail setImage:image forState:UIControlStateDisabled]; +} + +- (void)startChat:(UIButton*)sender +{ + if (self.delegate && sender.tag < matrixIDs.count) + { + sender.enabled = NO; + + [self.delegate contactDetailsViewController:self startChatWithMatrixId:[matrixIDs objectAtIndex:sender.tag] completion:^{ + + sender.enabled = YES; + + }]; + } +} + +- (void)onThumbnailUpdate:(NSNotification *)notif +{ + // sanity check + if ([notif.object isKindOfClass:[NSString class]]) + { + NSString* contactID = notif.object; + + if ([contactID isEqualToString:self.contact.contactID]) + { + if (_contact.thumbnail) + { + [self updatePictureButton:_contact.thumbnail]; + } + else + { + [self updatePictureButton:self.picturePlaceholder]; + } + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.xib new file mode 100644 index 000000000..d202e2856 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.xib @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.h new file mode 100644 index 000000000..e516aa15d --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.h @@ -0,0 +1,122 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKTableViewController.h" + +#import "MXKContactManager.h" +#import "MXKContact.h" +#import "MXKContactTableCell.h" + +@class MXKContactListViewController; + +/** + `MXKContactListViewController` delegate. + */ +@protocol MXKContactListViewControllerDelegate + +/** + Tells the delegate that the user selected a contact. + + @param contactListViewController the `MXKContactListViewController` instance. + @param contactId the id of the selected contact. + */ +- (void)contactListViewController:(MXKContactListViewController *)contactListViewController didSelectContact:(NSString*)contactId; + +/** + Tells the delegate that the user tapped a contact thumbnail. + + @param contactListViewController the `MXKContactListViewController` instance. + @param contactId the id of the tapped contact. + */ +- (void)contactListViewController:(MXKContactListViewController *)contactListViewController didTapContactThumbnail:(NSString*)contactId; + +@end + +/** + 'MXKContactListViewController' instance displays constact list. + This view controller support multi sessions by collecting all matrix users (only one occurrence is kept by user). + */ +@interface MXKContactListViewController : MXKTableViewController + +/** + The segmented control used to handle separatly matrix users and local contacts. + User's actions are handled by [MXKContactListViewController onSegmentValueChange:]. + */ +@property (weak, nonatomic) IBOutlet UISegmentedControl* contactsControls; + +/** + The delegate for the view controller. + */ +@property (nonatomic, weak) id delegate; + +/** + Enable the search option by adding a navigation item in the navigation bar (YES by default). + Set NO this property to disable this option and hide the related bar button. + */ +@property (nonatomic) BOOL enableBarButtonSearch; + +/** + Tell whether an action is already in progress. + */ +@property (nonatomic, readonly) BOOL hasPendingAction; + +/** + The class used in creating new contact table cells. + Only MXKContactTableCell classes or sub-classes are accepted. + */ +@property (nonatomic) Class contactTableViewCellClass; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKContactListViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `contactListViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKContactListViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKContactListViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)contactListViewController; + +/** + The action registered on 'value changed' event of the 'UISegmentedControl' contactControls. + */ +- (IBAction)onSegmentValueChange:(id)sender; + +/** + Add a mask in overlay to prevent a new contact selection (used when an action is on progress). + */ +- (void)addPendingActionMask; + +/** + Remove the potential overlay mask + */ +- (void)removePendingActionMask; + +@end + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.m new file mode 100644 index 000000000..6bcb30b42 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.m @@ -0,0 +1,663 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKContactListViewController.h" + +#import "MXKSectionedContacts.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@interface MXKContactListViewController () +{ + // YES -> only matrix users + // NO -> display local contacts + BOOL displayMatrixUsers; + + // screenshot of the local contacts + NSArray* localContactsArray; + MXKSectionedContacts* sectionedLocalContacts; + + // screenshot of the matrix users + NSArray* matrixContactsArray; + MXKSectionedContacts* sectionedMatrixContacts; + + // Search + UIBarButtonItem *searchButton; + UISearchBar *contactsSearchBar; + NSMutableArray *filteredContacts; + MXKSectionedContacts* sectionedFilteredContacts; + BOOL searchBarShouldEndEditing; + BOOL ignoreSearchRequest; + NSString* latestSearchedPattern; + + NSArray* collationTitles; + + // mask view while processing a request + UIActivityIndicatorView * pendingMaskSpinnerView; +} + +@end + +@implementation MXKContactListViewController + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKContactListViewController class]) + bundle:[NSBundle bundleForClass:[MXKContactListViewController class]]]; +} + ++ (instancetype)contactListViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKContactListViewController class]) + bundle:[NSBundle bundleForClass:[MXKContactListViewController class]]]; +} + +- (void)finalizeInit +{ + [super finalizeInit]; + + _enableBarButtonSearch = YES; + + // get the system collation titles + collationTitles = [[UILocalizedIndexedCollation currentCollation] sectionTitles]; +} + +- (void)dealloc +{ + searchButton = nil; +} + +- (void)destroy +{ + [self removePendingActionMask]; + + [super destroy]; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!_contactsControls) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + // global init + displayMatrixUsers = (0 == self.contactsControls.selectedSegmentIndex); + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onContactsRefresh:) name:kMXKContactManagerDidUpdateMatrixContactsNotification object:nil]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onContactsRefresh:) name:kMXKContactManagerDidUpdateLocalContactsNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onContactsRefresh:) name:kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification object:nil]; + + if (!_contactTableViewCellClass) + { + // Set default table view cell class + self.contactTableViewCellClass = [MXKContactTableCell class]; + } + + // Localize string + [_contactsControls setTitle:[MatrixKitL10n contactMxUsers] forSegmentAtIndex:0]; + [_contactsControls setTitle:[MatrixKitL10n contactLocalContacts] forSegmentAtIndex:1]; + + // Apply search option in navigation bar + self.enableBarButtonSearch = _enableBarButtonSearch; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + // Restore search mechanism (if enabled) + ignoreSearchRequest = NO; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + // The user may still press search button whereas the view disappears + ignoreSearchRequest = YES; + + // Leave potential search session + if (contactsSearchBar) + { + [self searchBarCancelButtonClicked:contactsSearchBar]; + } +} + +- (void)scrollToTop +{ + // stop any scrolling effect + [UIView setAnimationsEnabled:NO]; + // before scrolling to the tableview top + self.tableView.contentOffset = CGPointMake(-self.tableView.adjustedContentInset.left, -self.tableView.adjustedContentInset.top); + [UIView setAnimationsEnabled:YES]; +} + +#pragma mark - + +-(void)setContactTableViewCellClass:(Class)contactTableViewCellClass +{ + // Sanity check: accept only MXKContactTableCell classes or sub-classes + NSParameterAssert([contactTableViewCellClass isSubclassOfClass:MXKContactTableCell.class]); + + _contactTableViewCellClass = contactTableViewCellClass; + [self.tableView registerClass:contactTableViewCellClass forCellReuseIdentifier:[contactTableViewCellClass defaultReuseIdentifier]]; +} + +- (void)setEnableBarButtonSearch:(BOOL)enableBarButtonSearch +{ + _enableBarButtonSearch = enableBarButtonSearch; + + if (enableBarButtonSearch) + { + if (!searchButton) + { + searchButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemSearch target:self action:@selector(search:)]; + } + + // Add it in right bar items + NSArray *rightBarButtonItems = self.navigationItem.rightBarButtonItems; + self.navigationItem.rightBarButtonItems = rightBarButtonItems ? [rightBarButtonItems arrayByAddingObject:searchButton] : @[searchButton]; + } + else + { + NSMutableArray *rightBarButtonItems = [NSMutableArray arrayWithArray: self.navigationItem.rightBarButtonItems]; + [rightBarButtonItems removeObject:searchButton]; + self.navigationItem.rightBarButtonItems = rightBarButtonItems; + } +} + +#pragma mark - Internals + +- (void)updateSectionedLocalContacts:(BOOL)force +{ + [self stopActivityIndicator]; + + MXKContactManager* sharedManager = [MXKContactManager sharedManager]; + + if (force || !localContactsArray) + { + localContactsArray = sharedManager.localContacts; + sectionedLocalContacts = [sharedManager getSectionedContacts:localContactsArray]; + } +} + +- (void)updateSectionedMatrixContacts:(BOOL)force +{ + [self stopActivityIndicator]; + + MXKContactManager* sharedManager = [MXKContactManager sharedManager]; + + if (force || !matrixContactsArray) + { + matrixContactsArray = sharedManager.matrixContacts; + sectionedMatrixContacts = [sharedManager getSectionedContacts:matrixContactsArray]; + } +} + +- (BOOL)hasPendingAction +{ + return nil != pendingMaskSpinnerView; +} + +- (void)addPendingActionMask +{ + // add a spinner above the tableview to avoid that the user tap on any other button + pendingMaskSpinnerView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge]; + pendingMaskSpinnerView.backgroundColor = [UIColor colorWithRed:0.5 green:0.5 blue:0.5 alpha:0.5]; + pendingMaskSpinnerView.frame = self.tableView.frame; + pendingMaskSpinnerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleTopMargin; + + // append it + [self.tableView.superview addSubview:pendingMaskSpinnerView]; + + // animate it + [pendingMaskSpinnerView startAnimating]; +} + +- (void)removePendingActionMask +{ + if (pendingMaskSpinnerView) + { + [pendingMaskSpinnerView removeFromSuperview]; + pendingMaskSpinnerView = nil; + [self.tableView reloadData]; + } +} + +#pragma mark - UITableView dataSource + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView +{ + NSInteger sectionNb; + + // search in progress + if (contactsSearchBar) + { + sectionNb = sectionedFilteredContacts.sectionedContacts.count; + if (!sectionNb) + { + // Keep at least one section to display the search bar + sectionNb = 1; + } + } + else if (displayMatrixUsers) + { + [self updateSectionedMatrixContacts:NO]; + sectionNb = sectionedMatrixContacts.sectionedContacts.count; + + } + else + { + [self updateSectionedLocalContacts:NO]; + sectionNb = sectionedLocalContacts.sectionedContacts.count; + } + + return sectionNb; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + MXKSectionedContacts* sectionedContacts = contactsSearchBar ? sectionedFilteredContacts : (displayMatrixUsers ? sectionedMatrixContacts : sectionedLocalContacts); + + if (section < sectionedContacts.sectionedContacts.count) + { + return [sectionedContacts.sectionedContacts[section] count]; + } + return 0; +} + +- (NSString *)tableView:(UITableView *)aTableView titleForHeaderInSection:(NSInteger)section +{ + if (contactsSearchBar) + { + // Hide section titles during search session + return nil; + } + + MXKSectionedContacts* sectionedContacts = displayMatrixUsers ? sectionedMatrixContacts : sectionedLocalContacts; + if (section < sectionedContacts.sectionTitles.count) + { + return (NSString*)[sectionedContacts.sectionTitles objectAtIndex:section]; + } + + return nil; +} + +- (NSArray *)sectionIndexTitlesForTableView:(UITableView *)aTableView +{ + // do not display the collation during a search + if (contactsSearchBar) + { + return nil; + } + + return [[UILocalizedIndexedCollation currentCollation] sectionIndexTitles]; +} + +- (NSInteger)tableView:(UITableView *)aTableView sectionForSectionIndexTitle:(NSString *)title atIndex:(NSInteger)index +{ + MXKSectionedContacts* sectionedContacts = displayMatrixUsers ? sectionedMatrixContacts : sectionedLocalContacts; + NSInteger section = [sectionedContacts.sectionTitles indexOfObject:title]; + + // undefined title -> jump to the first valid non empty section + if (NSNotFound == section) + { + NSInteger systemCollationIndex = [collationTitles indexOfObject:title]; + + // find in the system collation + if (NSNotFound != systemCollationIndex) + { + systemCollationIndex--; + + while ((systemCollationIndex >= 0) && (NSNotFound == section)) + { + NSString* systemTitle = [collationTitles objectAtIndex:systemCollationIndex]; + section = [sectionedContacts.sectionTitles indexOfObject:systemTitle]; + systemCollationIndex--; + } + } + } + + return section; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + MXKContactTableCell* cell = [tableView dequeueReusableCellWithIdentifier:[_contactTableViewCellClass defaultReuseIdentifier] forIndexPath:indexPath]; + cell.thumbnailDisplayBoxType = MXKTableViewCellDisplayBoxTypeCircle; + + MXKSectionedContacts* sectionedContacts = contactsSearchBar ? sectionedFilteredContacts : (displayMatrixUsers ? sectionedMatrixContacts : sectionedLocalContacts); + + MXKContact* contact = nil; + + if (indexPath.section < sectionedContacts.sectionedContacts.count) + { + NSArray *thisSection = [sectionedContacts.sectionedContacts objectAtIndex:indexPath.section]; + + if (indexPath.row < thisSection.count) + { + contact = [thisSection objectAtIndex:indexPath.row]; + } + } + + if (contact) + { + cell.contactAccessoryViewType = MXKContactTableCellAccessoryMatrixIcon; + [cell render:contact]; + cell.delegate = self; + } + + return cell; +} + +#pragma mark - UITableView delegate + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + MXKSectionedContacts* sectionedContacts = contactsSearchBar ? sectionedFilteredContacts : (displayMatrixUsers ? sectionedMatrixContacts : sectionedLocalContacts); + + MXKContact* contact = nil; + + if (indexPath.section < sectionedContacts.sectionedContacts.count) + { + NSArray *thisSection = [sectionedContacts.sectionedContacts objectAtIndex:indexPath.section]; + + if (indexPath.row < thisSection.count) + { + contact = [thisSection objectAtIndex:indexPath.row]; + } + } + + return [((Class)_contactTableViewCellClass) heightForCellData:contact withMaximumWidth:tableView.frame.size.width]; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section +{ + // In case of search, the section titles are hidden and the search bar is displayed in first section header. + if (contactsSearchBar) + { + if (section == 0) + { + return contactsSearchBar.frame.size.height; + } + return 0; + } + + // Default section header height + return 22; +} + +- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section +{ + if (contactsSearchBar && section == 0) + { + return contactsSearchBar; + } + return nil; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + + MXKSectionedContacts* sectionedContacts = contactsSearchBar ? sectionedFilteredContacts : (displayMatrixUsers ? sectionedMatrixContacts : sectionedLocalContacts); + + MXKContact* contact = nil; + + if (indexPath.section < sectionedContacts.sectionedContacts.count) + { + NSArray *thisSection = [sectionedContacts.sectionedContacts objectAtIndex:indexPath.section]; + + if (indexPath.row < thisSection.count) + { + contact = [thisSection objectAtIndex:indexPath.row]; + } + } + + if (self.delegate) { + [self.delegate contactListViewController:self didSelectContact:contact.contactID]; + } +} + +- (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath*)indexPath +{ + // Release here resources, and restore reusable cells + if ([cell respondsToSelector:@selector(didEndDisplay)]) + { + [(id)cell didEndDisplay]; + } +} + +#pragma mark - Actions + +- (void)onContactsRefresh:(NSNotification *)notif +{ + if ([notif.name isEqualToString:kMXKContactManagerDidUpdateMatrixContactsNotification]) + { + [self updateSectionedMatrixContacts:YES]; + } + else if ([notif.name isEqualToString:kMXKContactManagerDidUpdateLocalContactsNotification]) + { + [self updateSectionedLocalContacts:YES]; + } + else //if ([notif.name isEqualToString:kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification]) + { + // Consider here only global notifications, ignore notifications related to a specific contact. + if (notif.object) + { + return; + } + + [self updateSectionedLocalContacts:YES]; + } + + if (contactsSearchBar) + { + latestSearchedPattern = nil; + [self searchBar:contactsSearchBar textDidChange:contactsSearchBar.text]; + } + else + { + [self.tableView reloadData]; + } +} + +- (IBAction)onSegmentValueChange:(id)sender +{ + if (sender == self.contactsControls) + { + displayMatrixUsers = (0 == self.contactsControls.selectedSegmentIndex); + + // Leave potential search session + if (contactsSearchBar) + { + [self searchBarCancelButtonClicked:contactsSearchBar]; + } + + [self.tableView reloadData]; + } +} + +#pragma mark Search management + +- (void)search:(id)sender +{ + // The user may have pressed search button whereas the view controller was disappearing + if (ignoreSearchRequest) + { + return; + } + + if (!contactsSearchBar) + { + MXKSectionedContacts* sectionedContacts = displayMatrixUsers ? sectionedMatrixContacts : sectionedLocalContacts; + + // Check whether there are data in which search + if (sectionedContacts.sectionedContacts.count > 0) + { + // Create search bar + contactsSearchBar = [[UISearchBar alloc] initWithFrame:CGRectMake(0, 0, self.view.frame.size.width, 44)]; + contactsSearchBar.autoresizingMask = UIViewAutoresizingFlexibleWidth; + contactsSearchBar.showsCancelButton = YES; + contactsSearchBar.returnKeyType = UIReturnKeyDone; + contactsSearchBar.delegate = self; + searchBarShouldEndEditing = NO; + + // init the table content + latestSearchedPattern = @""; + filteredContacts = [(displayMatrixUsers ? matrixContactsArray : localContactsArray) mutableCopy]; + sectionedFilteredContacts = [[MXKContactManager sharedManager] getSectionedContacts:filteredContacts]; + + [self.tableView reloadData]; + + dispatch_async(dispatch_get_main_queue(), ^{ + [self->contactsSearchBar becomeFirstResponder]; + }); + } + } + else + { + [self searchBarCancelButtonClicked:contactsSearchBar]; + } +} + +#pragma mark - UISearchBarDelegate + +- (BOOL)searchBarShouldBeginEditing:(UISearchBar *)searchBar +{ + searchBarShouldEndEditing = NO; + return YES; +} + +- (BOOL)searchBarShouldEndEditing:(UISearchBar *)searchBar +{ + return searchBarShouldEndEditing; +} + +- (NSArray*)patternsFromText:(NSString*)text +{ + NSArray* items = [text componentsSeparatedByString:@" "]; + + if (items.count <= 1) + { + return items; + } + + NSMutableArray* patterns = [[NSMutableArray alloc] init]; + + for (NSString* item in items) + { + if (item.length > 0) + { + [patterns addObject:item]; + } + } + + return patterns; +} + +- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText +{ + if ((contactsSearchBar == searchBar) && (![latestSearchedPattern isEqualToString:searchText])) + { + latestSearchedPattern = searchText; + + // contacts + NSArray* contacts = displayMatrixUsers ? matrixContactsArray : localContactsArray; + + // Update filtered list + if (searchText.length && contacts.count) + { + filteredContacts = [[NSMutableArray alloc] init]; + + NSArray* patterns = [self patternsFromText:searchText]; + for(MXKContact* contact in contacts) + { + if ([contact matchedWithPatterns:patterns]) + { + [filteredContacts addObject:contact]; + } + } + } + else + { + filteredContacts = [contacts mutableCopy]; + } + + sectionedFilteredContacts = [[MXKContactManager sharedManager] getSectionedContacts:filteredContacts]; + + // Refresh display + [self.tableView reloadData]; + [self scrollToTop]; + } +} + +- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar +{ + if (contactsSearchBar == searchBar) + { + // "Done" key has been pressed + searchBarShouldEndEditing = YES; + [contactsSearchBar resignFirstResponder]; + } +} + +- (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar +{ + if (contactsSearchBar == searchBar) + { + // Leave search + searchBarShouldEndEditing = YES; + [contactsSearchBar resignFirstResponder]; + [contactsSearchBar removeFromSuperview]; + contactsSearchBar = nil; + filteredContacts = nil; + sectionedFilteredContacts = nil; + latestSearchedPattern = nil; + [self.tableView reloadData]; + [self scrollToTop]; + } +} + +#pragma mark - MXKCellRendering delegate + +- (void)cell:(id)cell didRecognizeAction:(NSString*)actionIdentifier userInfo:(NSDictionary *)userInfo +{ + if ([actionIdentifier isEqualToString:kMXKContactCellTapOnThumbnailView]) + { + if (self.delegate) { + [self.delegate contactListViewController:self didTapContactThumbnail:userInfo[kMXKContactCellContactIdKey]]; + } + } +} + +- (BOOL)cell:(id)cell shouldDoAction:(NSString *)actionIdentifier userInfo:(NSDictionary *)userInfo defaultValue:(BOOL)defaultValue +{ + // No such action yet on contacts + return defaultValue; +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.xib new file mode 100644 index 000000000..6d638f106 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.xib @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.h new file mode 100644 index 000000000..83e92f2ae --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.h @@ -0,0 +1,81 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKTableViewController.h" + +@class MXKCountryPickerViewController; + +/** + `MXKCountryPickerViewController` delegate. + */ +@protocol MXKCountryPickerViewControllerDelegate + +/** + Tells the delegate that the user selected a country. + + @param countryPickerViewController the `MXKCountryPickerViewController` instance. + @param isoCountryCode the ISO 3166-1 country code representation. + */ +- (void)countryPickerViewController:(MXKCountryPickerViewController*)countryPickerViewController didSelectCountry:(NSString*)isoCountryCode; + +@end + +/** + 'MXKCountryPickerViewController' instance displays the list of supported countries. + */ +@interface MXKCountryPickerViewController : MXKTableViewController + +/** +The searchController used to manage search. +*/ +@property (nonatomic, strong) UISearchController *searchController; + +/** + The delegate for the view controller. + */ +@property (nonatomic, weak) id delegate; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKCountryPickerViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `countryPickerViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKCountryPickerViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKCountryPickerViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)countryPickerViewController; + +/** + Show/Hide the international dialing code for each country (NO by default). + */ +@property (nonatomic) BOOL showCountryCallingCode; + +@end + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.m new file mode 100644 index 000000000..273046244 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.m @@ -0,0 +1,299 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKCountryPickerViewController.h" + +@import libPhoneNumber_iOS; + +#import "NSBundle+MatrixKit.h" +#import "MXKSwiftHeader.h" + + +NSString* const kMXKCountryPickerViewControllerCountryCellId = @"kMXKCountryPickerViewControllerCountryCellId"; + +@interface MXKCountryPickerViewController () +{ + NSMutableDictionary *isoCountryCodesByCountryName; + + NSArray *countryNames; + NSMutableArray *filteredCountryNames; + + NSString *previousSearchPattern; + + NSMutableDictionary *callingCodesByCountryName; +} + +@end + +@implementation MXKCountryPickerViewController + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKCountryPickerViewController class]) + bundle:[NSBundle bundleForClass:[MXKCountryPickerViewController class]]]; +} + ++ (instancetype)countryPickerViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKCountryPickerViewController class]) + bundle:[NSBundle bundleForClass:[MXKCountryPickerViewController class]]]; +} + +- (void)finalizeInit +{ + [super finalizeInit]; + + NSArray *isoCountryCodes = [NSLocale ISOCountryCodes]; + NSMutableArray *countries; + + isoCountryCodesByCountryName = [NSMutableDictionary dictionaryWithCapacity:isoCountryCodes.count]; + countries = [NSMutableArray arrayWithCapacity:isoCountryCodes.count]; + + NSLocale *local = [[NSLocale alloc] initWithLocaleIdentifier:[[[NSBundle mainBundle] preferredLocalizations] objectAtIndex:0]]; + + for (NSString *isoCountryCode in isoCountryCodes) + { + NSString *country = [local displayNameForKey:NSLocaleCountryCode value:isoCountryCode]; + if (country) + { + [countries addObject: country]; + isoCountryCodesByCountryName[country] = isoCountryCode; + } + } + + countryNames = [countries sortedArrayUsingSelector:@selector(localizedCaseInsensitiveCompare:)]; + + previousSearchPattern = nil; + filteredCountryNames = nil; + + _showCountryCallingCode = NO; +} + +- (void)destroy +{ + [super destroy]; + + isoCountryCodesByCountryName = nil; + + countryNames = nil; + filteredCountryNames = nil; + + callingCodesByCountryName = nil; + + previousSearchPattern = nil; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!self.tableView) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + self.navigationItem.title = [MatrixKitL10n countryPickerTitle]; + + [self setupSearchController]; +} + +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + + self.navigationItem.hidesSearchBarWhenScrolling = YES; +} + +#pragma mark - + +- (void)setShowCountryCallingCode:(BOOL)showCountryCallingCode +{ + if (_showCountryCallingCode != showCountryCallingCode) + { + _showCountryCallingCode = showCountryCallingCode; + + if (_showCountryCallingCode && !callingCodesByCountryName) + { + callingCodesByCountryName = [NSMutableDictionary dictionary]; + + for (NSString *countryName in countryNames) + { + NSString *isoCountryCode = isoCountryCodesByCountryName[countryName]; + NSNumber *callingCode = [[NBPhoneNumberUtil sharedInstance] getCountryCodeForRegion:isoCountryCode]; + + callingCodesByCountryName[countryName] = callingCode; + } + } + + [self.tableView reloadData]; + } +} + +#pragma mark - Private + +- (void)setupSearchController +{ + UISearchController *searchController = [[UISearchController alloc] + initWithSearchResultsController:nil]; + searchController.dimsBackgroundDuringPresentation = NO; + searchController.hidesNavigationBarDuringPresentation = NO; + searchController.searchResultsUpdater = self; + + self.navigationItem.searchController = searchController; + // Make the search bar visible on first view appearance + self.navigationItem.hidesSearchBarWhenScrolling = NO; + + self.definesPresentationContext = YES; + + self.searchController = searchController; +} + +#pragma mark - UITableView dataSource + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + if (filteredCountryNames) + { + return filteredCountryNames.count; + } + return countryNames.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:kMXKCountryPickerViewControllerCountryCellId]; + if (!cell) + { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:kMXKCountryPickerViewControllerCountryCellId]; + } + + NSInteger index = indexPath.row; + NSString *countryName; + + if (filteredCountryNames) + { + if (index < filteredCountryNames.count) + { + countryName = filteredCountryNames[index]; + } + } + else if (index < countryNames.count) + { + countryName = countryNames[index]; + } + + if (countryName) + { + cell.textLabel.text = countryName; + + if (self.showCountryCallingCode) + { + cell.detailTextLabel.text = [NSString stringWithFormat:@"+%@", [callingCodesByCountryName[countryName] stringValue]]; + } + } + + return cell; +} + +#pragma mark - UITableView delegate + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + + if (self.delegate) + { + NSInteger index = indexPath.row; + NSString *countryName; + + if (filteredCountryNames) + { + if (index < filteredCountryNames.count) + { + countryName = filteredCountryNames[index]; + } + } + else if (index < countryNames.count) + { + countryName = countryNames[index]; + } + + if (countryName) + { + NSString *isoCountryCode = isoCountryCodesByCountryName[countryName]; + + [self.delegate countryPickerViewController:self didSelectCountry:isoCountryCode]; + } + } +} + +#pragma mark - UISearchResultsUpdating + +- (void)updateSearchResultsForSearchController:(UISearchController *)searchController +{ + NSString *searchText = searchController.searchBar.text; + + if (searchText.length) + { + searchText = [searchText lowercaseString]; + + if (previousSearchPattern && [searchText hasPrefix:previousSearchPattern]) + { + for (NSUInteger index = 0; index < filteredCountryNames.count;) + { + NSString *countryName = [filteredCountryNames[index] lowercaseString]; + + if ([countryName hasPrefix:searchText] == NO) + { + [filteredCountryNames removeObjectAtIndex:index]; + } + else + { + index++; + } + } + } + else + { + filteredCountryNames = [NSMutableArray array]; + + for (NSUInteger index = 0; index < countryNames.count; index++) + { + NSString *countryName = [countryNames[index] lowercaseString]; + + if ([countryName hasPrefix:searchText]) + { + [filteredCountryNames addObject:countryNames[index]]; + } + } + } + + previousSearchPattern = searchText; + } + else + { + previousSearchPattern = nil; + filteredCountryNames = nil; + } + + [self.tableView reloadData]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.xib new file mode 100644 index 000000000..a9349f0b2 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.xib @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.h new file mode 100644 index 000000000..020f78549 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.h @@ -0,0 +1,118 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKViewController.h" +#import "MXKSessionGroupsDataSource.h" + +@class MXKGroupListViewController; + +/** + `MXKGroupListViewController` delegate. + */ +@protocol MXKGroupListViewControllerDelegate + +/** + Tells the delegate that the user selected a group. + + @param groupListViewController the `MXKGroupListViewController` instance. + @param group the selected group. + @param mxSession the matrix session in which the group is defined. + */ +- (void)groupListViewController:(MXKGroupListViewController *)groupListViewController didSelectGroup:(MXGroup*)group inMatrixSession:(MXSession*)mxSession; + +@end + + +/** + This view controller displays a group list. + */ +@interface MXKGroupListViewController : MXKViewController +{ +@protected + + /** + The fake top view displayed in case of vertical bounce. + */ + __weak UIView *topview; +} + +@property (weak, nonatomic) IBOutlet UISearchBar *groupsSearchBar; +@property (weak, nonatomic) IBOutlet UITableView *groupsTableView; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *groupsSearchBarTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *groupsSearchBarHeightConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *groupsTableViewBottomConstraint; + +/** + The current data source associated to the view controller. + */ +@property (nonatomic, readonly) MXKSessionGroupsDataSource *dataSource; + +/** + The delegate for the view controller. + */ +@property (nonatomic, weak) id delegate; + +/** + Enable the search option by adding a navigation item in the navigation bar (YES by default). + Set NO this property to disable this option and hide the related bar button. + */ +@property (nonatomic) BOOL enableBarButtonSearch; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKGroupListViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `groupListViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKGroupListViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKGroupListViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)groupListViewController; + +/** + Display the groups described in the provided data source. + + Note: The provided data source will replace the current data source if any. The caller + should dispose properly this data source if it is not used anymore. + + @param listDataSource the data source providing the groups list. + */ +- (void)displayList:(MXKSessionGroupsDataSource*)listDataSource; + +/** + Refresh the groups table display. + */ +- (void)refreshGroupsTable; + +/** + Hide/show the search bar at the top of the groups table view. + */ +- (void)hideSearchBar:(BOOL)hidden; + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.m new file mode 100644 index 000000000..e9950fa3f --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.m @@ -0,0 +1,608 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKGroupListViewController.h" + +#import "MXKGroupTableViewCell.h" +#import "MXKTableViewHeaderFooterWithLabel.h" + +@interface MXKGroupListViewController () +{ + /** + The data source providing UITableViewCells + */ + MXKSessionGroupsDataSource *dataSource; + + /** + Search handling + */ + UIBarButtonItem *searchButton; + BOOL ignoreSearchRequest; + + /** + The reconnection animated view. + */ + UIView* reconnectingView; + + /** + The current table view header if any. + */ + UIView* tableViewHeaderView; + + /** + The latest server sync date + */ + NSDate* latestServerSync; + + /** + The restart the event connnection + */ + BOOL restartConnection; +} + +@end + +@implementation MXKGroupListViewController +@synthesize dataSource; + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKGroupListViewController class]) + bundle:[NSBundle bundleForClass:[MXKGroupListViewController class]]]; +} + ++ (instancetype)groupListViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKGroupListViewController class]) + bundle:[NSBundle bundleForClass:[MXKGroupListViewController class]]]; +} + +#pragma mark - + +- (void)finalizeInit +{ + [super finalizeInit]; + + _enableBarButtonSearch = YES; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!_groupsTableView) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + // Adjust search bar Top constraint to take into account potential navBar. + if (_groupsSearchBarTopConstraint) + { + [NSLayoutConstraint deactivateConstraints:@[_groupsSearchBarTopConstraint]]; + + _groupsSearchBarTopConstraint = [NSLayoutConstraint constraintWithItem:self.topLayoutGuide + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:self.groupsSearchBar + attribute:NSLayoutAttributeTop + multiplier:1.0f + constant:0.0f]; + + [NSLayoutConstraint activateConstraints:@[_groupsSearchBarTopConstraint]]; + } + + // Adjust table view Bottom constraint to take into account tabBar. + if (_groupsTableViewBottomConstraint) + { + [NSLayoutConstraint deactivateConstraints:@[_groupsTableViewBottomConstraint]]; + + _groupsTableViewBottomConstraint = [NSLayoutConstraint constraintWithItem:self.bottomLayoutGuide + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self.groupsTableView + attribute:NSLayoutAttributeBottom + multiplier:1.0f + constant:0.0f]; + + [NSLayoutConstraint activateConstraints:@[_groupsTableViewBottomConstraint]]; + } + + // Hide search bar by default + [self hideSearchBar:YES]; + + // Apply search option in navigation bar + self.enableBarButtonSearch = _enableBarButtonSearch; + + // Add an accessory view to the search bar in order to retrieve keyboard view. + self.groupsSearchBar.inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; + + // Finalize table view configuration + // Note: self-sizing cells and self-sizing section headers are enabled from the nib file. + self.groupsTableView.delegate = self; + self.groupsTableView.dataSource = dataSource; // Note: dataSource may be nil here + self.groupsTableView.estimatedSectionHeaderHeight = 30; // The value set in the nib seems not available for iOS version < 10. + + // Set up classes to use for the cells and the section headers. + [self.groupsTableView registerNib:MXKGroupTableViewCell.nib forCellReuseIdentifier:MXKGroupTableViewCell.defaultReuseIdentifier]; + [self.groupsTableView registerNib:MXKTableViewHeaderFooterWithLabel.nib forHeaderFooterViewReuseIdentifier:MXKTableViewHeaderFooterWithLabel.defaultReuseIdentifier]; + + // Add a top view which will be displayed in case of vertical bounce. + CGFloat height = self.groupsTableView.frame.size.height; + UIView *topview = [[UIView alloc] initWithFrame:CGRectMake(0,-height,self.groupsTableView.frame.size.width,height)]; + topview.autoresizingMask = UIViewAutoresizingFlexibleWidth; + topview.backgroundColor = [UIColor groupTableViewBackgroundColor]; + [self.groupsTableView addSubview:topview]; + self->topview = topview; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + // Restore search mechanism (if enabled) + ignoreSearchRequest = NO; + + // Observe the server sync + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onSyncNotification) name:kMXSessionDidSyncNotification object:nil]; + + // Do a full reload + [self refreshGroupsTable]; + + // Refresh all groups summary + [self.dataSource refreshGroupsSummary:nil]; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + // The user may still press search button whereas the view disappears + ignoreSearchRequest = YES; + + // Leave potential search session + if (!self.groupsSearchBar.isHidden) + { + [self searchBarCancelButtonClicked:self.groupsSearchBar]; + } + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidSyncNotification object:nil]; + + [self removeReconnectingView]; +} + +- (void)dealloc +{ + self.groupsSearchBar.inputAccessoryView = nil; + + searchButton = nil; +} + +- (void)didReceiveMemoryWarning +{ + [super didReceiveMemoryWarning]; + + // Dispose of any resources that can be recreated. +} + +#pragma mark - Override MXKViewController + +- (void)onKeyboardShowAnimationComplete +{ + // Report the keyboard view in order to track keyboard frame changes + self.keyboardView = _groupsSearchBar.inputAccessoryView.superview; +} + +- (void)setKeyboardHeight:(CGFloat)keyboardHeight +{ + // Deduce the bottom constraint for the table view (Don't forget the potential tabBar) + CGFloat tableViewBottomConst = keyboardHeight - self.bottomLayoutGuide.length; + // Check whether the keyboard is over the tabBar + if (tableViewBottomConst < 0) + { + tableViewBottomConst = 0; + } + + // Update constraints + _groupsTableViewBottomConstraint.constant = tableViewBottomConst; + + // Force layout immediately to take into account new constraint + [self.view layoutIfNeeded]; +} + +- (void)destroy +{ + self.groupsTableView.dataSource = nil; + self.groupsTableView.delegate = nil; + self.groupsTableView = nil; + + dataSource.delegate = nil; + dataSource = nil; + + _delegate = nil; + + [topview removeFromSuperview]; + topview = nil; + + [super destroy]; +} + +#pragma mark - + +- (void)setEnableBarButtonSearch:(BOOL)enableBarButtonSearch +{ + _enableBarButtonSearch = enableBarButtonSearch; + + if (enableBarButtonSearch) + { + if (!searchButton) + { + searchButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemSearch target:self action:@selector(search:)]; + } + + // Add it in right bar items + NSArray *rightBarButtonItems = self.navigationItem.rightBarButtonItems; + self.navigationItem.rightBarButtonItems = rightBarButtonItems ? [rightBarButtonItems arrayByAddingObject:searchButton] : @[searchButton]; + } + else + { + NSMutableArray *rightBarButtonItems = [NSMutableArray arrayWithArray: self.navigationItem.rightBarButtonItems]; + [rightBarButtonItems removeObject:searchButton]; + self.navigationItem.rightBarButtonItems = rightBarButtonItems; + } +} + +- (void)displayList:(MXKSessionGroupsDataSource *)listDataSource +{ + // Cancel registration on existing dataSource if any + if (dataSource) + { + dataSource.delegate = nil; + + // Remove associated matrix sessions + NSArray *mxSessions = self.mxSessions; + for (MXSession *mxSession in mxSessions) + { + [self removeMatrixSession:mxSession]; + } + } + + dataSource = listDataSource; + dataSource.delegate = self; + + // Report the matrix session at view controller level to update UI according to session state + [self addMatrixSession:listDataSource.mxSession]; + + if (self.groupsTableView) + { + // Set up table data source + self.groupsTableView.dataSource = dataSource; + } +} + +- (void)refreshGroupsTable +{ + // For now, do a simple full reload + [self.groupsTableView reloadData]; +} + +- (void)hideSearchBar:(BOOL)hidden +{ + self.groupsSearchBar.hidden = hidden; + self.groupsSearchBarHeightConstraint.constant = hidden ? 0 : 44; + [self.view setNeedsUpdateConstraints]; +} + +#pragma mark - Action + +- (IBAction)search:(id)sender +{ + // The user may have pressed search button whereas the view controller was disappearing + if (ignoreSearchRequest) + { + return; + } + + if (self.groupsSearchBar.isHidden) + { + // Check whether there are data in which search + if ([self.dataSource numberOfSectionsInTableView:self.groupsTableView]) + { + [self hideSearchBar:NO]; + + // Create search bar + [self.groupsSearchBar becomeFirstResponder]; + } + } + else + { + [self searchBarCancelButtonClicked: self.groupsSearchBar]; + } +} + +#pragma mark - MXKDataSourceDelegate + +- (Class)cellViewClassForCellData:(MXKCellData*)cellData +{ + // Return the default group table view cell + return MXKGroupTableViewCell.class; +} + +- (NSString *)cellReuseIdentifierForCellData:(MXKCellData*)cellData +{ + // Return the default group table view cell + return MXKGroupTableViewCell.defaultReuseIdentifier; +} + +- (void)dataSource:(MXKDataSource *)dataSource didCellChange:(id)changes +{ + // For now, do a simple full reload + [self refreshGroupsTable]; +} + +- (void)dataSource:(MXKDataSource *)dataSource didAddMatrixSession:(MXSession *)mxSession +{ + [self addMatrixSession:mxSession]; +} + +- (void)dataSource:(MXKDataSource *)dataSource didRemoveMatrixSession:(MXSession *)mxSession +{ + [self removeMatrixSession:mxSession]; +} + +#pragma mark - UITableView delegate + +- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + return tableView.estimatedRowHeight; +} + +- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForHeaderInSection:(NSInteger)section +{ + if (tableView.numberOfSections > 1) + { + return tableView.estimatedSectionHeaderHeight; + } + + return 0; +} + +- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath +{ + // Refresh here the estimated row height + tableView.estimatedRowHeight = cell.frame.size.height; +} + +- (void)tableView:(UITableView *)tableView willDisplayHeaderView:(nonnull UIView *)view forSection:(NSInteger)section +{ + // Refresh here the estimated header height + tableView.estimatedSectionHeaderHeight = view.frame.size.height; +} + +- (nullable UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section +{ + MXKTableViewHeaderFooterWithLabel *sectionHeader; + + if (tableView.numberOfSections > 1) + { + sectionHeader = [tableView dequeueReusableHeaderFooterViewWithIdentifier:MXKTableViewHeaderFooterWithLabel.defaultReuseIdentifier]; + + sectionHeader.mxkLabel.text = [self.dataSource tableView:tableView titleForHeaderInSection:section]; + } + + return sectionHeader; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (_delegate) + { + UITableViewCell *selectedCell = [tableView cellForRowAtIndexPath:indexPath]; + + if ([selectedCell conformsToProtocol:@protocol(MXKCellRendering)]) + { + id cell = (id)selectedCell; + + if ([cell respondsToSelector:@selector(renderedCellData)]) + { + MXKCellData *cellData = cell.renderedCellData; + if ([cellData conformsToProtocol:@protocol(MXKGroupCellDataStoring)]) + { + id groupCellData = (id)cellData; + [_delegate groupListViewController:self didSelectGroup:groupCellData.group inMatrixSession:self.mainSession]; + } + } + } + } + + // Hide the keyboard when user select a room + // do not hide the searchBar until the view controller disappear + // on tablets / iphone 6+, the user could expect to search again while looking at a room + [self.groupsSearchBar resignFirstResponder]; +} + +- (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath*)indexPath +{ + // Release here resources, and restore reusable cells + if ([cell respondsToSelector:@selector(didEndDisplay)]) + { + [(id)cell didEndDisplay]; + } +} + +- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset +{ + // Detect vertical bounce at the top of the tableview to trigger reconnection. + if (scrollView == _groupsTableView) + { + [self detectPullToKick:scrollView]; + } +} + +- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView +{ + if (scrollView == _groupsTableView) + { + [self managePullToKick:scrollView]; + } +} + +- (void)scrollViewDidScroll:(UIScrollView *)scrollView +{ + if (scrollView == _groupsTableView) + { + if (scrollView.contentOffset.y + scrollView.adjustedContentInset.top == 0) + { + [self managePullToKick:scrollView]; + } + } +} + +#pragma mark - UISearchBarDelegate + +- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText +{ + // Apply filter + if (searchText.length) + { + [self.dataSource searchWithPatterns:@[searchText]]; + } + else + { + [self.dataSource searchWithPatterns:nil]; + } +} + +- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar +{ + // "Done" key has been pressed + [searchBar resignFirstResponder]; +} + +- (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar +{ + // Leave search + [searchBar resignFirstResponder]; + + [self hideSearchBar:YES]; + + self.groupsSearchBar.text = nil; + + // Refresh display + [self.dataSource searchWithPatterns:nil]; +} + +#pragma mark - resync management + +- (void)onSyncNotification +{ + latestServerSync = [NSDate date]; + + MXWeakify(self); + + // Refresh all groups summary + [self.dataSource refreshGroupsSummary:^{ + + MXStrongifyAndReturnIfNil(self); + + [self removeReconnectingView]; + }]; +} + +- (BOOL)canReconnect +{ + // avoid restarting connection if some data has been received within 1 second (1000 : latestServerSync is null) + NSTimeInterval interval = latestServerSync ? [[NSDate date] timeIntervalSinceDate:latestServerSync] : 1000; + return (interval > 1) && [self.mainSession reconnect]; +} + +- (void)addReconnectingView +{ + if (!reconnectingView) + { + UIActivityIndicatorView* spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge]; + spinner.transform = CGAffineTransformMakeScale(0.75f, 0.75f); + CGRect frame = spinner.frame; + frame.size.height = 80; // 80 * 0.75 = 60 + spinner.bounds = frame; + spinner.color = [UIColor darkGrayColor]; + spinner.hidesWhenStopped = NO; + spinner.backgroundColor = _groupsTableView.backgroundColor; + [spinner startAnimating]; + + // no need to manage constraints here, IOS defines them. + tableViewHeaderView = _groupsTableView.tableHeaderView; + _groupsTableView.tableHeaderView = reconnectingView = spinner; + } +} + +- (void)removeReconnectingView +{ + if (reconnectingView && !restartConnection) + { + _groupsTableView.tableHeaderView = tableViewHeaderView; + reconnectingView = nil; + } +} + +/** + Detect if the current connection must be restarted. + The spinner is displayed until the overscroll ends (and scrollViewDidEndDecelerating is called). + */ +- (void)detectPullToKick:(UIScrollView *)scrollView +{ + if (!reconnectingView) + { + // detect if the user scrolls over the tableview top + restartConnection = (scrollView.contentOffset.y + scrollView.adjustedContentInset.top < -128); + + if (restartConnection) + { + // wait that list decelerate to display / hide it + [self addReconnectingView]; + } + } +} + +/** + Restarts the current connection if it is required. + The 0.3s delay is added to avoid flickering if the connection does not require to be restarted. + */ +- (void)managePullToKick:(UIScrollView *)scrollView +{ + // the current connection must be restarted + if (restartConnection) + { + // display at least 0.3s the spinner to show to the user that something is pending + // else the UI is flickering + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.3 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ + self->restartConnection = NO; + + if (![self canReconnect]) + { + // if the event stream has not been restarted + // hide the spinner + [self removeReconnectingView]; + } + // else wait that onSyncNotification is called. + }); + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.xib new file mode 100644 index 000000000..fad906339 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.xib @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.h new file mode 100644 index 000000000..f30cd3d79 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.h @@ -0,0 +1,104 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewController.h" + +@class MXKLanguagePickerViewController; + + /** + `MXKLanguagePickerViewController` delegate. + */ + @protocol MXKLanguagePickerViewControllerDelegate + + /** + Tells the delegate that the user has selected a language. + + @param languagePickerViewController the `MXKLanguagePickerViewController` instance. + @param language the ISO language code. nil means use the language chosen by the OS. + */ + - (void)languagePickerViewController:(MXKLanguagePickerViewController*)languagePickerViewController didSelectLangugage:(NSString*)language; + + @end + +/** + 'MXKLanguagePickerViewController' instance displays the list of languages. + For the moment, it displays only languages available in the application bundle. + */ +@interface MXKLanguagePickerViewController : MXKTableViewController + +/** +The searchController used to manage search. +*/ +@property (nonatomic, strong) UISearchController *searchController; + +/** + The delegate for the view controller. + */ +@property (nonatomic, weak) id delegate; + +/** + The language marked in the list. + @"" by default. + */ +@property (nonatomic) NSString *selectedLanguage; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKLanguagePickerViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `listViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKLanguagePickerViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKLanguagePickerViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)languagePickerViewController; + +/** + Get the description string of a language defined by its ISO country code. + The description is localised in this language. + + @param language the ISO country code of the language (ex: "en"). + @return its description (ex: "English"). + */ ++ (NSString *)languageDescription:(NSString*)language; + +/** + Get the localised description string of a language defined by its ISO country code. + + @param language the ISO country code of the language (ex: "en"). + @return its localised description (ex: "Anglais" on a device running in French). + */ ++ (NSString *)languageLocalisedDescription:(NSString *)language; + +/** + Get the ISO country code of the language selected by the OS according to + the device language and languages available in the app bundle. + */ ++ (NSString *)defaultLanguage; + +@end + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.m new file mode 100644 index 000000000..92d1a065c --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.m @@ -0,0 +1,308 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKLanguagePickerViewController.h" + +@import libPhoneNumber_iOS; + +#import "NSBundle+MatrixKit.h" +#import "MXKSwiftHeader.h" + +NSString* const kMXKLanguagePickerViewControllerCellId = @"kMXKLanguagePickerViewControllerCellId"; + +NSString* const kMXKLanguagePickerCellDataKeyText = @"text"; +NSString* const kMXKLanguagePickerCellDataKeyDetailText = @"detailText"; +NSString* const kMXKLanguagePickerCellDataKeyLanguage = @"language"; + +@interface MXKLanguagePickerViewController () +{ + NSMutableArray *cellDataArray; + NSMutableArray *filteredCellDataArray; + + NSString *previousSearchPattern; +} + +@end + +@implementation MXKLanguagePickerViewController + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKLanguagePickerViewController class]) + bundle:[NSBundle bundleForClass:[MXKLanguagePickerViewController class]]]; +} + ++ (instancetype)languagePickerViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKLanguagePickerViewController class]) + bundle:[NSBundle bundleForClass:[MXKLanguagePickerViewController class]]]; +} + ++ (NSString *)languageDescription:(NSString *)language +{ + NSLocale *locale = [[NSLocale alloc] initWithLocaleIdentifier:language]; + + return [locale displayNameForKey:NSLocaleIdentifier value:language]; +} + ++ (NSString *)languageLocalisedDescription:(NSString *)language +{ + NSLocale *locale = [[NSLocale alloc] initWithLocaleIdentifier:[NSBundle mainBundle].preferredLocalizations.firstObject]; + + return [locale displayNameForKey:NSLocaleIdentifier value:language]; +} + ++ (NSString *)defaultLanguage +{ + return [NSBundle mainBundle].preferredLocalizations.firstObject; +} + +- (void)finalizeInit +{ + [super finalizeInit]; + + cellDataArray = [NSMutableArray array]; + filteredCellDataArray = nil; + + previousSearchPattern = nil; + + // Populate cellDataArray + // Start by the default language chosen by the OS + NSString *defaultLanguage = [MXKLanguagePickerViewController defaultLanguage]; + NSString *languageDescription = [MatrixKitL10n languagePickerDefaultLanguage:[MXKLanguagePickerViewController languageDescription:defaultLanguage]]; + + [cellDataArray addObject:@{ + kMXKLanguagePickerCellDataKeyText:languageDescription + }]; + + // Then, add languages available in the app bundle + NSArray *localizations = [[NSBundle mainBundle] localizations]; + for (NSString *language in localizations) + { + // Do not duplicate the default lang + if (![language isEqualToString:defaultLanguage]) + { + languageDescription = [MXKLanguagePickerViewController languageDescription:language]; + NSString *localisedLanguageDescription = [MXKLanguagePickerViewController languageLocalisedDescription:language]; + + // Capitalise the description in the language locale + NSLocale *locale = [[NSLocale alloc] initWithLocaleIdentifier:language]; + languageDescription = [languageDescription capitalizedStringWithLocale:locale]; + localisedLanguageDescription = [localisedLanguageDescription capitalizedStringWithLocale:locale]; + + if (languageDescription) + { + [cellDataArray addObject:@{ + kMXKLanguagePickerCellDataKeyText: languageDescription, + kMXKLanguagePickerCellDataKeyDetailText: localisedLanguageDescription, + kMXKLanguagePickerCellDataKeyLanguage: language + }]; + } + } + } + + // Default to "" in order to differentiate it from nil + _selectedLanguage = @""; +} + +- (void)destroy +{ + [super destroy]; + + cellDataArray = nil; + filteredCellDataArray = nil; + + previousSearchPattern = nil; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!self.tableView) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + [self setupSearchController]; + + self.navigationItem.title = [MatrixKitL10n languagePickerTitle]; + +} + +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + + self.navigationItem.hidesSearchBarWhenScrolling = YES; +} + +#pragma mark - Private + +- (void)setupSearchController +{ + UISearchController *searchController = [[UISearchController alloc] + initWithSearchResultsController:nil]; + searchController.dimsBackgroundDuringPresentation = NO; + searchController.hidesNavigationBarDuringPresentation = NO; + searchController.searchResultsUpdater = self; + + // Search bar is hidden for the moment, uncomment following line to enable it. + // TODO: Enable it once we have enough translations to fill pages and pages + // self.navigationItem.searchController = searchController; + // Make the search bar visible on first view appearance + self.navigationItem.hidesSearchBarWhenScrolling = NO; + + self.definesPresentationContext = YES; + + self.searchController = searchController; +} + +#pragma mark - UITableView dataSource + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + if (filteredCellDataArray) + { + return filteredCellDataArray.count; + } + return cellDataArray.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:kMXKLanguagePickerViewControllerCellId]; + if (!cell) + { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:kMXKLanguagePickerViewControllerCellId]; + } + + NSInteger index = indexPath.row; + NSDictionary *itemCellData; + + if (filteredCellDataArray) + { + if (index < filteredCellDataArray.count) + { + itemCellData = filteredCellDataArray[index]; + } + } + else if (index < cellDataArray.count) + { + itemCellData = cellDataArray[index]; + } + + if (itemCellData) + { + cell.textLabel.text = itemCellData[kMXKLanguagePickerCellDataKeyText]; + cell.detailTextLabel.text = itemCellData[kMXKLanguagePickerCellDataKeyDetailText]; + + // Mark the cell with the selected language + if (_selectedLanguage == itemCellData[kMXKLanguagePickerCellDataKeyLanguage] || [_selectedLanguage isEqualToString:itemCellData[kMXKLanguagePickerCellDataKeyLanguage]]) + { + cell.accessoryType = UITableViewCellAccessoryCheckmark; + } + else + { + cell.accessoryType = UITableViewCellAccessoryNone; + } + } + + return cell; +} + +#pragma mark - UITableView delegate + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + + if (self.delegate) + { + NSInteger index = indexPath.row; + NSString *language; + + if (filteredCellDataArray) + { + if (index < filteredCellDataArray.count) + { + language = filteredCellDataArray[index][kMXKLanguagePickerCellDataKeyLanguage]; + } + } + else if (index < cellDataArray.count) + { + language = cellDataArray[index][kMXKLanguagePickerCellDataKeyLanguage]; + } + + [self.delegate languagePickerViewController:self didSelectLangugage:language]; + } +} + +#pragma mark - UISearchResultsUpdating + +- (void)updateSearchResultsForSearchController:(UISearchController *)searchController +{ + NSString *searchText = searchController.searchBar.text; + + if (searchText.length) + { + searchText = [searchText lowercaseString]; + + if (previousSearchPattern && [searchText hasPrefix:previousSearchPattern]) + { + for (NSUInteger index = 0; index < filteredCellDataArray.count;) + { + NSString *text = [filteredCellDataArray[index][kMXKLanguagePickerCellDataKeyText] lowercaseString]; + + if ([text hasPrefix:searchText] == NO) + { + [filteredCellDataArray removeObjectAtIndex:index]; + } + else + { + index++; + } + } + } + else + { + filteredCellDataArray = [NSMutableArray array]; + + for (NSUInteger index = 0; index < cellDataArray.count; index++) + { + NSString *text = [cellDataArray[index][kMXKLanguagePickerCellDataKeyText] lowercaseString]; + + if ([text hasPrefix:searchText]) + { + [filteredCellDataArray addObject:cellDataArray[index]]; + } + } + } + + previousSearchPattern = searchText; + } + else + { + previousSearchPattern = nil; + filteredCellDataArray = nil; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.xib new file mode 100644 index 000000000..2261ee450 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.xib @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKNotificationSettingsViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKNotificationSettingsViewController.h new file mode 100644 index 000000000..e8ff33015 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKNotificationSettingsViewController.h @@ -0,0 +1,34 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKTableViewController.h" +#import "MXKAccount.h" + +/** + 'MXKNotificationSettingsViewController' instance may be used to display the notification settings (account's push rules). + Presently only the Global notification settings are supported. + */ +@interface MXKNotificationSettingsViewController : MXKTableViewController + +/** + The account who owns the displayed notification settings. + */ +@property (nonatomic) MXKAccount *mxAccount; + +@end + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKNotificationSettingsViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKNotificationSettingsViewController.m new file mode 100644 index 000000000..156b0c9c3 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKNotificationSettingsViewController.m @@ -0,0 +1,637 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKNotificationSettingsViewController.h" + +#import "MXKTableViewCellWithButton.h" +#import "MXKPushRuleTableViewCell.h" +#import "MXKPushRuleCreationTableViewCell.h" +#import "MXKTableViewCellWithTextView.h" + +#import "MXKConstants.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +#define MXKNOTIFICATIONSETTINGS_SECTION_INTRO_INDEX 0 +#define MXKNOTIFICATIONSETTINGS_SECTION_PER_WORD_INDEX 1 +#define MXKNOTIFICATIONSETTINGS_SECTION_PER_ROOM_INDEX 2 +#define MXKNOTIFICATIONSETTINGS_SECTION_PER_SENDER_INDEX 3 +#define MXKNOTIFICATIONSETTINGS_SECTION_OTHERS_INDEX 4 +#define MXKNOTIFICATIONSETTINGS_SECTION_DEFAULT_INDEX 5 +#define MXKNOTIFICATIONSETTINGS_SECTION_COUNT 6 + +@interface MXKNotificationSettingsViewController () +{ + /** + Handle master rule state + */ + UIButton *ruleMasterButton; + BOOL areAllDisabled; + + /** + */ + NSInteger contentRuleCreationIndex; + NSInteger roomRuleCreationIndex; + NSInteger senderRuleCreationIndex; + + /** + Predefined rules index + */ + NSInteger ruleContainsUserNameIndex; + NSInteger ruleContainsDisplayNameIndex; + NSInteger ruleOneToOneRoomIndex; + NSInteger ruleInviteForMeIndex; + NSInteger ruleMemberEventIndex; + NSInteger ruleCallIndex; + NSInteger ruleSuppressBotsNotificationsIndex; + + /** + Notification center observers + */ + id notificationCenterWillUpdateObserver; + id notificationCenterDidUpdateObserver; + id notificationCenterDidFailObserver; +} + +@end + +@implementation MXKNotificationSettingsViewController + +- (void)finalizeInit +{ + [super finalizeInit]; +} + +- (void)dealloc +{ + ruleMasterButton = nil; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + // Do any additional setup after loading the view, typically from a nib. +} + +- (void)didReceiveMemoryWarning +{ + [super didReceiveMemoryWarning]; + // Dispose of any resources that can be recreated. +} + +- (void)destroy +{ + if (notificationCenterWillUpdateObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:notificationCenterWillUpdateObserver]; + notificationCenterWillUpdateObserver = nil; + } + + if (notificationCenterDidUpdateObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:notificationCenterDidUpdateObserver]; + notificationCenterDidUpdateObserver = nil; + } + + if (notificationCenterDidFailObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:notificationCenterDidFailObserver]; + notificationCenterDidFailObserver = nil; + } + + [super destroy]; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + if (_mxAccount) + { + [self startActivityIndicator]; + + // Refresh existing notification rules + [_mxAccount.mxSession.notificationCenter refreshRules:^{ + + [self stopActivityIndicator]; + [self.tableView reloadData]; + + } failure:^(NSError *error) { + + [self stopActivityIndicator]; + + }]; + + notificationCenterWillUpdateObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXNotificationCenterWillUpdateRules object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) { + [self startActivityIndicator]; + }]; + + notificationCenterDidUpdateObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXNotificationCenterDidUpdateRules object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) { + [self stopActivityIndicator]; + [self.tableView reloadData]; + }]; + + notificationCenterDidFailObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXNotificationCenterDidFailRulesUpdate object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) { + [self stopActivityIndicator]; + + // Notify MatrixKit user + NSString *myUserId = self.mxAccount.mxCredentials.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:note.userInfo[kMXNotificationCenterErrorKey] userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + }]; + } + + // Refresh display + [self.tableView reloadData]; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + if (notificationCenterWillUpdateObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:notificationCenterWillUpdateObserver]; + notificationCenterWillUpdateObserver = nil; + } + + if (notificationCenterDidUpdateObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:notificationCenterDidUpdateObserver]; + notificationCenterDidUpdateObserver = nil; + } + + if (notificationCenterDidFailObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:notificationCenterDidFailObserver]; + notificationCenterDidFailObserver = nil; + } +} + +#pragma mark - Actions + +- (IBAction)onButtonPressed:(id)sender +{ + if (sender == ruleMasterButton) + { + // Swap enable state for all noticiations + MXPushRule *pushRule = [_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterDisableAllNotificationsRuleID]; + if (pushRule) + { + [_mxAccount.mxSession.notificationCenter enableRule:pushRule isEnabled:!areAllDisabled]; + } + } +} + +#pragma mark - UITableView data source + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView +{ + // Check master rule state + MXPushRule *pushRule = [_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterDisableAllNotificationsRuleID]; + if (pushRule.enabled) + { + areAllDisabled = YES; + return 1; + } + else + { + areAllDisabled = NO; + return MXKNOTIFICATIONSETTINGS_SECTION_COUNT; + } +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + NSInteger count = 0; + + if (section == MXKNOTIFICATIONSETTINGS_SECTION_INTRO_INDEX) + { + count = 2; + } + else if (section == MXKNOTIFICATIONSETTINGS_SECTION_PER_WORD_INDEX) + { + // A first cell will display a user information + count = 1; + + // Only removable content rules are listed in this section (we ignore here predefined rules) + for (MXPushRule *pushRule in _mxAccount.mxSession.notificationCenter.rules.global.content) + { + if (!pushRule.isDefault) + { + count++; + } + } + + // Add one item to suggest new rule creation + contentRuleCreationIndex = count ++; + } + else if (section == MXKNOTIFICATIONSETTINGS_SECTION_PER_ROOM_INDEX) + { + count = _mxAccount.mxSession.notificationCenter.rules.global.room.count; + + // Add one item to suggest new rule creation + roomRuleCreationIndex = count ++; + } + else if (section == MXKNOTIFICATIONSETTINGS_SECTION_PER_SENDER_INDEX) + { + count = _mxAccount.mxSession.notificationCenter.rules.global.sender.count; + + // Add one item to suggest new rule creation + senderRuleCreationIndex = count ++; + } + else if (section == MXKNOTIFICATIONSETTINGS_SECTION_OTHERS_INDEX) + { + ruleContainsUserNameIndex = ruleContainsDisplayNameIndex = ruleOneToOneRoomIndex = ruleInviteForMeIndex = ruleMemberEventIndex = ruleCallIndex = ruleSuppressBotsNotificationsIndex = -1; + + // Check whether each predefined rule is supported + if ([_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterContainUserNameRuleID]) + { + ruleContainsUserNameIndex = count++; + } + if ([_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterContainDisplayNameRuleID]) + { + ruleContainsDisplayNameIndex = count++; + } + if ([_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterOneToOneRoomRuleID]) + { + ruleOneToOneRoomIndex = count++; + } + if ([_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterInviteMeRuleID]) + { + ruleInviteForMeIndex = count++; + } + if ([_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterMemberEventRuleID]) + { + ruleMemberEventIndex = count++; + } + if ([_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterCallRuleID]) + { + ruleCallIndex = count++; + } + if ([_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterSuppressBotsNotificationsRuleID]) + { + ruleSuppressBotsNotificationsIndex = count++; + } + } + else if (section == MXKNOTIFICATIONSETTINGS_SECTION_DEFAULT_INDEX) + { + if ([_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterAllOtherRoomMessagesRuleID]) + { + count = 1; + } + } + + return count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UITableViewCell *cell = nil; + NSInteger rowIndex = indexPath.row; + + if (indexPath.section == MXKNOTIFICATIONSETTINGS_SECTION_INTRO_INDEX) + { + if (indexPath.row == 0) + { + MXKTableViewCellWithButton *masterBtnCell = [tableView dequeueReusableCellWithIdentifier:[MXKTableViewCellWithButton defaultReuseIdentifier]]; + if (!masterBtnCell) + { + masterBtnCell = [[MXKTableViewCellWithButton alloc] init]; + } + + if (areAllDisabled) + { + [masterBtnCell.mxkButton setTitle:[MatrixKitL10n notificationSettingsEnableNotifications] forState:UIControlStateNormal]; + [masterBtnCell.mxkButton setTitle:[MatrixKitL10n notificationSettingsEnableNotifications] forState:UIControlStateHighlighted]; + } + else + { + [masterBtnCell.mxkButton setTitle:[MatrixKitL10n notificationSettingsDisableAll] forState:UIControlStateNormal]; + [masterBtnCell.mxkButton setTitle:[MatrixKitL10n notificationSettingsDisableAll] forState:UIControlStateHighlighted]; + } + + [masterBtnCell.mxkButton addTarget:self action:@selector(onButtonPressed:) forControlEvents:UIControlEventTouchUpInside]; + + ruleMasterButton = masterBtnCell.mxkButton; + + cell = masterBtnCell; + } + else + { + MXKTableViewCellWithTextView *introCell = [tableView dequeueReusableCellWithIdentifier:[MXKTableViewCellWithTextView defaultReuseIdentifier]]; + if (!introCell) + { + introCell = [[MXKTableViewCellWithTextView alloc] init]; + } + + if (areAllDisabled) + { + introCell.mxkTextView.text = [MatrixKitL10n notificationSettingsEnableNotificationsWarning]; + introCell.mxkTextView.backgroundColor = [UIColor redColor]; + } + else + { + introCell.mxkTextView.text = [MatrixKitL10n notificationSettingsGlobalInfo]; + introCell.mxkTextView.backgroundColor = [UIColor clearColor]; + } + + introCell.mxkTextView.font = [UIFont systemFontOfSize:14]; + + cell = introCell; + } + } + else if (indexPath.section == MXKNOTIFICATIONSETTINGS_SECTION_PER_WORD_INDEX) + { + if (rowIndex == 0) + { + MXKTableViewCellWithTextView *introCell = [tableView dequeueReusableCellWithIdentifier:[MXKTableViewCellWithTextView defaultReuseIdentifier]]; + if (!introCell) + { + introCell = [[MXKTableViewCellWithTextView alloc] init]; + } + introCell.mxkTextView.text = [MatrixKitL10n notificationSettingsPerWordInfo]; + introCell.mxkTextView.font = [UIFont systemFontOfSize:14]; + + cell = introCell; + } + else if (rowIndex == contentRuleCreationIndex) + { + MXKPushRuleCreationTableViewCell *pushRuleCreationCell = [tableView dequeueReusableCellWithIdentifier:[MXKPushRuleCreationTableViewCell defaultReuseIdentifier]]; + if (!pushRuleCreationCell) + { + pushRuleCreationCell = [[MXKPushRuleCreationTableViewCell alloc] init]; + } + + pushRuleCreationCell.mxSession = _mxAccount.mxSession; + pushRuleCreationCell.mxPushRuleKind = MXPushRuleKindContent; + cell = pushRuleCreationCell; + } + else + { + // Only removable content rules are listed in this section + NSInteger count = 0; + for (MXPushRule *pushRule in _mxAccount.mxSession.notificationCenter.rules.global.content) + { + if (!pushRule.isDefault) + { + count++; + + if (count == rowIndex) + { + MXKPushRuleTableViewCell *pushRuleCell = [tableView dequeueReusableCellWithIdentifier:[MXKPushRuleTableViewCell defaultReuseIdentifier]]; + if (!pushRuleCell) + { + pushRuleCell = [[MXKPushRuleTableViewCell alloc] init]; + } + + pushRuleCell.mxSession = _mxAccount.mxSession; + pushRuleCell.mxPushRule = pushRule; + + cell = pushRuleCell; + break; + } + } + } + } + } + else if (indexPath.section == MXKNOTIFICATIONSETTINGS_SECTION_PER_ROOM_INDEX) + { + if (rowIndex == roomRuleCreationIndex) + { + MXKPushRuleCreationTableViewCell *pushRuleCreationCell = [tableView dequeueReusableCellWithIdentifier:[MXKPushRuleCreationTableViewCell defaultReuseIdentifier]]; + if (!pushRuleCreationCell) + { + pushRuleCreationCell = [[MXKPushRuleCreationTableViewCell alloc] init]; + } + + pushRuleCreationCell.mxSession = _mxAccount.mxSession; + pushRuleCreationCell.mxPushRuleKind = MXPushRuleKindRoom; + cell = pushRuleCreationCell; + } + else if (rowIndex < _mxAccount.mxSession.notificationCenter.rules.global.room.count) + { + MXKPushRuleTableViewCell *pushRuleCell = [tableView dequeueReusableCellWithIdentifier:[MXKPushRuleTableViewCell defaultReuseIdentifier]]; + if (!pushRuleCell) + { + pushRuleCell = [[MXKPushRuleTableViewCell alloc] init]; + } + + pushRuleCell.mxSession = _mxAccount.mxSession; + pushRuleCell.mxPushRule = [_mxAccount.mxSession.notificationCenter.rules.global.room objectAtIndex:rowIndex]; + + cell = pushRuleCell; + } + } + else if (indexPath.section == MXKNOTIFICATIONSETTINGS_SECTION_PER_SENDER_INDEX) + { + if (rowIndex == senderRuleCreationIndex) + { + MXKPushRuleCreationTableViewCell *pushRuleCreationCell = [tableView dequeueReusableCellWithIdentifier:[MXKPushRuleCreationTableViewCell defaultReuseIdentifier]]; + if (!pushRuleCreationCell) + { + pushRuleCreationCell = [[MXKPushRuleCreationTableViewCell alloc] init]; + } + + pushRuleCreationCell.mxSession = _mxAccount.mxSession; + pushRuleCreationCell.mxPushRuleKind = MXPushRuleKindSender; + cell = pushRuleCreationCell; + } + else if (rowIndex < _mxAccount.mxSession.notificationCenter.rules.global.sender.count) + { + MXKPushRuleTableViewCell *pushRuleCell = [tableView dequeueReusableCellWithIdentifier:[MXKPushRuleTableViewCell defaultReuseIdentifier]]; + if (!pushRuleCell) + { + pushRuleCell = [[MXKPushRuleTableViewCell alloc] init]; + } + + pushRuleCell.mxSession = _mxAccount.mxSession; + pushRuleCell.mxPushRule = [_mxAccount.mxSession.notificationCenter.rules.global.sender objectAtIndex:rowIndex]; + + cell = pushRuleCell; + } + } + else if (indexPath.section == MXKNOTIFICATIONSETTINGS_SECTION_OTHERS_INDEX) + { + MXPushRule *pushRule; + NSString *ruleDescription; + + if (rowIndex == ruleContainsUserNameIndex) + { + pushRule = [_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterContainUserNameRuleID]; + ruleDescription = [MatrixKitL10n notificationSettingsContainMyUserName]; + } + if (rowIndex == ruleContainsDisplayNameIndex) + { + pushRule = [_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterContainDisplayNameRuleID]; + ruleDescription = [MatrixKitL10n notificationSettingsContainMyDisplayName]; + } + if (rowIndex == ruleOneToOneRoomIndex) + { + pushRule = [_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterOneToOneRoomRuleID]; + ruleDescription = [MatrixKitL10n notificationSettingsJustSentToMe]; + } + if (rowIndex == ruleInviteForMeIndex) + { + pushRule = [_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterInviteMeRuleID]; + ruleDescription = [MatrixKitL10n notificationSettingsInviteToANewRoom]; + } + if (rowIndex == ruleMemberEventIndex) + { + pushRule = [_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterMemberEventRuleID]; + ruleDescription = [MatrixKitL10n notificationSettingsPeopleJoinLeaveRooms]; + } + if (rowIndex == ruleCallIndex) + { + pushRule = [_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterCallRuleID]; + ruleDescription = [MatrixKitL10n notificationSettingsReceiveACall]; + } + if (rowIndex == ruleSuppressBotsNotificationsIndex) + { + pushRule = [_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterSuppressBotsNotificationsRuleID]; + ruleDescription = [MatrixKitL10n notificationSettingsSuppressFromBots]; + } + + if (pushRule) + { + MXKPushRuleTableViewCell *pushRuleCell = [tableView dequeueReusableCellWithIdentifier:[MXKPushRuleTableViewCell defaultReuseIdentifier]]; + if (!pushRuleCell) + { + pushRuleCell = [[MXKPushRuleTableViewCell alloc] init]; + } + + pushRuleCell.mxSession = _mxAccount.mxSession; + pushRuleCell.mxPushRule = pushRule; + pushRuleCell.ruleDescription.text = ruleDescription; + + cell = pushRuleCell; + } + } + else if (indexPath.section == MXKNOTIFICATIONSETTINGS_SECTION_DEFAULT_INDEX) + { + MXPushRule *pushRule = [_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterAllOtherRoomMessagesRuleID]; + + if (pushRule) + { + MXKPushRuleTableViewCell *pushRuleCell = [tableView dequeueReusableCellWithIdentifier:[MXKPushRuleTableViewCell defaultReuseIdentifier]]; + if (!pushRuleCell) + { + pushRuleCell = [[MXKPushRuleTableViewCell alloc] init]; + } + + pushRuleCell.mxSession = _mxAccount.mxSession; + pushRuleCell.mxPushRule = pushRule; + pushRuleCell.ruleDescription.text = [MatrixKitL10n notificationSettingsNotifyAllOther]; + + cell = pushRuleCell; + } + } + else + { + // Return a fake cell to prevent app from crashing. + cell = [[UITableViewCell alloc] init]; + } + + return cell; +} + +#pragma mark - UITableView delegate + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (indexPath.section == MXKNOTIFICATIONSETTINGS_SECTION_INTRO_INDEX && indexPath.row == 1) + { + UITextView *textView = [[UITextView alloc] initWithFrame:CGRectMake(0, 0, tableView.frame.size.width, MAXFLOAT)]; + textView.font = [UIFont systemFontOfSize:14]; + textView.text = areAllDisabled ? [MatrixKitL10n notificationSettingsEnableNotificationsWarning] : [MatrixKitL10n notificationSettingsGlobalInfo]; + CGSize contentSize = [textView sizeThatFits:textView.frame.size]; + return contentSize.height + 1; + } + + if (indexPath.section == MXKNOTIFICATIONSETTINGS_SECTION_PER_WORD_INDEX) + { + if (indexPath.row == 0) + { + UITextView *textView = [[UITextView alloc] initWithFrame:CGRectMake(0, 0, tableView.frame.size.width, MAXFLOAT)]; + textView.font = [UIFont systemFontOfSize:14]; + textView.text = [MatrixKitL10n notificationSettingsPerWordInfo]; + CGSize contentSize = [textView sizeThatFits:textView.frame.size]; + return contentSize.height + 1; + } + else if (indexPath.row == contentRuleCreationIndex) + { + return 120; + } + } + + if (indexPath.section == MXKNOTIFICATIONSETTINGS_SECTION_PER_ROOM_INDEX && indexPath.row == roomRuleCreationIndex) + { + return 120; + } + + if (indexPath.section == MXKNOTIFICATIONSETTINGS_SECTION_PER_SENDER_INDEX && indexPath.row == senderRuleCreationIndex) + { + return 120; + } + + return 50; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section +{ + if (section != MXKNOTIFICATIONSETTINGS_SECTION_INTRO_INDEX) + { + return 30; + } + return 0; +} + +- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section +{ + UIView *sectionHeader = [[UIView alloc] initWithFrame:[tableView rectForHeaderInSection:section]]; + sectionHeader.backgroundColor = [UIColor colorWithRed:0.9 green:0.9 blue:0.9 alpha:1.0]; + UILabel *sectionLabel = [[UILabel alloc] initWithFrame:CGRectMake(5, 5, sectionHeader.frame.size.width - 10, sectionHeader.frame.size.height - 10)]; + sectionLabel.font = [UIFont boldSystemFontOfSize:16]; + sectionLabel.backgroundColor = [UIColor clearColor]; + [sectionHeader addSubview:sectionLabel]; + + if (section == MXKNOTIFICATIONSETTINGS_SECTION_PER_WORD_INDEX) + { + sectionLabel.text = [MatrixKitL10n notificationSettingsPerWordNotifications]; + } + else if (section == MXKNOTIFICATIONSETTINGS_SECTION_PER_ROOM_INDEX) + { + sectionLabel.text = [MatrixKitL10n notificationSettingsPerRoomNotifications]; + } + else if (section == MXKNOTIFICATIONSETTINGS_SECTION_PER_SENDER_INDEX) + { + sectionLabel.text = [MatrixKitL10n notificationSettingsPerSenderNotifications]; + } + else if (section == MXKNOTIFICATIONSETTINGS_SECTION_OTHERS_INDEX) + { + sectionLabel.text = [MatrixKitL10n notificationSettingsOtherAlerts]; + } + else if (section == MXKNOTIFICATIONSETTINGS_SECTION_DEFAULT_INDEX) + { + sectionLabel.text = [MatrixKitL10n notificationSettingsByDefault]; + } + + return sectionHeader; +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKPreviewViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKPreviewViewController.h new file mode 100644 index 000000000..127baaa9b --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKPreviewViewController.h @@ -0,0 +1,74 @@ +/* + Copyright 2020 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +@protocol MXKPreviewViewControllerDelegate; + +/** + @brief A view controller that previews, opens, or prints files whose file format cannot be handled directly by your app. + + Use this class to present an appropriate user interface for previewing, opening, copying, or printing a specified file. For example, an email program might use this class to allow the user to preview attachments and open them in other apps. + + After presenting its user interface, a document interaction controller handles all interactions needed to support file preview and menu display. + + Unlike UIDocumentInteractionController, this view controller aims to be modal presented. + */ +@interface MXKPreviewViewController : UINavigationController + +/** + @brief presents a new instance of MXKPreviewViewController as modal. + + @param presenting view controller that presents the MXKPreviewViewController + @param fileUrl URL of the file. This URL should point to a local file. + @param allowActions YES to display actions Button. NO otherwise + @param delegate delegate (optional) that receives some events about the lifecycle of the MXKPreviewViewController + + @return the instance of MXKPreviewViewController + */ ++ (MXKPreviewViewController *)presentFrom:(nonnull UIViewController *)presenting + fileUrl: (nonnull NSURL *)fileUrl + allowActions: (BOOL)allowActions + delegate: (nullable id)delegate; + +@end + +/** + A set of methods you can implement to respond to messages from a preview controller. + */ +@protocol MXKPreviewViewControllerDelegate + +@optional + +/** + The MXKPreviewViewController will present the preview + + @param controller the instance of MXKPreviewViewController + */ +- (void)previewViewControllerWillBeginPreview:(MXKPreviewViewController *)controller; + +/** + The MXKPreviewViewController did end presenting the preview + + @param controller the instance of MXKPreviewViewController + */ +- (void)previewViewControllerDidEndPreview:(MXKPreviewViewController *)controller; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/MatrixKit/Controllers/MXKPreviewViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKPreviewViewController.m new file mode 100644 index 000000000..214ea6c17 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKPreviewViewController.m @@ -0,0 +1,104 @@ +/* + Copyright 2020 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKPreviewViewController.h" +@import QuickLook; + +@interface MXKPreviewViewController () + +/// A specialized view controller for previewing an item. +@property (nonatomic, weak) QLPreviewController *previewController; + +/// URL of the file to preview +@property (nonatomic, strong) NSURL *fileURL; + +/// YES to display actions Button. NO otherwise +@property (nonatomic) BOOL allowActions; + +@property (nonatomic, weak) id previewDelegate; + +@end + +@implementation MXKPreviewViewController + ++ (MXKPreviewViewController *)presentFrom:(UIViewController *)presenting fileUrl:(NSURL *)fileUrl allowActions:(BOOL)allowActions delegate:(nullable id)delegate +{ + MXKPreviewViewController *previewController = [[MXKPreviewViewController alloc] initWithFileUrl: fileUrl allowActions: allowActions]; + previewController.previewDelegate = delegate; + if ([delegate respondsToSelector:@selector(previewViewControllerWillBeginPreview:)]) { + [delegate previewViewControllerWillBeginPreview:previewController]; + } + [presenting presentViewController:previewController animated:YES completion:^{ + }]; + + return previewController; +} + +- (instancetype)initWithFileUrl: (NSURL *)fileUrl allowActions: (BOOL)allowActions +{ + QLPreviewController *previewController = [[QLPreviewController alloc] init]; + self = [super initWithRootViewController:previewController]; + self.previewController = previewController; + + if (self) + { + self.modalPresentationStyle = UIModalPresentationFullScreen; + self.fileURL = fileUrl; + self.allowActions = allowActions; + self.previewController.dataSource = self; + self.previewController.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone target:self action:@selector(doneAction:)]; + } + + return self; +} + +- (void)viewDidLayoutSubviews +{ + [super viewDidLayoutSubviews]; + + if (!self.allowActions) + { + NSMutableArray *items = [NSMutableArray arrayWithArray: self.previewController.navigationItem.rightBarButtonItems]; + if (items.count > 0) + { + [items removeObjectAtIndex:0]; + } + self.previewController.navigationItem.rightBarButtonItems = items; + } +} + +- (IBAction)doneAction:(id)sender +{ + [self dismissViewControllerAnimated:YES completion:^{ + if ([self.previewDelegate respondsToSelector:@selector(previewViewControllerDidEndPreview:)]) { + [self.previewDelegate previewViewControllerDidEndPreview:self]; + } + }]; +} + +#pragma mark - QLPreviewControllerDataSource + +- (NSInteger)numberOfPreviewItemsInPreviewController:(nonnull QLPreviewController *)controller +{ + return self.fileURL ? 1 : 0; +} + +- (nonnull id)previewController:(nonnull QLPreviewController *)controller previewItemAtIndex:(NSInteger)index +{ + return self.fileURL; +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.h new file mode 100644 index 000000000..da8d193f3 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.h @@ -0,0 +1,129 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +#import + +#import "MXKViewController.h" +#import "MXKRecentsDataSource.h" + +@class MXKRecentListViewController; + +/** + `MXKRecentListViewController` delegate. + */ +@protocol MXKRecentListViewControllerDelegate + +/** + Tells the delegate that the user selected a room. + + @param recentListViewController the `MXKRecentListViewController` instance. + @param roomId the id of the selected room. + @param mxSession the matrix session in which the room is defined. + */ +- (void)recentListViewController:(MXKRecentListViewController *)recentListViewController didSelectRoom:(NSString*)roomId inMatrixSession:(MXSession*)mxSession; + +/** + Tells the delegate that the user selected a suggested room. + + @param recentListViewController the `MXKRecentListViewController` instance. + @param childInfo the `MXSpaceChildInfo` instance that describes the selected room. + */ +-(void)recentListViewController:(MXKRecentListViewController *)recentListViewController didSelectSuggestedRoom:(MXSpaceChildInfo *)childInfo; + +@end + + +/** + This view controller displays a room list. + */ +@interface MXKRecentListViewController : MXKViewController +{ +@protected + + /** + The fake top view displayed in case of vertical bounce. + */ + __weak UIView *topview; +} + +@property (weak, nonatomic) IBOutlet UISearchBar *recentsSearchBar; +@property (weak, nonatomic) IBOutlet UITableView *recentsTableView; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *recentsSearchBarTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *recentsSearchBarHeightConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *recentsTableViewBottomConstraint; + +/** + The current data source associated to the view controller. + */ +@property (nonatomic, readonly) MXKRecentsDataSource *dataSource; + +/** + The delegate for the view controller. + */ +@property (nonatomic, weak) id delegate; + +/** + Enable the search option by adding a navigation item in the navigation bar (YES by default). + Set NO this property to disable this option and hide the related bar button. + */ +@property (nonatomic) BOOL enableBarButtonSearch; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKRecentListViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `recentListViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKRecentListViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKRecentListViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)recentListViewController; + +/** + Display the recents described in the provided data source. + + Note1: The provided data source will replace the current data source if any. The caller + should dispose properly this data source if it is not used anymore. + + Note2: You may provide here a MXKInterleavedRecentsDataSource instance to display interleaved recents. + + @param listDataSource the data source providing the recents list. + */ +- (void)displayList:(MXKRecentsDataSource*)listDataSource; + +/** + Refresh the recents table display. + */ +- (void)refreshRecentsTable; + +/** + Hide/show the search bar at the top of the recents table view. + */ +- (void)hideSearchBar:(BOOL)hidden; + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.m new file mode 100644 index 000000000..828f84825 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.m @@ -0,0 +1,624 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRecentListViewController.h" + +#import "MXKRoomDataSourceManager.h" + +#import "MXKInterleavedRecentsDataSource.h" +#import "MXKInterleavedRecentTableViewCell.h" + +#import "MXKSwiftHeader.h" + +@interface MXKRecentListViewController () +{ + /** + The data source providing UITableViewCells + */ + MXKRecentsDataSource *dataSource; + + /** + Search handling + */ + UIBarButtonItem *searchButton; + BOOL ignoreSearchRequest; + + /** + The reconnection animated view. + */ + __weak UIView* reconnectingView; + + /** + The current table view header if any. + */ + UIView* tableViewHeaderView; + + /** + The latest server sync date + */ + NSDate* latestServerSync; + + /** + The restart the event connnection + */ + BOOL restartConnection; +} + +@end + +@implementation MXKRecentListViewController +@synthesize dataSource; + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRecentListViewController class]) + bundle:[NSBundle bundleForClass:[MXKRecentListViewController class]]]; +} + ++ (instancetype)recentListViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKRecentListViewController class]) + bundle:[NSBundle bundleForClass:[MXKRecentListViewController class]]]; +} + +#pragma mark - + +- (void)finalizeInit +{ + [super finalizeInit]; + + _enableBarButtonSearch = YES; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!_recentsTableView) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + // Adjust search bar Top constraint to take into account potential navBar. + if (_recentsSearchBarTopConstraint) + { + _recentsSearchBarTopConstraint.active = NO; + _recentsSearchBarTopConstraint = [NSLayoutConstraint constraintWithItem:self.topLayoutGuide + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:self.recentsSearchBar + attribute:NSLayoutAttributeTop + multiplier:1.0f + constant:0.0f]; + + _recentsSearchBarTopConstraint.active = YES; + } + + // Adjust table view Bottom constraint to take into account tabBar. + if (_recentsTableViewBottomConstraint) + { + _recentsTableViewBottomConstraint.active = NO; + _recentsTableViewBottomConstraint = [NSLayoutConstraint constraintWithItem:self.bottomLayoutGuide + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self.recentsTableView + attribute:NSLayoutAttributeBottom + multiplier:1.0f + constant:0.0f]; + + _recentsTableViewBottomConstraint.active = YES; + } + + // Hide search bar by default + [self hideSearchBar:YES]; + + // Apply search option in navigation bar + self.enableBarButtonSearch = _enableBarButtonSearch; + + // Add an accessory view to the search bar in order to retrieve keyboard view. + self.recentsSearchBar.inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; + + // Finalize table view configuration + self.recentsTableView.delegate = self; + self.recentsTableView.dataSource = dataSource; // Note: dataSource may be nil here + + // Set up classes to use for cells + [self.recentsTableView registerNib:MXKRecentTableViewCell.nib forCellReuseIdentifier:MXKRecentTableViewCell.defaultReuseIdentifier]; + // Consider here the specific case where interleaved recents are supported + [self.recentsTableView registerNib:MXKInterleavedRecentTableViewCell.nib forCellReuseIdentifier:MXKInterleavedRecentTableViewCell.defaultReuseIdentifier]; + + // Add a top view which will be displayed in case of vertical bounce. + CGFloat height = self.recentsTableView.frame.size.height; + UIView *topview = [[UIView alloc] initWithFrame:CGRectMake(0,-height,self.recentsTableView.frame.size.width,height)]; + topview.autoresizingMask = UIViewAutoresizingFlexibleWidth; + topview.backgroundColor = [UIColor groupTableViewBackgroundColor]; + [self.recentsTableView addSubview:topview]; + self->topview = topview; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + // Restore search mechanism (if enabled) + ignoreSearchRequest = NO; + + // Observe server sync at room data source level too + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMatrixSessionChange) name:kMXKRoomDataSourceSyncStatusChanged object:nil]; + + // Observe the server sync + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onSyncNotification) name:kMXSessionDidSyncNotification object:nil]; + + // Do a full reload + [self refreshRecentsTable]; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + // The user may still press search button whereas the view disappears + ignoreSearchRequest = YES; + + // Leave potential search session + if (!self.recentsSearchBar.isHidden) + { + [self searchBarCancelButtonClicked:self.recentsSearchBar]; + } + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXKRoomDataSourceSyncStatusChanged object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidSyncNotification object:nil]; + + [self removeReconnectingView]; +} + +- (void)dealloc +{ + self.recentsSearchBar.inputAccessoryView = nil; + + searchButton = nil; +} + +- (void)didReceiveMemoryWarning +{ + [super didReceiveMemoryWarning]; + + // Dispose of any resources that can be recreated. +} + +#pragma mark - Override MXKViewController + +- (void)onMatrixSessionChange +{ + [super onMatrixSessionChange]; + + // Check whether no server sync is in progress in room data sources + NSArray *mxSessions = self.mxSessions; + for (MXSession *mxSession in mxSessions) + { + if ([MXKRoomDataSourceManager sharedManagerForMatrixSession:mxSession].isServerSyncInProgress) + { + // sync is in progress for at least one data source, keep running the loading wheel + [self.activityIndicator startAnimating]; + break; + } + } +} + +- (void)onKeyboardShowAnimationComplete +{ + // Report the keyboard view in order to track keyboard frame changes + self.keyboardView = _recentsSearchBar.inputAccessoryView.superview; +} + +- (void)setKeyboardHeight:(CGFloat)keyboardHeight +{ + // Deduce the bottom constraint for the table view (Don't forget the potential tabBar) + CGFloat tableViewBottomConst = keyboardHeight - self.bottomLayoutGuide.length; + // Check whether the keyboard is over the tabBar + if (tableViewBottomConst < 0) + { + tableViewBottomConst = 0; + } + + // Update constraints + _recentsTableViewBottomConstraint.constant = tableViewBottomConst; + + // Force layout immediately to take into account new constraint + [self.view layoutIfNeeded]; +} + +- (void)destroy +{ + self.recentsTableView.dataSource = nil; + self.recentsTableView.delegate = nil; + self.recentsTableView = nil; + + dataSource.delegate = nil; + dataSource = nil; + + _delegate = nil; + + [topview removeFromSuperview]; + topview = nil; + + [super destroy]; +} + +#pragma mark - + +- (void)setEnableBarButtonSearch:(BOOL)enableBarButtonSearch +{ + _enableBarButtonSearch = enableBarButtonSearch; + + if (enableBarButtonSearch) + { + if (!searchButton) + { + searchButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemSearch target:self action:@selector(search:)]; + } + + // Add it in right bar items + NSArray *rightBarButtonItems = self.navigationItem.rightBarButtonItems; + self.navigationItem.rightBarButtonItems = rightBarButtonItems ? [rightBarButtonItems arrayByAddingObject:searchButton] : @[searchButton]; + } + else + { + NSMutableArray *rightBarButtonItems = [NSMutableArray arrayWithArray: self.navigationItem.rightBarButtonItems]; + [rightBarButtonItems removeObject:searchButton]; + self.navigationItem.rightBarButtonItems = rightBarButtonItems; + } +} + +- (void)displayList:(MXKRecentsDataSource *)listDataSource +{ + // Cancel registration on existing dataSource if any + if (dataSource) + { + dataSource.delegate = nil; + + // Remove associated matrix sessions + NSArray *mxSessions = self.mxSessions; + for (MXSession *mxSession in mxSessions) + { + [self removeMatrixSession:mxSession]; + } + } + + dataSource = listDataSource; + dataSource.delegate = self; + + // Report all matrix sessions at view controller level to update UI according to sessions state + NSArray *mxSessions = listDataSource.mxSessions; + for (MXSession *mxSession in mxSessions) + { + [self addMatrixSession:mxSession]; + } + + if (self.recentsTableView) + { + // Set up table data source + self.recentsTableView.dataSource = dataSource; + } +} + +- (void)refreshRecentsTable +{ + // For now, do a simple full reload + [self.recentsTableView reloadData]; +} + +- (void)hideSearchBar:(BOOL)hidden +{ + self.recentsSearchBar.hidden = hidden; + self.recentsSearchBarHeightConstraint.constant = hidden ? 0 : 44; + [self.view setNeedsUpdateConstraints]; +} + +#pragma mark - Action + +- (IBAction)search:(id)sender +{ + // The user may have pressed search button whereas the view controller was disappearing + if (ignoreSearchRequest) + { + return; + } + + if (self.recentsSearchBar.isHidden) + { + // Check whether there are data in which search + if ([self.dataSource numberOfSectionsInTableView:self.recentsTableView]) + { + [self hideSearchBar:NO]; + + // Create search bar + [self.recentsSearchBar becomeFirstResponder]; + } + } + else + { + [self searchBarCancelButtonClicked: self.recentsSearchBar]; + } +} + +#pragma mark - MXKDataSourceDelegate + +- (Class)cellViewClassForCellData:(MXKCellData*)cellData +{ + // Consider here the specific case where interleaved recents are supported + if ([dataSource isKindOfClass:MXKInterleavedRecentsDataSource.class]) + { + return MXKInterleavedRecentTableViewCell.class; + } + + // Return the default recent table view cell + return MXKRecentTableViewCell.class; +} + +- (NSString *)cellReuseIdentifierForCellData:(MXKCellData*)cellData +{ + // Consider here the specific case where interleaved recents are supported + if ([dataSource isKindOfClass:MXKInterleavedRecentsDataSource.class]) + { + return MXKInterleavedRecentTableViewCell.defaultReuseIdentifier; + } + + // Return the default recent table view cell + return MXKRecentTableViewCell.defaultReuseIdentifier; +} + +- (void)dataSource:(MXKDataSource *)dataSource didCellChange:(id)changes +{ + // For now, do a simple full reload + [self refreshRecentsTable]; +} + +- (void)dataSource:(MXKDataSource *)dataSource didAddMatrixSession:(MXSession *)mxSession +{ + [self addMatrixSession:mxSession]; +} + +- (void)dataSource:(MXKDataSource *)dataSource didRemoveMatrixSession:(MXSession *)mxSession +{ + [self removeMatrixSession:mxSession]; +} + +#pragma mark - UITableView delegate +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + return [dataSource cellHeightAtIndexPath:indexPath]; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section +{ + // Section header is required only when several recent lists are displayed. + if (self.dataSource.displayedRecentsDataSourcesCount > 1) + { + return 35; + } + return 0; +} + +- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section +{ + // Let dataSource provide the section header. + return [dataSource viewForHeaderInSection:section withFrame:[tableView rectForHeaderInSection:section]]; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (_delegate) + { + UITableViewCell *selectedCell = [tableView cellForRowAtIndexPath:indexPath]; + + if ([selectedCell conformsToProtocol:@protocol(MXKCellRendering)]) + { + id cell = (id)selectedCell; + + if ([cell respondsToSelector:@selector(renderedCellData)]) + { + MXKCellData *cellData = cell.renderedCellData; + if ([cellData conformsToProtocol:@protocol(MXKRecentCellDataStoring)]) + { + id recentCellData = (id)cellData; + if (recentCellData.isSuggestedRoom) + { + [_delegate recentListViewController:self + didSelectSuggestedRoom:recentCellData.roomSummary.spaceChildInfo]; + } + else + { + [_delegate recentListViewController:self + didSelectRoom:recentCellData.roomIdentifier + inMatrixSession:recentCellData.mxSession]; + } + } + } + } + } + + // Hide the keyboard when user select a room + // do not hide the searchBar until the view controller disappear + // on tablets / iphone 6+, the user could expect to search again while looking at a room + [self.recentsSearchBar resignFirstResponder]; +} + +- (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath*)indexPath +{ + // Release here resources, and restore reusable cells + if ([cell respondsToSelector:@selector(didEndDisplay)]) + { + [(id)cell didEndDisplay]; + } +} + +- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset +{ + // Detect vertical bounce at the top of the tableview to trigger reconnection. + if (scrollView == _recentsTableView) + { + [self detectPullToKick:scrollView]; + } +} + +- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView +{ + if (scrollView == _recentsTableView) + { + [self managePullToKick:scrollView]; + } +} + +- (void)scrollViewDidScroll:(UIScrollView *)scrollView +{ + if (scrollView == _recentsTableView) + { + if (scrollView.contentOffset.y + scrollView.adjustedContentInset.top == 0) + { + [self managePullToKick:scrollView]; + } + } +} + +#pragma mark - UISearchBarDelegate + +- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText +{ + // Apply filter + if (searchText.length) + { + [self.dataSource searchWithPatterns:@[searchText]]; + } + else + { + [self.dataSource searchWithPatterns:nil]; + } +} + +- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar +{ + // "Done" key has been pressed + [searchBar resignFirstResponder]; +} + +- (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar +{ + // Leave search + [searchBar resignFirstResponder]; + + [self hideSearchBar:YES]; + + self.recentsSearchBar.text = nil; + + // Refresh display + [self.dataSource searchWithPatterns:nil]; +} + +#pragma mark - resync management + +- (void)onSyncNotification +{ + latestServerSync = [NSDate date]; + [self removeReconnectingView]; +} + +- (BOOL)canReconnect +{ + // avoid restarting connection if some data has been received within 1 second (1000 : latestServerSync is null) + NSTimeInterval interval = latestServerSync ? [[NSDate date] timeIntervalSinceDate:latestServerSync] : 1000; + return (interval > 1) && [self.mainSession reconnect]; +} + +- (void)addReconnectingView +{ + if (!reconnectingView) + { + UIActivityIndicatorView* spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge]; + spinner.transform = CGAffineTransformMakeScale(0.75f, 0.75f); + CGRect frame = spinner.frame; + frame.size.height = 80; // 80 * 0.75 = 60 + spinner.bounds = frame; + spinner.color = [UIColor darkGrayColor]; + spinner.hidesWhenStopped = NO; + spinner.backgroundColor = _recentsTableView.backgroundColor; + [spinner startAnimating]; + + // no need to manage constraints here, IOS defines them. + tableViewHeaderView = _recentsTableView.tableHeaderView; + _recentsTableView.tableHeaderView = reconnectingView = spinner; + } +} + +- (void)removeReconnectingView +{ + if (reconnectingView && !restartConnection) + { + _recentsTableView.tableHeaderView = tableViewHeaderView; + reconnectingView = nil; + } +} + +/** + Detect if the current connection must be restarted. + The spinner is displayed until the overscroll ends (and scrollViewDidEndDecelerating is called). + */ +- (void)detectPullToKick:(UIScrollView *)scrollView +{ + if (!reconnectingView) + { + // detect if the user scrolls over the tableview top + restartConnection = (scrollView.contentOffset.y + scrollView.adjustedContentInset.top < -128); + + if (restartConnection) + { + // wait that list decelerate to display / hide it + [self addReconnectingView]; + } + } +} + +/** + Restarts the current connection if it is required. + The 0.3s delay is added to avoid flickering if the connection does not require to be restarted. + */ +- (void)managePullToKick:(UIScrollView *)scrollView +{ + // the current connection must be restarted + if (restartConnection) + { + // display at least 0.3s the spinner to show to the user that something is pending + // else the UI is flickering + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.3 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ + self->restartConnection = NO; + + if (![self canReconnect]) + { + // if the event stream has not been restarted + // hide the spinner + [self removeReconnectingView]; + } + // else wait that onSyncNotification is called. + }); + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.xib new file mode 100644 index 000000000..441694c1f --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.xib @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.h new file mode 100644 index 000000000..6724f6998 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.h @@ -0,0 +1,212 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKViewController.h" +#import "MXKImageView.h" + +/** + Available actions on room member + */ +typedef enum : NSUInteger +{ + MXKRoomMemberDetailsActionInvite, + MXKRoomMemberDetailsActionLeave, + MXKRoomMemberDetailsActionKick, + MXKRoomMemberDetailsActionBan, + MXKRoomMemberDetailsActionUnban, + MXKRoomMemberDetailsActionIgnore, + MXKRoomMemberDetailsActionUnignore, + MXKRoomMemberDetailsActionSetDefaultPowerLevel, + MXKRoomMemberDetailsActionSetModerator, + MXKRoomMemberDetailsActionSetAdmin, + MXKRoomMemberDetailsActionSetCustomPowerLevel, + MXKRoomMemberDetailsActionStartChat, + MXKRoomMemberDetailsActionStartVoiceCall, + MXKRoomMemberDetailsActionStartVideoCall, + MXKRoomMemberDetailsActionMention, + MXKRoomMemberDetailsActionSecurity, + MXKRoomMemberDetailsActionSecurityInformation + +} MXKRoomMemberDetailsAction; + +@class MXKRoomMemberDetailsViewController; + +/** + `MXKRoomMemberDetailsViewController` delegate. + */ +@protocol MXKRoomMemberDetailsViewControllerDelegate + +/** + Tells the delegate that the user wants to start a one-to-one chat with the room member. + + @param roomMemberDetailsViewController the `MXKRoomMemberDetailsViewController` instance. + @param matrixId the member's matrix id + @param completion the block to execute at the end of the operation (independently if it succeeded or not). + */ +- (void)roomMemberDetailsViewController:(MXKRoomMemberDetailsViewController *)roomMemberDetailsViewController startChatWithMemberId:(NSString*)matrixId completion:(void (^)(void))completion; + +@optional +/** + Tells the delegate that the user wants to mention the room member. + + @discussion the `MXKRoomMemberDetailsViewController` instance is withdrawn automatically. + + @param roomMemberDetailsViewController the `MXKRoomMemberDetailsViewController` instance. + @param member the room member to mention. + */ +- (void)roomMemberDetailsViewController:(MXKRoomMemberDetailsViewController *)roomMemberDetailsViewController mention:(MXRoomMember*)member; + +/** + Tells the delegate that the user wants to place a voip call with the room member. + + @param roomMemberDetailsViewController the `MXKRoomMemberDetailsViewController` instance. + @param matrixId the member's matrix id + @param isVideoCall the type of the call: YES for video call / NO for voice call. + */ +- (void)roomMemberDetailsViewController:(MXKRoomMemberDetailsViewController *)roomMemberDetailsViewController placeVoipCallWithMemberId:(NSString*)matrixId andVideo:(BOOL)isVideoCall; + +@end + +/** + Whereas the main item of this view controller is a table view, the 'MXKRoomMemberDetailsViewController' class inherits + from 'MXKViewController' instead of 'MXKTableViewController' in order to ease the customization. + Indeed some items like header may be added at the same level than the table. + */ +@interface MXKRoomMemberDetailsViewController : MXKViewController +{ +@protected + /** + Current alert (if any). + */ + UIAlertController *currentAlert; + + /** + List of the allowed actions on this member. + */ + NSMutableArray *actionsArray; +} + +@property (weak, nonatomic) IBOutlet UITableView *tableView; + +@property (weak, nonatomic) IBOutlet MXKImageView *memberThumbnail; +@property (weak, nonatomic) IBOutlet UITextView *roomMemberMatrixInfo; + +/** + The default account picture displayed when no picture is defined. + */ +@property (nonatomic) UIImage *picturePlaceholder; + +/** + The displayed member and the corresponding room + */ +@property (nonatomic, readonly) MXRoomMember *mxRoomMember; +@property (nonatomic, readonly) MXRoom *mxRoom; +@property (nonatomic, readonly) MXEventTimeline *mxRoomLiveTimeline; + +/** + Enable mention option. NO by default + */ +@property (nonatomic) BOOL enableMention; + +/** + Enable voip call (voice/video). NO by default + */ +@property (nonatomic) BOOL enableVoipCall; + +/** + Enable leave this room. YES by default + */ +@property (nonatomic) BOOL enableLeave; + +/** + Tell whether an action is already in progress. + */ +@property (nonatomic, readonly) BOOL hasPendingAction; + +/** + The delegate for the view controller. + */ +@property (nonatomic, weak) id delegate; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKRoomMemberDetailsViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `roomMemberDetailsViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKRoomMemberDetailsViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKRoomMemberDetailsViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)roomMemberDetailsViewController; + +/** + Set the room member to display. Provide the actual room in order to handle member changes. + + @param roomMember the matrix room member + @param room the matrix room to which this member belongs. + */ +- (void)displayRoomMember:(MXRoomMember*)roomMember withMatrixRoom:(MXRoom*)room; + +/** + Refresh the member information. + */ +- (void)updateMemberInfo; + +/** + The following method is registered on `UIControlEventTouchUpInside` event for all displayed action buttons. + + The start chat and mention options are transferred to the delegate. + All the other actions are handled by the current implementation. + + If the delegate responds to selector: @selector(roomMemberDetailsViewController:placeVoipCallWithMemberId:andVideo:), the voip options + are transferred to the delegate. + */ +- (IBAction)onActionButtonPressed:(id)sender; + +/** + Set the power level of the room member + + @param value the value to set. + @param promptUser prompt the user if they ops a member with the same power level. + */ +- (void)setPowerLevel:(NSInteger)value promptUser:(BOOL)promptUser; + +/** + Add a mask in overlay to prevent a new contact selection (used when an action is on progress). + */ +- (void)addPendingActionMask; + +/** + Remove the potential overlay mask + */ +- (void)removePendingActionMask; + +@end + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.m new file mode 100644 index 000000000..67623bc18 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.m @@ -0,0 +1,1037 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomMemberDetailsViewController.h" + +@import MatrixSDK.MXMediaManager; + +#import "MXKTableViewCellWithButtons.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKAppSettings.h" + +#import "MXKConstants.h" + +#import "MXKSwiftHeader.h" + +@interface MXKRoomMemberDetailsViewController () +{ + id membersListener; + + // mask view while processing a request + UIActivityIndicatorView * pendingMaskSpinnerView; + + // Observe left rooms + id leaveRoomNotificationObserver; + + // Observe kMXRoomDidFlushDataNotification to take into account the updated room members when the room history is flushed. + id roomDidFlushDataNotificationObserver; + + // Cache for the room live timeline + MXEventTimeline *mxRoomLiveTimeline; +} + +@end + +@implementation MXKRoomMemberDetailsViewController +@synthesize mxRoom; + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRoomMemberDetailsViewController class]) + bundle:[NSBundle bundleForClass:[MXKRoomMemberDetailsViewController class]]]; +} + ++ (instancetype)roomMemberDetailsViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKRoomMemberDetailsViewController class]) + bundle:[NSBundle bundleForClass:[MXKRoomMemberDetailsViewController class]]]; +} + +- (void)finalizeInit +{ + [super finalizeInit]; + + actionsArray = [[NSMutableArray alloc] init]; + _enableLeave = YES; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!self.tableView) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + // ignore useless update + if (_mxRoomMember) + { + [self initObservers]; + } +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + [self initObservers]; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + [self removeObservers]; +} + +- (void)destroy +{ + // close any pending actionsheet + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + currentAlert = nil; + } + + [self removePendingActionMask]; + + [self removeObservers]; + + _delegate = nil; + _mxRoomMember = nil; + + actionsArray = nil; + + [super destroy]; +} + +#pragma mark - + +- (void)displayRoomMember:(MXRoomMember*)roomMember withMatrixRoom:(MXRoom*)room +{ + [self removeObservers]; + + mxRoom = room; + + MXWeakify(self); + [mxRoom liveTimeline:^(MXEventTimeline *liveTimeline) { + MXStrongifyAndReturnIfNil(self); + + self->mxRoomLiveTimeline = liveTimeline; + + // Update matrix session associated to the view controller + NSArray *mxSessions = self.mxSessions; + for (MXSession *mxSession in mxSessions) { + [self removeMatrixSession:mxSession]; + } + [self addMatrixSession:room.mxSession]; + + self->_mxRoomMember = roomMember; + + [self initObservers]; + }]; +} + +- (MXEventTimeline *)mxRoomLiveTimeline +{ + // @TODO(async-state): Just here for dev + NSAssert(mxRoomLiveTimeline, @"[MXKRoomMemberDetailsViewController] Room live timeline must be preloaded before accessing to MXKRoomMemberDetailsViewController.mxRoomLiveTimeline"); + return mxRoomLiveTimeline; +} + +- (UIImage*)picturePlaceholder +{ + return [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"default-profile"]; +} + +- (void)setEnableMention:(BOOL)enableMention +{ + if (_enableMention != enableMention) + { + _enableMention = enableMention; + + [self updateMemberInfo]; + } +} + +- (void)setEnableVoipCall:(BOOL)enableVoipCall +{ + if (_enableVoipCall != enableVoipCall) + { + _enableVoipCall = enableVoipCall; + + [self updateMemberInfo]; + } +} + +- (void)setEnableLeave:(BOOL)enableLeave +{ + if (_enableLeave != enableLeave) + { + _enableLeave = enableLeave; + + [self updateMemberInfo]; + } +} + +- (IBAction)onActionButtonPressed:(id)sender +{ + if ([sender isKindOfClass:[UIButton class]]) + { + // Check whether an action is already in progress + if ([self hasPendingAction]) + { + return; + } + + UIButton *button = (UIButton*)sender; + + switch (button.tag) + { + case MXKRoomMemberDetailsActionInvite: + { + [self addPendingActionMask]; + [mxRoom inviteUser:_mxRoomMember.userId + success:^{ + + [self removePendingActionMask]; + + } failure:^(NSError *error) { + + [self removePendingActionMask]; + MXLogDebug(@"[MXKRoomMemberDetailsVC] Invite %@ failed", self->_mxRoomMember.userId); + // Notify MatrixKit user + NSString *myUserId = self.mainSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + break; + } + case MXKRoomMemberDetailsActionLeave: + { + [self addPendingActionMask]; + [self.mxRoom leave:^{ + + [self removePendingActionMask]; + [self withdrawViewControllerAnimated:YES completion:nil]; + + } failure:^(NSError *error) { + + [self removePendingActionMask]; + MXLogDebug(@"[MXKRoomMemberDetailsVC] Leave room %@ failed", self->mxRoom.roomId); + // Notify MatrixKit user + NSString *myUserId = self.mainSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + break; + } + case MXKRoomMemberDetailsActionKick: + { + [self addPendingActionMask]; + [mxRoom kickUser:_mxRoomMember.userId + reason:nil + success:^{ + + [self removePendingActionMask]; + // Pop/Dismiss the current view controller if the left members are hidden + if (![[MXKAppSettings standardAppSettings] showLeftMembersInRoomMemberList]) + { + [self withdrawViewControllerAnimated:YES completion:nil]; + } + + } failure:^(NSError *error) { + + [self removePendingActionMask]; + MXLogDebug(@"[MXKRoomMemberDetailsVC] Kick %@ failed", self->_mxRoomMember.userId); + // Notify MatrixKit user + NSString *myUserId = self.mainSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + break; + } + case MXKRoomMemberDetailsActionBan: + { + [self addPendingActionMask]; + [mxRoom banUser:_mxRoomMember.userId + reason:nil + success:^{ + + [self removePendingActionMask]; + + } failure:^(NSError *error) { + + [self removePendingActionMask]; + MXLogDebug(@"[MXKRoomMemberDetailsVC] Ban %@ failed", self->_mxRoomMember.userId); + // Notify MatrixKit user + NSString *myUserId = self.mainSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + break; + } + case MXKRoomMemberDetailsActionUnban: + { + [self addPendingActionMask]; + [mxRoom unbanUser:_mxRoomMember.userId + success:^{ + + [self removePendingActionMask]; + + } failure:^(NSError *error) { + + [self removePendingActionMask]; + MXLogDebug(@"[MXKRoomMemberDetailsVC] Unban %@ failed", self->_mxRoomMember.userId); + // Notify MatrixKit user + NSString *myUserId = self.mainSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + break; + } + case MXKRoomMemberDetailsActionIgnore: + { + // Prompt user to ignore content from this user + MXWeakify(self); + + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + } + + currentAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n roomMemberIgnorePrompt] message:nil preferredStyle:UIAlertControllerStyleAlert]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n yes] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + + self->currentAlert = nil; + + // Add the user to the blacklist: ignored users + [self addPendingActionMask]; + + MXWeakify(self); + + [self.mainSession ignoreUsers:@[self.mxRoomMember.userId] + success:^{ + + MXStrongifyAndReturnIfNil(self); + + [self removePendingActionMask]; + + } failure:^(NSError *error) { + + MXStrongifyAndReturnIfNil(self); + + [self removePendingActionMask]; + MXLogDebug(@"[MXKRoomMemberDetailsVC] Ignore %@ failed", self.mxRoomMember.userId); + + // Notify MatrixKit user + NSString *myUserId = self.mainSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + + }]]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n no] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + + self->currentAlert = nil; + }]]; + + [self presentViewController:currentAlert animated:YES completion:nil]; + break; + } + case MXKRoomMemberDetailsActionUnignore: + { + // Remove the member from the ignored user list. + [self addPendingActionMask]; + + MXWeakify(self); + + [self.mainSession unIgnoreUsers:@[self.mxRoomMember.userId] + success:^{ + + MXStrongifyAndReturnIfNil(self); + [self removePendingActionMask]; + + } failure:^(NSError *error) { + + MXStrongifyAndReturnIfNil(self); + + [self removePendingActionMask]; + MXLogDebug(@"[MXKRoomMemberDetailsVC] Unignore %@ failed", self.mxRoomMember.userId); + + // Notify MatrixKit user + NSString *myUserId = self.mainSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + break; + } + case MXKRoomMemberDetailsActionSetDefaultPowerLevel: + { + break; + } + case MXKRoomMemberDetailsActionSetModerator: + { + break; + } + case MXKRoomMemberDetailsActionSetAdmin: + { + break; + } + case MXKRoomMemberDetailsActionSetCustomPowerLevel: + { + [self updateUserPowerLevel]; + break; + } + case MXKRoomMemberDetailsActionStartChat: + { + if (self.delegate) + { + [self addPendingActionMask]; + + [self.delegate roomMemberDetailsViewController:self startChatWithMemberId:_mxRoomMember.userId completion:^{ + + [self removePendingActionMask]; + }]; + } + break; + } + case MXKRoomMemberDetailsActionStartVoiceCall: + case MXKRoomMemberDetailsActionStartVideoCall: + { + BOOL isVideoCall = (button.tag == MXKRoomMemberDetailsActionStartVideoCall); + + if (self.delegate && [self.delegate respondsToSelector:@selector(roomMemberDetailsViewController:placeVoipCallWithMemberId:andVideo:)]) + { + [self addPendingActionMask]; + + [self.delegate roomMemberDetailsViewController:self placeVoipCallWithMemberId:_mxRoomMember.userId andVideo:isVideoCall]; + + [self removePendingActionMask]; + } + else + { + [self addPendingActionMask]; + + MXRoom* directRoom = [self.mainSession directJoinedRoomWithUserId:_mxRoomMember.userId]; + + // Place the call directly if the room exists + if (directRoom) + { + [directRoom placeCallWithVideo:isVideoCall success:nil failure:nil]; + [self removePendingActionMask]; + } + else + { + // Create a new room + MXRoomCreationParameters *roomCreationParameters = [MXRoomCreationParameters parametersForDirectRoomWithUser:_mxRoomMember.userId]; + [self.mainSession createRoomWithParameters:roomCreationParameters success:^(MXRoom *room) { + + // Delay the call in order to be sure that the room is ready + dispatch_async(dispatch_get_main_queue(), ^{ + [room placeCallWithVideo:isVideoCall success:nil failure:nil]; + [self removePendingActionMask]; + }); + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomMemberDetailsVC] Create room failed"); + [self removePendingActionMask]; + // Notify MatrixKit user + NSString *myUserId = self.mainSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + } + break; + } + case MXKRoomMemberDetailsActionMention: + { + // Sanity check + if (_delegate && [_delegate respondsToSelector:@selector(roomMemberDetailsViewController:mention:)]) + { + id delegate = _delegate; + MXRoomMember *member = _mxRoomMember; + + // Withdraw the current view controller, and let the delegate mention the member + [self withdrawViewControllerAnimated:YES completion:^{ + + [delegate roomMemberDetailsViewController:self mention:member]; + + }]; + } + break; + } + default: + break; + } + } +} + +#pragma mark - Internals + +- (void)initObservers +{ + // Remove any pending observers + [self removeObservers]; + + if (mxRoom) + { + // Observe room's members update + NSArray *mxMembersEvents = @[kMXEventTypeStringRoomMember, kMXEventTypeStringRoomPowerLevels]; + self->membersListener = [mxRoom listenToEventsOfTypes:mxMembersEvents onEvent:^(MXEvent *event, MXTimelineDirection direction, id customObject) { + + // consider only live event + if (direction == MXTimelineDirectionForwards) + { + [self refreshRoomMember]; + } + }]; + + // Observe kMXSessionWillLeaveRoomNotification to be notified if the user leaves the current room. + leaveRoomNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXSessionWillLeaveRoomNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + // Check whether the user will leave the room related to the displayed member + if (notif.object == self.mainSession) + { + NSString *roomId = notif.userInfo[kMXSessionNotificationRoomIdKey]; + if (roomId && [roomId isEqualToString:self->mxRoom.roomId]) + { + // We must remove the current view controller. + [self withdrawViewControllerAnimated:YES completion:nil]; + } + } + }]; + + // Observe room history flush (sync with limited timeline, or state event redaction) + roomDidFlushDataNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXRoomDidFlushDataNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXRoom *room = notif.object; + if (self.mainSession == room.mxSession && [self->mxRoom.roomId isEqualToString:room.roomId]) + { + // The existing room history has been flushed during server sync. + // Take into account the updated room members list by updating the room member instance + [self refreshRoomMember]; + } + + }]; + } + + [self updateMemberInfo]; +} + +- (void)removeObservers +{ + if (leaveRoomNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:leaveRoomNotificationObserver]; + leaveRoomNotificationObserver = nil; + } + if (roomDidFlushDataNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:roomDidFlushDataNotificationObserver]; + roomDidFlushDataNotificationObserver = nil; + } + + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + if (membersListener && mxRoom) + { + MXWeakify(self); + [mxRoom liveTimeline:^(MXEventTimeline *liveTimeline) { + MXStrongifyAndReturnIfNil(self); + + [liveTimeline removeListener:self->membersListener]; + self->membersListener = nil; + }]; + } +} + +- (void)refreshRoomMember +{ + // Hide potential action sheet + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + currentAlert = nil; + } + + MXRoomMember* nextRoomMember = nil; + + // get the updated memmber + NSArray *membersList = self.mxRoomLiveTimeline.state.members.members; + for (MXRoomMember* member in membersList) + { + if ([member.userId isEqualToString:_mxRoomMember.userId]) + { + nextRoomMember = member; + break; + } + } + + // does the member still exist ? + if (nextRoomMember) + { + // Refresh member + _mxRoomMember = nextRoomMember; + [self updateMemberInfo]; + } + else + { + [self withdrawViewControllerAnimated:YES completion:nil]; + } +} + +- (void)updateMemberInfo +{ + self.title = _mxRoomMember.displayname ? _mxRoomMember.displayname : _mxRoomMember.userId; + + // set the thumbnail info + self.memberThumbnail.contentMode = UIViewContentModeScaleAspectFill; + self.memberThumbnail.defaultBackgroundColor = [UIColor clearColor]; + [self.memberThumbnail.layer setCornerRadius:self.memberThumbnail.frame.size.width / 2]; + [self.memberThumbnail setClipsToBounds:YES]; + + self.memberThumbnail.mediaFolder = kMXMediaManagerAvatarThumbnailFolder; + self.memberThumbnail.enableInMemoryCache = YES; + [self.memberThumbnail setImageURI:_mxRoomMember.avatarUrl + withType:nil + andImageOrientation:UIImageOrientationUp + toFitViewSize:self.memberThumbnail.frame.size + withMethod:MXThumbnailingMethodCrop + previewImage:self.picturePlaceholder + mediaManager:self.mainSession.mediaManager]; + + self.roomMemberMatrixInfo.text = _mxRoomMember.userId; + + [self.tableView reloadData]; +} + +#pragma mark - Table view data source + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView +{ + return 1; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + // Check user's power level before allowing an action (kick, ban, ...) + MXRoomPowerLevels *powerLevels = [self.mxRoomLiveTimeline.state powerLevels]; + NSInteger memberPowerLevel = [powerLevels powerLevelOfUserWithUserID:_mxRoomMember.userId]; + NSInteger oneSelfPowerLevel = [powerLevels powerLevelOfUserWithUserID:self.mainSession.myUser.userId]; + + [actionsArray removeAllObjects]; + + // Consider the case of the user himself + if ([_mxRoomMember.userId isEqualToString:self.mainSession.myUser.userId]) + { + if (_enableLeave) + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionLeave)]; + } + + if (oneSelfPowerLevel >= [powerLevels minimumPowerLevelForSendingEventAsStateEvent:kMXEventTypeStringRoomPowerLevels]) + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionSetCustomPowerLevel)]; + } + } + else if (_mxRoomMember) + { + if (_enableVoipCall) + { + // Offer voip call options + [actionsArray addObject:@(MXKRoomMemberDetailsActionStartVoiceCall)]; + [actionsArray addObject:@(MXKRoomMemberDetailsActionStartVideoCall)]; + } + + // Consider membership of the selected member + switch (_mxRoomMember.membership) + { + case MXMembershipInvite: + case MXMembershipJoin: + { + // Check conditions to be able to kick someone + if (oneSelfPowerLevel >= [powerLevels kick] && oneSelfPowerLevel > memberPowerLevel) + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionKick)]; + } + // Check conditions to be able to ban someone + if (oneSelfPowerLevel >= [powerLevels ban] && oneSelfPowerLevel > memberPowerLevel) + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionBan)]; + } + + // Check whether the option Ignore may be presented + if (_mxRoomMember.membership == MXMembershipJoin) + { + // is he already ignored ? + if (![self.mainSession isUserIgnored:_mxRoomMember.userId]) + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionIgnore)]; + } + else + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionUnignore)]; + } + } + break; + } + case MXMembershipLeave: + { + // Check conditions to be able to invite someone + if (oneSelfPowerLevel >= [powerLevels invite]) + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionInvite)]; + } + // Check conditions to be able to ban someone + if (oneSelfPowerLevel >= [powerLevels ban] && oneSelfPowerLevel > memberPowerLevel) + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionBan)]; + } + break; + } + case MXMembershipBan: + { + // Check conditions to be able to unban someone + if (oneSelfPowerLevel >= [powerLevels ban] && oneSelfPowerLevel > memberPowerLevel) + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionUnban)]; + } + break; + } + default: + { + break; + } + } + + // update power level + if (oneSelfPowerLevel >= [powerLevels minimumPowerLevelForSendingEventAsStateEvent:kMXEventTypeStringRoomPowerLevels] && oneSelfPowerLevel > memberPowerLevel) + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionSetCustomPowerLevel)]; + } + + // offer to start a new chat only if the room is not the first direct chat with this user + // it does not make sense : it would open the same room + MXRoom* directRoom = [self.mainSession directJoinedRoomWithUserId:_mxRoomMember.userId]; + if (!directRoom || (![directRoom.roomId isEqualToString:mxRoom.roomId])) + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionStartChat)]; + } + } + + if (_enableMention) + { + // Add mention option + [actionsArray addObject:@(MXKRoomMemberDetailsActionMention)]; + } + + return (actionsArray.count + 1) / 2; +} + +- (NSString*)actionButtonTitle:(MXKRoomMemberDetailsAction)action +{ + NSString *title; + + switch (action) + { + case MXKRoomMemberDetailsActionInvite: + title = [MatrixKitL10n invite]; + break; + case MXKRoomMemberDetailsActionLeave: + title = [MatrixKitL10n leave]; + break; + case MXKRoomMemberDetailsActionKick: + title = [MatrixKitL10n kick]; + break; + case MXKRoomMemberDetailsActionBan: + title = [MatrixKitL10n ban]; + break; + case MXKRoomMemberDetailsActionUnban: + title = [MatrixKitL10n unban]; + break; + case MXKRoomMemberDetailsActionIgnore: + title = [MatrixKitL10n ignore]; + break; + case MXKRoomMemberDetailsActionUnignore: + title = [MatrixKitL10n unignore]; + break; + case MXKRoomMemberDetailsActionSetDefaultPowerLevel: + title = [MatrixKitL10n setDefaultPowerLevel]; + break; + case MXKRoomMemberDetailsActionSetModerator: + title = [MatrixKitL10n setModerator]; + break; + case MXKRoomMemberDetailsActionSetAdmin: + title = [MatrixKitL10n setAdmin]; + break; + case MXKRoomMemberDetailsActionSetCustomPowerLevel: + title = [MatrixKitL10n setPowerLevel]; + break; + case MXKRoomMemberDetailsActionStartChat: + title = [MatrixKitL10n startChat]; + break; + case MXKRoomMemberDetailsActionStartVoiceCall: + title = [MatrixKitL10n startVoiceCall]; + break; + case MXKRoomMemberDetailsActionStartVideoCall: + title = [MatrixKitL10n startVideoCall]; + break; + case MXKRoomMemberDetailsActionMention: + title = [MatrixKitL10n mention]; + break; + default: + break; + } + + return title; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (self.tableView == tableView) + { + NSInteger row = indexPath.row; + + MXKTableViewCellWithButtons *cell = [tableView dequeueReusableCellWithIdentifier:[MXKTableViewCellWithButtons defaultReuseIdentifier]]; + if (!cell) + { + cell = [[MXKTableViewCellWithButtons alloc] init]; + } + + cell.mxkButtonNumber = 2; + NSArray *buttons = cell.mxkButtons; + NSInteger index = row * 2; + NSString *text = nil; + for (UIButton *button in buttons) + { + NSNumber *actionNumber; + if (index < actionsArray.count) + { + actionNumber = [actionsArray objectAtIndex:index]; + } + + text = (actionNumber ? [self actionButtonTitle:actionNumber.unsignedIntegerValue] : nil); + + button.hidden = (text.length == 0); + + button.layer.borderColor = button.tintColor.CGColor; + button.layer.borderWidth = 1; + button.layer.cornerRadius = 5; + + [button setTitle:text forState:UIControlStateNormal]; + [button setTitle:text forState:UIControlStateHighlighted]; + + [button addTarget:self action:@selector(onActionButtonPressed:) forControlEvents:UIControlEventTouchUpInside]; + + button.tag = (actionNumber ? actionNumber.unsignedIntegerValue : -1); + + index ++; + } + + return cell; + } + + // Return a fake cell to prevent app from crashing. + return [[UITableViewCell alloc] init]; +} + + +#pragma mark - button management + +- (BOOL)hasPendingAction +{ + return nil != pendingMaskSpinnerView; +} + +- (void)addPendingActionMask +{ + // add a spinner above the tableview to avoid that the user tap on any other button + pendingMaskSpinnerView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge]; + pendingMaskSpinnerView.backgroundColor = [UIColor colorWithRed:0.5 green:0.5 blue:0.5 alpha:0.5]; + pendingMaskSpinnerView.frame = self.tableView.frame; + pendingMaskSpinnerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleTopMargin; + + // append it + [self.tableView.superview addSubview:pendingMaskSpinnerView]; + + // animate it + [pendingMaskSpinnerView startAnimating]; +} + +- (void)removePendingActionMask +{ + if (pendingMaskSpinnerView) + { + [pendingMaskSpinnerView removeFromSuperview]; + pendingMaskSpinnerView = nil; + [self.tableView reloadData]; + } +} + +- (void)setPowerLevel:(NSInteger)value promptUser:(BOOL)promptUser +{ + NSInteger currentPowerLevel = [self.mxRoomLiveTimeline.state.powerLevels powerLevelOfUserWithUserID:_mxRoomMember.userId]; + + // check if the power level has not yet been set to 0 + if (value != currentPowerLevel) + { + __weak typeof(self) weakSelf = self; + + if (promptUser && value == [self.mxRoomLiveTimeline.state.powerLevels powerLevelOfUserWithUserID:self.mainSession.myUser.userId]) + { + // If the user is setting the same power level as his to another user, ask him for a confirmation + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + } + + currentAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n roomMemberPowerLevelPrompt] message:nil preferredStyle:UIAlertControllerStyleAlert]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n no] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + } + + }]]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n yes] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // The user confirms. Apply the power level + [self setPowerLevel:value promptUser:NO]; + } + + }]]; + + [self presentViewController:currentAlert animated:YES completion:nil]; + } + else + { + [self addPendingActionMask]; + + // Reset user power level + [self.mxRoom setPowerLevelOfUserWithUserID:_mxRoomMember.userId powerLevel:value success:^{ + + __strong __typeof(weakSelf)strongSelf = weakSelf; + [strongSelf removePendingActionMask]; + + } failure:^(NSError *error) { + + __strong __typeof(weakSelf)strongSelf = weakSelf; + [strongSelf removePendingActionMask]; + MXLogDebug(@"[MXKRoomMemberDetailsVC] Set user power (%@) failed", strongSelf.mxRoomMember.userId); + + // Notify MatrixKit user + NSString *myUserId = strongSelf.mainSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + } +} + +- (void)updateUserPowerLevel +{ + __weak typeof(self) weakSelf = self; + + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + } + + currentAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n powerLevel] message:nil preferredStyle:UIAlertControllerStyleAlert]; + + + if (![self.mainSession.myUser.userId isEqualToString:_mxRoomMember.userId]) + { + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n resetToDefault] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + + [self setPowerLevel:self.mxRoomLiveTimeline.state.powerLevels.usersDefault promptUser:YES]; + } + + }]]; + } + + [currentAlert addTextFieldWithConfigurationHandler:^(UITextField *textField) + { + typeof(self) self = weakSelf; + + textField.secureTextEntry = NO; + textField.text = [NSString stringWithFormat:@"%ld", (long)[self.mxRoomLiveTimeline.state.powerLevels powerLevelOfUserWithUserID:self.mxRoomMember.userId]]; + textField.placeholder = nil; + textField.keyboardType = UIKeyboardTypeDecimalPad; + }]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + NSString *text = [self->currentAlert textFields].firstObject.text; + self->currentAlert = nil; + + if (text.length > 0) + { + [self setPowerLevel:[text integerValue] promptUser:YES]; + } + } + + }]]; + + [self presentViewController:currentAlert animated:YES completion:nil]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.xib new file mode 100644 index 000000000..238c3d370 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.xib @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.h new file mode 100644 index 000000000..d35eb497b --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.h @@ -0,0 +1,116 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKViewController.h" +#import "MXKRoomMemberListDataSource.h" + +@class MXKRoomMemberListViewController; + +/** + `MXKRoomMemberListViewController` delegate. + */ +@protocol MXKRoomMemberListViewControllerDelegate + +/** + Tells the delegate that the user selected a member. + + @param roomMemberListViewController the `MXKRoomMemberListViewController` instance. + @param member the selected member. + */ +- (void)roomMemberListViewController:(MXKRoomMemberListViewController *)roomMemberListViewController didSelectMember:(MXRoomMember*)member; + +@end + + +/** + This view controller displays members of a room. Only one matrix session is handled by this view controller. + */ +@interface MXKRoomMemberListViewController : MXKViewController +{ +@protected + /** + Used to auto scroll at the top when search session is started or cancelled. + */ + BOOL shouldScrollToTopOnRefresh; +} + +@property (weak, nonatomic) IBOutlet UISearchBar *membersSearchBar; +@property (weak, nonatomic) IBOutlet UITableView *membersTableView; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *membersSearchBarTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *membersSearchBarHeightConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *membersTableViewBottomConstraint; + +/** + The current data source associated to the view controller. + */ +@property (nonatomic, readonly) MXKRoomMemberListDataSource *dataSource; + +/** + The delegate for the view controller. + */ +@property (nonatomic, weak) id delegate; + +/** + Enable the search in room members list according to the member's display name (YES by default). + Set NO this property to disable this option and hide the related bar button. + */ +@property (nonatomic) BOOL enableMemberSearch; + +/** + Enable the invitation of a new member (YES by default). + Set NO this property to disable this option and hide the related bar button. + */ +@property (nonatomic) BOOL enableMemberInvitation; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKRoomMemberListViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `roomMemberListViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKRoomMemberListViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKRoomMemberListViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)roomMemberListViewController; + +/** + Display the members list. + + @param listDataSource the data source providing the members list. + */ +- (void)displayList:(MXKRoomMemberListDataSource*)listDataSource; + +/** + Scroll the members list to the top. + + @param animated YES to animate the transition at a constant velocity to the new offset, NO to make the transition immediate. + */ +- (void)scrollToTop:(BOOL)animated; + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.m new file mode 100644 index 000000000..217fb4758 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.m @@ -0,0 +1,571 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomMemberListViewController.h" + +#import "MXKRoomMemberTableViewCell.h" + +#import "MXKConstants.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@interface MXKRoomMemberListViewController () +{ + /** + The data source providing UITableViewCells + */ + MXKRoomMemberListDataSource *dataSource; + + /** + Timer used to update members presence + */ + NSTimer* presenceUpdateTimer; + + /** + Optional bar buttons + */ + UIBarButtonItem *searchBarButton; + UIBarButtonItem *addBarButton; + + /** + The current displayed alert (if any). + */ + UIAlertController *currentAlert; + + /** + Search bar + */ + BOOL ignoreSearchRequest; + + /** + Observe kMXSessionWillLeaveRoomNotification to be notified if the user leaves the current room. + */ + id leaveRoomNotificationObserver; +} + +@end + +@implementation MXKRoomMemberListViewController +@synthesize dataSource; + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRoomMemberListViewController class]) + bundle:[NSBundle bundleForClass:[MXKRoomMemberListViewController class]]]; +} + ++ (instancetype)roomMemberListViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKRoomMemberListViewController class]) + bundle:[NSBundle bundleForClass:[MXKRoomMemberListViewController class]]]; +} + + +#pragma mark - + +- (void)finalizeInit +{ + [super finalizeInit]; + + // Enable both bar button by default. + _enableMemberInvitation = YES; + _enableMemberSearch = YES; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!self.membersTableView) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + // Adjust Top and Bottom constraints to take into account potential navBar and tabBar. + [NSLayoutConstraint deactivateConstraints:@[_membersSearchBarTopConstraint, _membersTableViewBottomConstraint]]; + + _membersSearchBarTopConstraint = [NSLayoutConstraint constraintWithItem:self.topLayoutGuide + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:self.membersSearchBar + attribute:NSLayoutAttributeTop + multiplier:1.0f + constant:0.0f]; + + _membersTableViewBottomConstraint = [NSLayoutConstraint constraintWithItem:self.bottomLayoutGuide + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self.membersTableView + attribute:NSLayoutAttributeBottom + multiplier:1.0f + constant:0.0f]; + + [NSLayoutConstraint activateConstraints:@[_membersSearchBarTopConstraint, _membersTableViewBottomConstraint]]; + + // Hide search bar by default + self.membersSearchBar.hidden = YES; + self.membersSearchBarHeightConstraint.constant = 0; + [self.view setNeedsUpdateConstraints]; + + searchBarButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemSearch target:self action:@selector(search:)]; + addBarButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAdd target:self action:@selector(inviteNewMember:)]; + + // Refresh bar button display. + [self refreshUIBarButtons]; + + // Add an accessory view to the search bar in order to retrieve keyboard view. + self.membersSearchBar.inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; + + // Finalize table view configuration + self.membersTableView.delegate = self; + self.membersTableView.dataSource = dataSource; // Note datasource may be nil here. + + // Set up default table view cell class + [self.membersTableView registerNib:MXKRoomMemberTableViewCell.nib forCellReuseIdentifier:MXKRoomMemberTableViewCell.defaultReuseIdentifier]; +} + +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + + // Check whether the user still belongs to the room's members. + if (self.dataSource && [self.mainSession roomWithRoomId:self.dataSource.roomId]) + { + [self refreshUIBarButtons]; + + // Observe kMXSessionWillLeaveRoomNotification to be notified if the user leaves the current room. + leaveRoomNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXSessionWillLeaveRoomNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) + { + + // Check whether the user will leave the room related to the displayed member list + if (notif.object == self.mainSession) + { + NSString *roomId = notif.userInfo[kMXSessionNotificationRoomIdKey]; + if (roomId && [roomId isEqualToString:self.dataSource.roomId]) + { + // We remove the current view controller. + [self withdrawViewControllerAnimated:YES completion:nil]; + } + } + }]; + } + else + { + // We remove the current view controller. + [self withdrawViewControllerAnimated:YES completion:nil]; + } +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + // Restore search mechanism (if enabled) + ignoreSearchRequest = NO; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + // The user may still press search button whereas the view disappears + ignoreSearchRequest = YES; + + if (leaveRoomNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:leaveRoomNotificationObserver]; + leaveRoomNotificationObserver = nil; + } + + // Leave potential search session + if (!self.membersSearchBar.isHidden) + { + [self searchBarCancelButtonClicked:self.membersSearchBar]; + } +} + +- (void)dealloc +{ + self.membersSearchBar.inputAccessoryView = nil; +} + +- (void)didReceiveMemoryWarning +{ + [super didReceiveMemoryWarning]; + + // Dispose of any resources that can be recreated. +} + +#pragma mark - Override MXKTableViewController + +- (void)onKeyboardShowAnimationComplete +{ + // Report the keyboard view in order to track keyboard frame changes + self.keyboardView = _membersSearchBar.inputAccessoryView.superview; +} + +- (void)setKeyboardHeight:(CGFloat)keyboardHeight +{ + // Deduce the bottom constraint for the table view (Don't forget the potential tabBar) + CGFloat tableViewBottomConst = keyboardHeight - self.bottomLayoutGuide.length; + // Check whether the keyboard is over the tabBar + if (tableViewBottomConst < 0) + { + tableViewBottomConst = 0; + } + + // Update constraints + _membersTableViewBottomConstraint.constant = tableViewBottomConst; + + // Force layout immediately to take into account new constraint + [self.view layoutIfNeeded]; +} + +- (void)destroy +{ + if (presenceUpdateTimer) + { + [presenceUpdateTimer invalidate]; + presenceUpdateTimer = nil; + } + + self.membersTableView.dataSource = nil; + self.membersTableView.delegate = nil; + self.membersTableView = nil; + dataSource.delegate = nil; + dataSource = nil; + + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + currentAlert = nil; + } + + searchBarButton = nil; + addBarButton = nil; + + _delegate = nil; + + [super destroy]; +} + +#pragma mark - Internal methods + +- (void)updateMembersActivityInfo +{ + for (id memberCell in self.membersTableView.visibleCells) + { + if ([memberCell respondsToSelector:@selector(updateActivityInfo)]) + { + [memberCell updateActivityInfo]; + } + } +} + +#pragma mark - UIBarButton handling + +- (void)setEnableMemberSearch:(BOOL)enableMemberSearch +{ + _enableMemberSearch = enableMemberSearch; + [self refreshUIBarButtons]; +} + +- (void)setEnableMemberInvitation:(BOOL)enableMemberInvitation +{ + _enableMemberInvitation = enableMemberInvitation; + [self refreshUIBarButtons]; +} + +- (void)refreshUIBarButtons +{ + MXRoom *mxRoom = [self.mainSession roomWithRoomId:dataSource.roomId]; + + MXWeakify(self); + [mxRoom state:^(MXRoomState *roomState) { + MXStrongifyAndReturnIfNil(self); + + BOOL showInvitationOption = self.enableMemberInvitation; + + if (showInvitationOption && self->dataSource) + { + // Check conditions to be able to invite someone + NSInteger oneSelfPowerLevel = [roomState.powerLevels powerLevelOfUserWithUserID:self.mainSession.myUser.userId]; + if (oneSelfPowerLevel < [roomState.powerLevels invite]) + { + showInvitationOption = NO; + } + } + + if (showInvitationOption) + { + if (self.enableMemberSearch) + { + self.navigationItem.rightBarButtonItems = @[self->searchBarButton, self->addBarButton]; + } + else + { + self.navigationItem.rightBarButtonItems = @[self->addBarButton]; + } + } + else if (self.enableMemberSearch) + { + self.navigationItem.rightBarButtonItems = @[self->searchBarButton]; + } + else + { + self.navigationItem.rightBarButtonItems = nil; + } + }]; +} + +#pragma mark - +- (void)displayList:(MXKRoomMemberListDataSource *)listDataSource +{ + if (dataSource) + { + dataSource.delegate = nil; + dataSource = nil; + [self removeMatrixSession:self.mainSession]; + } + + dataSource = listDataSource; + dataSource.delegate = self; + + // Report the matrix session at view controller level to update UI according to session state + [self addMatrixSession:dataSource.mxSession]; + + if (self.membersTableView) + { + // Set up table data source + self.membersTableView.dataSource = dataSource; + } +} + +- (void)scrollToTop:(BOOL)animated +{ + [self.membersTableView setContentOffset:CGPointMake(-self.membersTableView.adjustedContentInset.left, -self.membersTableView.adjustedContentInset.top) animated:animated]; +} + +#pragma mark - MXKDataSourceDelegate + +- (Class)cellViewClassForCellData:(MXKCellData*)cellData +{ + // Return the default member table view cell + return MXKRoomMemberTableViewCell.class; +} + +- (NSString *)cellReuseIdentifierForCellData:(MXKCellData*)cellData +{ + // Consider the default member table view cell + return MXKRoomMemberTableViewCell.defaultReuseIdentifier; +} + +- (void)dataSource:(MXKDataSource *)dataSource didCellChange:(id)changes +{ + if (presenceUpdateTimer) + { + [presenceUpdateTimer invalidate]; + presenceUpdateTimer = nil; + } + + // For now, do a simple full reload + [self.membersTableView reloadData]; + + if (shouldScrollToTopOnRefresh) + { + [self scrollToTop:NO]; + shouldScrollToTopOnRefresh = NO; + } + + // Place a timer to update members's activity information + presenceUpdateTimer = [NSTimer scheduledTimerWithTimeInterval:5 target:self selector:@selector(updateMembersActivityInfo) userInfo:self repeats:YES]; +} + +#pragma mark - UITableView delegate +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + return [dataSource cellHeightAtIndex:indexPath.row]; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (_delegate) + { + id cellData = [dataSource cellDataAtIndex:indexPath.row]; + + [_delegate roomMemberListViewController:self didSelectMember:cellData.roomMember]; + } + [tableView deselectRowAtIndexPath:indexPath animated:NO]; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section +{ + return 0; +} + +- (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath*)indexPath +{ + // Release here resources, and restore reusable cells + if ([cell respondsToSelector:@selector(didEndDisplay)]) + { + [(id)cell didEndDisplay]; + } +} + +#pragma mark - UISearchBarDelegate + +- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText +{ + // Apply filter + shouldScrollToTopOnRefresh = YES; + if (searchText.length) + { + [self.dataSource searchWithPatterns:@[searchText]]; + } + else + { + [self.dataSource searchWithPatterns:nil]; + } +} + +- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar +{ + // "Done" key has been pressed + [searchBar resignFirstResponder]; +} + +- (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar +{ + // Leave search + [searchBar resignFirstResponder]; + + self.membersSearchBar.hidden = YES; + self.membersSearchBarHeightConstraint.constant = 0; + [self.view setNeedsUpdateConstraints]; + + self.membersSearchBar.text = nil; + + // Refresh display + shouldScrollToTopOnRefresh = YES; + [self.dataSource searchWithPatterns:nil]; +} + +#pragma mark - Actions + +- (void)search:(id)sender +{ + // The user may have pressed search button whereas the view controller was disappearing + if (ignoreSearchRequest) + { + return; + } + + if (self.membersSearchBar.isHidden) + { + // Check whether there are data in which search + if ([self.dataSource tableView:self.membersTableView numberOfRowsInSection:0]) + { + self.membersSearchBar.hidden = NO; + self.membersSearchBarHeightConstraint.constant = 44; + [self.view setNeedsUpdateConstraints]; + + // Create search bar + [self.membersSearchBar becomeFirstResponder]; + } + } + else + { + [self searchBarCancelButtonClicked: self.membersSearchBar]; + } +} + +- (void)inviteNewMember:(id)sender +{ + __weak typeof(self) weakSelf = self; + + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + } + + // Ask for userId to invite + currentAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n userIdTitle] message:nil preferredStyle:UIAlertControllerStyleAlert]; + + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + } + + }]]; + + + [currentAlert addTextFieldWithConfigurationHandler:^(UITextField *textField) + { + textField.secureTextEntry = NO; + textField.placeholder = [MatrixKitL10n userIdPlaceholder]; + }]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n invite] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + NSString *userId = [self->currentAlert textFields].firstObject.text; + + self->currentAlert = nil; + + if (userId.length) + { + MXRoom *mxRoom = [self.mainSession roomWithRoomId:self.dataSource.roomId]; + if (mxRoom) + { + [mxRoom inviteUser:userId success:^{ + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Invite %@ failed", userId); + // Notify MatrixKit user + NSString *myUserId = self.mainSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + } + } + + }]]; + + [self presentViewController:currentAlert animated:YES completion:nil]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.xib new file mode 100644 index 000000000..4a339bdad --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.xib @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.h new file mode 100644 index 000000000..663101028 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.h @@ -0,0 +1,81 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKTableViewController.h" + +/** + This view controller displays the room settings. + */ +@interface MXKRoomSettingsViewController : MXKTableViewController +{ +@protected + // the dedicated room + MXRoom* mxRoom; + + // the room state + MXRoomState* mxRoomState; +} + +/** + The dedicated roomId. + */ +@property (nonatomic, readonly) NSString *roomId; + + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKRoomSettingsViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `roomViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKRoomSettingsViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKRoomSettingsViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)roomSettingsViewController; + +/** + Set the dedicated session and the room Id + */ +- (void)initWithSession:(MXSession*)session andRoomId:(NSString*)roomId; + +/** + Refresh the displayed room settings. By default this method reload the table view. + + @discusion You may override this method to handle the table refresh. + */ +- (void)refreshRoomSettings; + +/** + Updates the display with a new room state. + + @param newRoomState the new room state. + */ +- (void)updateRoomState:(MXRoomState*)newRoomState; + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.m new file mode 100644 index 000000000..8f729e9b9 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.m @@ -0,0 +1,215 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomSettingsViewController.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@interface MXKRoomSettingsViewController() +{ + // the room events listener + id roomListener; + + // Observe kMXSessionWillLeaveRoomNotification to be notified if the user leaves the current room. + id leaveRoomNotificationObserver; + + // Observe kMXRoomDidFlushDataNotification to take into account the updated room state when the room history is flushed. + id roomDidFlushDataNotificationObserver; +} +@end + +@implementation MXKRoomSettingsViewController + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRoomSettingsViewController class]) + bundle:[NSBundle bundleForClass:[MXKRoomSettingsViewController class]]]; +} + ++ (instancetype)roomSettingsViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKRoomSettingsViewController class]) + bundle:[NSBundle bundleForClass:[MXKRoomSettingsViewController class]]]; +} + +#pragma mark - + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + [self refreshRoomSettings]; +} + +#pragma mark - Override MXKTableViewController + +- (void)finalizeInit +{ + [super finalizeInit]; +} + +- (void)destroy +{ + if (roomListener) + { + MXWeakify(self); + [mxRoom liveTimeline:^(MXEventTimeline *liveTimeline) { + MXStrongifyAndReturnIfNil(self); + + [liveTimeline removeListener:self->roomListener]; + self->roomListener = nil; + }]; + } + + if (leaveRoomNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:leaveRoomNotificationObserver]; + leaveRoomNotificationObserver = nil; + } + + if (roomDidFlushDataNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:roomDidFlushDataNotificationObserver]; + roomDidFlushDataNotificationObserver = nil; + } + + mxRoom = nil; + mxRoomState = nil; + + [super destroy]; +} + +- (void)onMatrixSessionStateDidChange:(NSNotification *)notif; +{ + // Check this is our Matrix session that has changed + if (notif.object == self.mainSession) + { + [super onMatrixSessionStateDidChange:notif]; + + // refresh when the session sync is done. + if (MXSessionStateRunning == self.mainSession.state) + { + [self refreshRoomSettings]; + } + } +} + +#pragma mark - Public API + +/** + Set the dedicated session and the room Id + */ +- (void)initWithSession:(MXSession*)mxSession andRoomId:(NSString*)roomId +{ + // Update the matrix session + if (self.mainSession) + { + [self removeMatrixSession:self.mainSession]; + } + mxRoom = nil; + + // Sanity checks + if (mxSession && roomId) + { + [self addMatrixSession:mxSession]; + + // Report the room identifier + _roomId = roomId; + mxRoom = [mxSession roomWithRoomId:roomId]; + } + + if (mxRoom) + { + // Register a listener to handle messages related to room name, topic... + MXWeakify(self); + [mxRoom liveTimeline:^(MXEventTimeline *liveTimeline) { + MXStrongifyAndReturnIfNil(self); + + self->roomListener = [liveTimeline listenToEventsOfTypes:@[kMXEventTypeStringRoomName, kMXEventTypeStringRoomTopic, kMXEventTypeStringRoomAliases, kMXEventTypeStringRoomAvatar, kMXEventTypeStringRoomPowerLevels, kMXEventTypeStringRoomCanonicalAlias, kMXEventTypeStringRoomJoinRules, kMXEventTypeStringRoomGuestAccess, kMXEventTypeStringRoomHistoryVisibility] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + + // Consider only live events + if (direction == MXTimelineDirectionForwards) + { + [self updateRoomState:liveTimeline.state]; + } + }]; + + // Observe kMXSessionWillLeaveRoomNotification to be notified if the user leaves the current room. + self->leaveRoomNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXSessionWillLeaveRoomNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + // Check whether the user will leave the room related to the displayed participants + if (notif.object == self.mainSession) + { + NSString *roomId = notif.userInfo[kMXSessionNotificationRoomIdKey]; + if (roomId && [roomId isEqualToString:self.roomId]) + { + // We remove the current view controller. + [self withdrawViewControllerAnimated:YES completion:nil]; + } + } + }]; + + // Observe room history flush (sync with limited timeline, or state event redaction) + self->roomDidFlushDataNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXRoomDidFlushDataNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXRoom *room = notif.object; + if (self.mainSession == room.mxSession && [self.roomId isEqualToString:room.roomId]) + { + // The existing room history has been flushed during server sync. Take into account the updated room state. + [self updateRoomState:liveTimeline.state]; + } + + }]; + + [self updateRoomState:liveTimeline.state]; + }]; + } + + self.title = [MatrixKitL10n roomDetailsTitle]; +} + +- (void)refreshRoomSettings +{ + [self.tableView reloadData]; +} + +- (void)updateRoomState:(MXRoomState*)newRoomState +{ + mxRoomState = newRoomState.copy; + + [self refreshRoomSettings]; +} + +#pragma mark - UITableViewDataSource + +// empty by default + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + return 0; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + // Return a fake cell to prevent app from crashing. + return [[UITableViewCell alloc] init]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.xib new file mode 100644 index 000000000..4dbf0f338 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.xib @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.h new file mode 100644 index 000000000..8ae86eccb --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.h @@ -0,0 +1,440 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKViewController.h" +#import "MXKRoomDataSource.h" +#import "MXKRoomTitleView.h" +#import "MXKRoomInputToolbarView.h" +#import "MXKRoomActivitiesView.h" +#import "MXKEventDetailsView.h" + +#import "MXKAttachmentsViewController.h" +#import "MXKAttachmentAnimator.h" + +typedef NS_ENUM(NSUInteger, MXKRoomViewControllerJoinRoomResult) { + MXKRoomViewControllerJoinRoomResultSuccess, + MXKRoomViewControllerJoinRoomResultFailureRoomEmpty, + MXKRoomViewControllerJoinRoomResultFailureJoinInProgress, + MXKRoomViewControllerJoinRoomResultFailureGeneric +}; + +/** + This view controller displays messages of a room. Only one matrix session is handled by this view controller. + */ +@interface MXKRoomViewController : MXKViewController +{ +@protected + /** + The identifier of the current event displayed at the bottom of the table (just above the toolbar). + Use to anchor the message displayed at the bottom during table refresh. + */ + NSString *currentEventIdAtTableBottom; + + /** + Boolean value used to scroll to bottom the bubble history after refresh. + */ + BOOL shouldScrollToBottomOnTableRefresh; + + /** + Potential event details view. + */ + __weak MXKEventDetailsView *eventDetailsView; + + /** + Current alert (if any). + */ + __weak UIAlertController *currentAlert; + + /** + The document interaction Controller used to share attachment + */ + UIDocumentInteractionController *documentInteractionController; + + /** + The current shared attachment. + */ + MXKAttachment *currentSharedAttachment; + + /** + The potential text input placeholder is saved when it is replaced temporarily + */ + NSString *savedInputToolbarPlaceholder; + + /** + Tell whether the input toolbar required to run an animation indicator. + */ + BOOL isInputToolbarProcessing; + + /** + Tell whether a device rotation is in progress + */ + BOOL isSizeTransitionInProgress; + + /** + The current visibility of the status bar in this view controller. + */ + BOOL isStatusBarHidden; + + /** + YES to prevent `bubblesTableView` scrolling when calling -[setBubbleTableViewContentOffset:animated:] + */ + BOOL preventBubblesTableViewScroll; +} + +/** + The current data source associated to the view controller. + */ +@property (nonatomic, readonly) MXKRoomDataSource *roomDataSource; + +/** + Flag indicating if this instance has the memory ownership of its `roomDataSource`. + If YES, it will release it on [self destroy] call; + Default is NO. + */ +@property (nonatomic) BOOL hasRoomDataSourceOwnership; + +/** + Tell whether the bubbles table view display is in transition. Its display is not warranty during the transition. + */ +@property (nonatomic, getter=isBubbleTableViewDisplayInTransition) BOOL bubbleTableViewDisplayInTransition; + +/** + Tell whether the automatic events acknowledgement (based on read receipt) is enabled. + Default is YES. + */ +@property (nonatomic, getter=isEventsAcknowledgementEnabled) BOOL eventsAcknowledgementEnabled; + +/** + Tell whether the room read marker must be updated when an event is acknowledged with a read receipt. + Default is NO. + */ +@property (nonatomic) BOOL updateRoomReadMarker; + +/** + When the room view controller displays a room data source based on a timeline with an initial event, + the bubble table view content is scrolled by default to display the top of this event at the center of the screen + the first time it appears. + Use this property to force the table view to center its content on the bottom part of the event. + Default is NO. + */ +@property (nonatomic) BOOL centerBubblesTableViewContentOnTheInitialEventBottom; + +/** + The current title view defined into the view controller. + */ +@property (nonatomic, weak, readonly) MXKRoomTitleView* titleView; + +/** + The current input toolbar view defined into the view controller. + */ +@property (nonatomic, weak, readonly) MXKRoomInputToolbarView* inputToolbarView; + +/** + The current extra info view defined into the view controller. + */ +@property (nonatomic, readonly) MXKRoomActivitiesView* activitiesView; + +/** + The threshold used to trigger inconspicuous back pagination, or forwards pagination + for non live timeline. A pagination is triggered when the vertical content offset + is lower this threshold. + Default is 300. + */ +@property (nonatomic) NSUInteger paginationThreshold; + +/** + The maximum number of messages to retrieve during a pagination. Default is 30. + */ +@property (nonatomic) NSUInteger paginationLimit; + +/** + Enable/disable saving of the current typed text in message composer when view disappears. + The message composer is prefilled with this text when the room is opened again. + This property value is YES by default. + */ +@property BOOL saveProgressTextInput; + +/** + The invited rooms can be automatically joined when the data source is ready. + This property enable/disable this option. Its value is YES by default. + */ +@property BOOL autoJoinInvitedRoom; + +/** + Tell whether the room history is automatically scrolled to the most recent messages + when a keyboard is presented. YES by default. + This option is ignored when an alert is presented. + */ +@property BOOL scrollHistoryToTheBottomOnKeyboardPresentation; + +/** + YES (default) to show actions button in document preview. NO otherwise. + */ +@property BOOL allowActionsInDocumentPreview; + +/** + Duration of the animation in case of the composer needs to be resized (default 0.3s) + */ +@property NSTimeInterval resizeComposerAnimationDuration; + +/** + This object is defined when the displayed room is left. It is added into the bubbles table header. + This label is used to display the reason why the room has been left. + */ +@property (nonatomic, weak, readonly) UILabel *leftRoomReasonLabel; + +@property (weak, nonatomic) IBOutlet UITableView *bubblesTableView; +@property (weak, nonatomic) IBOutlet UIView *roomTitleViewContainer; +@property (weak, nonatomic) IBOutlet UIView *roomInputToolbarContainer; +@property (weak, nonatomic) IBOutlet UIView *roomActivitiesContainer; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *bubblesTableViewTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *bubblesTableViewBottomConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *roomActivitiesContainerHeightConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *roomInputToolbarContainerHeightConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *roomInputToolbarContainerBottomConstraint; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKRoomViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `roomViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKRoomViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKRoomViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)roomViewController; + +/** + Display a room. + + @param dataSource the data source . + */ +- (void)displayRoom:(MXKRoomDataSource*)dataSource; + +/** + This method is called when the associated data source is ready. + + By default this operation triggers the initial back pagination when the user is an actual + member of the room (membership = join). + + The invited rooms are automatically joined during this operation if 'autoJoinInvitedRoom' is YES. + When the room is successfully joined, an initial back pagination is triggered too. + Else nothing is done for the invited rooms. + + Override it to customize the view controller behavior when the data source is ready. + */ +- (void)onRoomDataSourceReady; + +/** + Update view controller appearance according to the state of its associated data source. + This method is called in the following use cases: + - on data source change (see `[MXKRoomViewController displayRoom:]`). + - on data source state change (see `[MXKDataSourceDelegate dataSource:didStateChange:]`) + - when view did appear. + + The default implementation: + - show input toolbar view if the dataSource is defined and ready (`MXKDataSourceStateReady`), hide toolbar in others use cases. + - stop activity indicator if the dataSource is defined and ready (`MXKDataSourceStateReady`). + - update view controller title with room information. + + Override it to customize view appearance according to data source state. + */ +- (void)updateViewControllerAppearanceOnRoomDataSourceState; + +/** + This method is called when the associated data source has encountered an error on the timeline. + + Override it to customize the view controller behavior. + + @param notif the notification data sent with kMXKRoomDataSourceTimelineError notif. + */ +- (void)onTimelineError:(NSNotification *)notif; + +/** + Join the current displayed room. + + This operation fails if the user has already joined the room, or if the data source is not ready. + It fails if a join request is already running too. + + @param completion the block to execute at the end of the operation. + You may specify nil for this parameter. + */ +- (void)joinRoom:(void(^)(MXKRoomViewControllerJoinRoomResult result))completion; + +/** + Join a room with a room id or an alias. + + This operation fails if the user has already joined the room, or if the data source is not ready, + or if the access to the room is forbidden to the user. + It fails if a join request is already running too. + + @param roomIdOrAlias the id or the alias of the room to join. + @param viaServers The server names to try and join through in addition to those that are automatically chosen. It is optional and can be nil. + @param signUrl the signurl paramater passed with a 3PID invitation. It is optional and can be nil. + + @param completion the block to execute at the end of the operation. + You may specify nil for this parameter. + */ +- (void)joinRoomWithRoomIdOrAlias:(NSString*)roomIdOrAlias + viaServers:(NSArray*)viaServers + andSignUrl:(NSString*)signUrl + completion:(void(^)(MXKRoomViewControllerJoinRoomResult result))completion; + +/** + Update view controller appearance when the user is about to leave the displayed room. + This method is called when the user will leave the current room (see `kMXSessionWillLeaveRoomNotification`). + + The default implementation: + - discard `roomDataSource` + - hide input toolbar view + - freeze the room title display + - add a label (`leftRoomReasonLabel`) in bubbles table header to display the reason why the room has been left. + + Override it to customize view appearance, or to withdraw the view controller. + + @param event the MXEvent responsible for the leaving. + */ +- (void)leaveRoomOnEvent:(MXEvent*)event; + +/** + Register the class used to instantiate the title view which will handle the room name display. + + The resulting view is added into 'roomTitleViewContainer' view, which must be defined before calling this method. + + Note: By default the room name is displayed by using 'navigationItem.title' field of the view controller. + + @param roomTitleViewClass a MXKRoomTitleView-inherited class. + */ +- (void)setRoomTitleViewClass:(Class)roomTitleViewClass; + +/** + Register the class used to instantiate the input toolbar view which will handle message composer + and attachments selection for the room. + + The resulting view is added into 'roomInputToolbarContainer' view, which must be defined before calling this method. + + @param roomInputToolbarViewClass a MXKRoomInputToolbarView-inherited class, or nil to remove the current view. + */ +- (void)setRoomInputToolbarViewClass:(Class)roomInputToolbarViewClass; + +/** + Register the class used to instantiate the extra info view. + + The resulting view is added into 'roomActivitiesContainer' view, which must be defined before calling this method. + + @param roomActivitiesViewClass a MXKRoomActivitiesViewClass-inherited class, or nil to remove the current view. + */ +- (void)setRoomActivitiesViewClass:(Class)roomActivitiesViewClass; + +/** + Register the class used to instantiate the viewer dedicated to the attachments with thumbnail. + By default 'MXKAttachmentsViewController' class is used. + + @param attachmentsViewerClass a MXKAttachmentsViewController-inherited class, or nil to restore the default class. + */ +- (void)setAttachmentsViewerClass:(Class)attachmentsViewerClass; + +/** + Register the view class used to display the details of an event. + MXKEventDetailsView is used by default. + + @param eventDetailsViewClass a MXKEventDetailsView-inherited class. + */ +- (void)setEventDetailsViewClass:(Class)eventDetailsViewClass; + +/** + Detect and process potential IRC command in provided string. + + @param string to analyse + @return YES if IRC style command has been detected and interpreted. + */ +- (BOOL)isIRCStyleCommand:(NSString*)string; + +/** + Mention the member display name in the current text of the message composer. + The message composer becomes then the first responder. + */ +- (void)mention:(MXRoomMember*)roomMember; + +/** + Force to dismiss keyboard if any + */ +- (void)dismissKeyboard; + +/** + Tell whether the most recent message of the room history is visible. + */ +- (BOOL)isBubblesTableScrollViewAtTheBottom; + +/** + Scroll the room history until the most recent message. + */ +- (void)scrollBubblesTableViewToBottomAnimated:(BOOL)animated; + +/** + Dismiss the keyboard and all the potential subviews. + */ +- (void)dismissTemporarySubViews; + +/** + Display a popup with the event detais. + + @param event the event to inspect. + */ +- (void)showEventDetails:(MXEvent *)event; + +/** + Present the attachments viewer by displaying the attachment of the provided cell. + + @param cell the table view cell with attachment + */ +- (void)showAttachmentInCell:(UITableViewCell*)cell; + +/** + Force a refresh of the room history display. + + You should not call this method directly. + You may override it in inherited 'MXKRoomViewController' class. + + @param useBottomAnchor tells whether the updated history must keep display the same event at the bottom. + @return a boolean value which tells whether the table has been scrolled to the bottom. + */ +- (BOOL)reloadBubblesTable:(BOOL)useBottomAnchor; + +/** + Sets the offset from the content `bubblesTableView`'s origin. Take into account `preventBubblesTableViewScroll` value. + + @param contentOffset Offset from the content `bubblesTableView`’s origin. + @param animated YES to animate the transition. + */ +- (void)setBubbleTableViewContentOffset:(CGPoint)contentOffset animated:(BOOL)animated; + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.m new file mode 100644 index 000000000..382373a92 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.m @@ -0,0 +1,4066 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#define MXKROOMVIEWCONTROLLER_DEFAULT_TYPING_TIMEOUT_SEC 10 +#define MXKROOMVIEWCONTROLLER_MESSAGES_TABLE_MINIMUM_HEIGHT 50 + +#import "MXKRoomViewController.h" + +#import + +#import "MXKRoomBubbleTableViewCell.h" +#import "MXKSearchTableViewCell.h" +#import "MXKImageView.h" + +#import "MXKRoomDataSourceManager.h" + +#import "MXKRoomInputToolbarViewWithSimpleTextView.h" + +#import "MXKConstants.h" + +#import "MXKRoomBubbleCellData.h" + +#import "MXKRoomIncomingTextMsgBubbleCell.h" +#import "MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.h" +#import "MXKRoomIncomingAttachmentBubbleCell.h" +#import "MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.h" + +#import "MXKRoomOutgoingTextMsgBubbleCell.h" +#import "MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.h" +#import "MXKRoomOutgoingAttachmentBubbleCell.h" +#import "MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.h" + +#import "MXKEncryptionKeysImportView.h" + +#import "NSBundle+MatrixKit.h" +#import "MXKSlashCommands.h" +#import "MXKSwiftHeader.h" + +#import "MXKPreviewViewController.h" + +@interface MXKRoomViewController () +{ + /** + YES once the view has appeared + */ + BOOL hasAppearedOnce; + + /** + YES if scrolling to bottom is in progress + */ + BOOL isScrollingToBottom; + + /** + Date of the last observed typing + */ + NSDate *lastTypingDate; + + /** + Local typing timout + */ + NSTimer *typingTimer; + + /** + YES when pagination is in progress. + */ + BOOL isPaginationInProgress; + + /** + The back pagination spinner view. + */ + UIView* backPaginationActivityView; + + /** + Store the height of the first bubble before back pagination. + */ + CGFloat backPaginationSavedFirstBubbleHeight; + + /** + Potential request in progress to join the selected room + */ + MXHTTPOperation *joinRoomRequest; + + /** + Text selection + */ + NSString *selectedText; + + /** + The class used to instantiate attachments viewer for image and video.. + */ + Class attachmentsViewerClass; + + /** + The class used to display event details. + */ + Class customEventDetailsViewClass; + + /** + The reconnection animated view. + */ + UIView* reconnectingView; + + /** + The view to import e2e keys. + */ + MXKEncryptionKeysImportView *importView; + + /** + The latest server sync date + */ + NSDate* latestServerSync; + + /** + The restart the event connnection + */ + BOOL restartConnection; +} + +/** + The eventId of the Attachment that was used to open the Attachments ViewController + */ +@property (nonatomic) NSString *openedAttachmentEventId; + +/** + The eventId of the Attachment from which the Attachments ViewController was closed + */ +@property (nonatomic) NSString *closedAttachmentEventId; + +@property (nonatomic) UIImageView *openedAttachmentImageView; + +/** + Observe kMXSessionWillLeaveRoomNotification to be notified if the user leaves the current room. + */ +@property (nonatomic, weak) id mxSessionWillLeaveRoomNotificationObserver; + +/** + Observe UIApplicationDidBecomeActiveNotification to refresh bubbles when app leaves the background state. + */ +@property (nonatomic, weak) id uiApplicationDidBecomeActiveNotificationObserver; + +/** + Observe UIMenuControllerDidHideMenuNotification to cancel text selection + */ +@property (nonatomic, weak) id uiMenuControllerDidHideMenuNotificationObserver; + +/** + The attachments viewer for image and video. + */ +@property (nonatomic, weak) MXKAttachmentsViewController *attachmentsViewer; + +@end + +@implementation MXKRoomViewController +@synthesize roomDataSource, titleView, inputToolbarView, activitiesView; + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRoomViewController class]) + bundle:[NSBundle bundleForClass:[MXKRoomViewController class]]]; +} + ++ (instancetype)roomViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKRoomViewController class]) + bundle:[NSBundle bundleForClass:[MXKRoomViewController class]]]; +} + +#pragma mark - + +- (void)finalizeInit +{ + [super finalizeInit]; + + // Scroll to bottom the bubble history at first display + shouldScrollToBottomOnTableRefresh = YES; + + // Default pagination settings + _paginationThreshold = 300; + _paginationLimit = 30; + + // Save progress text input by default + _saveProgressTextInput = YES; + + // Enable auto join option by default + _autoJoinInvitedRoom = YES; + + // Do not take ownership of room data source by default + _hasRoomDataSourceOwnership = NO; + + // Turn on the automatic events acknowledgement. + _eventsAcknowledgementEnabled = YES; + + // Do not update the read marker by default. + _updateRoomReadMarker = NO; + + // Center the table content on the initial event top by default. + _centerBubblesTableViewContentOnTheInitialEventBottom = NO; + + // Scroll to the bottom when a keyboard is presented + _scrollHistoryToTheBottomOnKeyboardPresentation = YES; + + // Keep visible the status bar by default. + isStatusBarHidden = NO; + + // By default actions button is shown in document preview + _allowActionsInDocumentPreview = YES; + + // By default the duration of the composer resizing is 0.3s + _resizeComposerAnimationDuration = 0.3; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!_bubblesTableView) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + // Adjust bottom constraint of the input toolbar container in order to take into account potential tabBar + _roomInputToolbarContainerBottomConstraint.active = NO; + _roomInputToolbarContainerBottomConstraint = [NSLayoutConstraint constraintWithItem:self.bottomLayoutGuide + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self.roomInputToolbarContainer + attribute:NSLayoutAttributeBottom + multiplier:1.0f + constant:0.0f]; + _roomInputToolbarContainerBottomConstraint.active = YES; + [self.view setNeedsUpdateConstraints]; + + // Hide bubbles table by default in order to hide initial scrolling to the bottom + _bubblesTableView.hidden = YES; + + // Ensure that the titleView will be scaled when it will be required + // during a screen rotation for example. + _roomTitleViewContainer.autoresizingMask = UIViewAutoresizingFlexibleWidth; + + // Set default input toolbar view + [self setRoomInputToolbarViewClass:MXKRoomInputToolbarViewWithSimpleTextView.class]; + + // set the default extra + [self setRoomActivitiesViewClass:MXKRoomActivitiesView.class]; + + // Finalize table view configuration + [self configureBubblesTableView]; + + // Observe UIApplicationDidBecomeActiveNotification to refresh bubbles when app leaves the background state. + MXWeakify(self); + _uiApplicationDidBecomeActiveNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidBecomeActiveNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXStrongifyAndReturnIfNil(self); + if (self->roomDataSource.state == MXKDataSourceStateReady && [self->roomDataSource tableView:self->_bubblesTableView numberOfRowsInSection:0]) + { + // Reload the full table + self.bubbleTableViewDisplayInTransition = YES; + [self reloadBubblesTable:YES]; + self.bubbleTableViewDisplayInTransition = NO; + } + }]; + + if ([MXKAppSettings standardAppSettings].outboundGroupSessionKeyPreSharingStrategy == MXKKeyPreSharingWhenEnteringRoom) + { + [self shareEncryptionKeys]; + } +} + +- (BOOL)prefersStatusBarHidden +{ + // Return the current status bar visibility. + // Caution: Enable [UIViewController prefersStatusBarHidden] use at application level + // by turning on UIViewControllerBasedStatusBarAppearance in Info.plist. + return isStatusBarHidden; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + // Observe server sync process at room data source level too + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMatrixSessionChange) name:kMXKRoomDataSourceSyncStatusChanged object:nil]; + + // Observe timeline failure + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onTimelineError:) name:kMXKRoomDataSourceTimelineError object:nil]; + + // Observe the server sync + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onSyncNotification) name:kMXSessionDidSyncNotification object:nil]; + + // Be sure to display the activity indicator during back pagination + if (isPaginationInProgress) + { + [self startActivityIndicator]; + } + + // Finalize view controller appearance + [self updateViewControllerAppearanceOnRoomDataSourceState]; + + // no need to reload the tableview at this stage + // IOS is going to load it after calling this method + // so give a breath to scroll to the bottom if required + if (shouldScrollToBottomOnTableRefresh) + { + self.bubbleTableViewDisplayInTransition = YES; + + dispatch_async(dispatch_get_main_queue(), ^{ + + [self scrollBubblesTableViewToBottomAnimated:NO]; + + // Show bubbles table after initial scrolling to the bottom + // Patch: We need to delay this operation to wait for the end of scrolling. + dispatch_after(dispatch_walltime(DISPATCH_TIME_NOW, 0.3 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ + + self->_bubblesTableView.hidden = NO; + self.bubbleTableViewDisplayInTransition = NO; + + }); + + }); + } + else + { + _bubblesTableView.hidden = NO; + } +} + +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + + // Remove the rounded bottom unsafe area of the iPhone X + _bubblesTableViewBottomConstraint.constant += self.view.safeAreaInsets.bottom; + + if (_saveProgressTextInput && roomDataSource) + { + // Retrieve the potential message partially typed during last room display. + // Note: We have to wait for viewDidAppear before updating growingTextView (viewWillAppear is too early) + inputToolbarView.textMessage = roomDataSource.partialTextMessage; + } + + if (!hasAppearedOnce) + { + hasAppearedOnce = YES; + } + + // Mark all messages as read when the room is displayed + [self.roomDataSource.room.summary markAllAsReadLocally]; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXKRoomDataSourceSyncStatusChanged object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXKRoomDataSourceTimelineError object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidSyncNotification object:nil]; + + [self removeReconnectingView]; +} + +- (void)dealloc +{ + if (_mxSessionWillLeaveRoomNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:_mxSessionWillLeaveRoomNotificationObserver]; + } + + if (_uiApplicationDidBecomeActiveNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:_uiApplicationDidBecomeActiveNotificationObserver]; + } + + if (_uiMenuControllerDidHideMenuNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:_uiMenuControllerDidHideMenuNotificationObserver]; + } + + [self destroy]; +} + +- (void)didReceiveMemoryWarning +{ + [super didReceiveMemoryWarning]; + + // Dispose of any resources that can be recreated. +} + +- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id )coordinator +{ + isSizeTransitionInProgress = YES; + shouldScrollToBottomOnTableRefresh = [self isBubblesTableScrollViewAtTheBottom]; + + [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(coordinator.transitionDuration * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + + if (!self.keyboardView) + { + [self updateMessageTextViewFrame]; + } + + // Force full table refresh to take into account cell width change. + self.bubbleTableViewDisplayInTransition = YES; + [self reloadBubblesTable:YES invalidateBubblesCellDataCache:YES]; + self.bubbleTableViewDisplayInTransition = NO; + + self->shouldScrollToBottomOnTableRefresh = NO; + self->isSizeTransitionInProgress = NO; + }); +} + +// The 2 following methods are deprecated since iOS 8 +- (void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration +{ + isSizeTransitionInProgress = YES; + shouldScrollToBottomOnTableRefresh = [self isBubblesTableScrollViewAtTheBottom]; + + [super willRotateToInterfaceOrientation:toInterfaceOrientation duration:duration]; +} +- (void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation +{ + [super didRotateFromInterfaceOrientation:fromInterfaceOrientation]; + + if (!self.keyboardView) + { + [self updateMessageTextViewFrame]; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + // Force full table refresh to take into account cell width change. + self.bubbleTableViewDisplayInTransition = YES; + [self reloadBubblesTable:YES]; + self.bubbleTableViewDisplayInTransition = NO; + + self->shouldScrollToBottomOnTableRefresh = NO; + self->isSizeTransitionInProgress = NO; + }); +} + +- (void)viewDidLayoutSubviews +{ + [super viewDidLayoutSubviews]; + + CGFloat bubblesTableViewBottomConst = self.roomInputToolbarContainerBottomConstraint.constant + self.roomInputToolbarContainerHeightConstraint.constant + self.roomActivitiesContainerHeightConstraint.constant; + + if (self.bubblesTableViewBottomConstraint.constant != bubblesTableViewBottomConst) + { + self.bubblesTableViewBottomConstraint.constant = bubblesTableViewBottomConst; + } + +} + +#pragma mark - Override MXKViewController + +- (void)onMatrixSessionChange +{ + [super onMatrixSessionChange]; + + // Check dataSource state + if (self.roomDataSource && (self.roomDataSource.state == MXKDataSourceStatePreparing || self.roomDataSource.serverSyncEventCount)) + { + // dataSource is not ready, keep running the loading wheel + [self startActivityIndicator]; + } +} + +- (void)onKeyboardShowAnimationComplete +{ + // Check first if the first responder belongs to title view + UIView *keyboardView = titleView.inputAccessoryView.superview; + if (!keyboardView) + { + // Check whether the first responder is the input tool bar text composer + keyboardView = inputToolbarView.inputAccessoryView.superview; + } + + // Report the keyboard view in order to track keyboard frame changes + self.keyboardView = keyboardView; +} + +- (void)setKeyboardHeight:(CGFloat)keyboardHeight +{ + // Deduce the bottom constraint for the input toolbar view (Don't forget the potential tabBar) + CGFloat inputToolbarViewBottomConst = keyboardHeight - self.bottomLayoutGuide.length; + // Check whether the keyboard is over the tabBar + if (inputToolbarViewBottomConst < 0) + { + inputToolbarViewBottomConst = 0; + } + + // Update constraints + _roomInputToolbarContainerBottomConstraint.constant = inputToolbarViewBottomConst; + _bubblesTableViewBottomConstraint.constant = inputToolbarViewBottomConst + _roomInputToolbarContainerHeightConstraint.constant + _roomActivitiesContainerHeightConstraint.constant; + + // Remove the rounded bottom unsafe area of the iPhone X + _bubblesTableViewBottomConstraint.constant += self.view.safeAreaInsets.bottom; + + // Invalidate the current layout to take into account the new constraints in the next update cycle. + [self.view setNeedsLayout]; + + // Compute the visible area (tableview + toolbar) at the end of animation + CGFloat visibleArea = self.view.frame.size.height - _bubblesTableView.adjustedContentInset.top - keyboardHeight; + // Deduce max height of the message text input by considering the minimum height of the table view. + inputToolbarView.maxHeight = visibleArea - MXKROOMVIEWCONTROLLER_MESSAGES_TABLE_MINIMUM_HEIGHT; + + // Check conditions before scrolling the tableview content when a new keyboard is presented. + if ((_scrollHistoryToTheBottomOnKeyboardPresentation || [self isBubblesTableScrollViewAtTheBottom]) && !super.keyboardHeight && keyboardHeight && !currentAlert) + { + self.bubbleTableViewDisplayInTransition = YES; + + // Force here the layout update to scroll correctly the table content. + [self.view layoutIfNeeded]; + [self scrollBubblesTableViewToBottomAnimated:NO]; + + self.bubbleTableViewDisplayInTransition = NO; + } + else + { + [self updateCurrentEventIdAtTableBottom:NO]; + } + + super.keyboardHeight = keyboardHeight; +} + +- (void)destroy +{ + if (documentInteractionController) + { + [documentInteractionController dismissPreviewAnimated:NO]; + [documentInteractionController dismissMenuAnimated:NO]; + documentInteractionController = nil; + } + + if (currentSharedAttachment) + { + [currentSharedAttachment onShareEnded]; + currentSharedAttachment = nil; + } + + [self dismissTemporarySubViews]; + + _bubblesTableView.dataSource = nil; + _bubblesTableView.delegate = nil; + _bubblesTableView = nil; + + if (roomDataSource.delegate == self) + { + roomDataSource.delegate = nil; + } + + if (_hasRoomDataSourceOwnership) + { + // Release the room data source + [roomDataSource destroy]; + } + roomDataSource = nil; + + if (titleView) + { + [titleView removeFromSuperview]; + [titleView destroy]; + titleView = nil; + } + + if (inputToolbarView) + { + [inputToolbarView removeFromSuperview]; + [inputToolbarView destroy]; + inputToolbarView = nil; + } + + if (activitiesView) + { + [activitiesView removeFromSuperview]; + [activitiesView destroy]; + activitiesView = nil; + } + + [typingTimer invalidate]; + typingTimer = nil; + + if (joinRoomRequest) + { + [joinRoomRequest cancel]; + joinRoomRequest = nil; + } + + [super destroy]; +} + +#pragma mark - + +- (void)configureBubblesTableView +{ + // Set up table delegates + _bubblesTableView.delegate = self; + _bubblesTableView.dataSource = roomDataSource; // Note: data source may be nil here, it will be set during [displayRoom:] call. + + // Set up default classes to use for cells + [_bubblesTableView registerClass:MXKRoomIncomingTextMsgBubbleCell.class forCellReuseIdentifier:MXKRoomIncomingTextMsgBubbleCell.defaultReuseIdentifier]; + [_bubblesTableView registerClass:MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.class forCellReuseIdentifier:MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.defaultReuseIdentifier]; + [_bubblesTableView registerClass:MXKRoomIncomingAttachmentBubbleCell.class forCellReuseIdentifier:MXKRoomIncomingAttachmentBubbleCell.defaultReuseIdentifier]; + [_bubblesTableView registerClass:MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.class forCellReuseIdentifier:MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.defaultReuseIdentifier]; + + [_bubblesTableView registerClass:MXKRoomOutgoingTextMsgBubbleCell.class forCellReuseIdentifier:MXKRoomOutgoingTextMsgBubbleCell.defaultReuseIdentifier]; + [_bubblesTableView registerClass:MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.class forCellReuseIdentifier:MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.defaultReuseIdentifier]; + [_bubblesTableView registerClass:MXKRoomOutgoingAttachmentBubbleCell.class forCellReuseIdentifier:MXKRoomOutgoingAttachmentBubbleCell.defaultReuseIdentifier]; + [_bubblesTableView registerClass:MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.class forCellReuseIdentifier:MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.defaultReuseIdentifier]; + + // Observe kMXSessionWillLeaveRoomNotification to be notified if the user leaves the current room. + MXWeakify(self); + _mxSessionWillLeaveRoomNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXSessionWillLeaveRoomNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXStrongifyAndReturnIfNil(self); + // Check whether the user will leave the current room + if (notif.object == self.mainSession) + { + NSString *roomId = notif.userInfo[kMXSessionNotificationRoomIdKey]; + if (roomId && [roomId isEqualToString:self->roomDataSource.roomId]) + { + // Update view controller appearance + [self leaveRoomOnEvent:notif.userInfo[kMXSessionNotificationEventKey]]; + } + } + }]; +} + +- (void)updateMessageTextViewFrame +{ + if (!self.keyboardView) + { + // Compute the visible area (tableview + toolbar) + CGFloat visibleArea = self.view.frame.size.height - _bubblesTableView.adjustedContentInset.top; + // Deduce max height of the message text input by considering the minimum height of the table view. + inputToolbarView.maxHeight = visibleArea - MXKROOMVIEWCONTROLLER_MESSAGES_TABLE_MINIMUM_HEIGHT; + } +} + +- (CGFloat)tableViewSafeAreaWidth +{ + CGFloat safeAreaInsetsWidth; + + // Take safe area into account + safeAreaInsetsWidth = self.bubblesTableView.safeAreaInsets.left + self.bubblesTableView.safeAreaInsets.right; + + return self.bubblesTableView.frame.size.width - safeAreaInsetsWidth; +} + +#pragma mark - Public API + +- (void)displayRoom:(MXKRoomDataSource *)dataSource +{ + if (roomDataSource) + { + if (self.hasRoomDataSourceOwnership) + { + // Release the room data source + [roomDataSource destroy]; + } + else if (roomDataSource.delegate == self) + { + roomDataSource.delegate = nil; + } + roomDataSource = nil; + + [self removeMatrixSession:self.mainSession]; + } + + // Reset the current event id + currentEventIdAtTableBottom = nil; + + if (dataSource) + { + if (!dataSource.isLive || dataSource.isPeeking) + { + // Remove the input toolbar if the displayed timeline is not a live one or in case of peeking. + // We do not let the user type message in this case. + [self setRoomInputToolbarViewClass:nil]; + } + + roomDataSource = dataSource; + roomDataSource.delegate = self; + roomDataSource.paginationLimitAroundInitialEvent = _paginationLimit; + + // Report the matrix session at view controller level to update UI according to session state + [self addMatrixSession:roomDataSource.mxSession]; + + if (_bubblesTableView) + { + [self dismissTemporarySubViews]; + + // Set up table data source + _bubblesTableView.dataSource = roomDataSource; + } + + // When ready, do the initial back pagination + if (roomDataSource.state == MXKDataSourceStateReady) + { + [self onRoomDataSourceReady]; + } + } + + [self updateViewControllerAppearanceOnRoomDataSourceState]; +} + +- (void)onRoomDataSourceReady +{ + // If the user is only invited, auto-join the room if this option is enabled + if (roomDataSource.room.summary.membership == MXMembershipInvite) + { + if (_autoJoinInvitedRoom) + { + [self joinRoom:nil]; + } + } + else + { + [self triggerInitialBackPagination]; + } +} + +- (void)updateViewControllerAppearanceOnRoomDataSourceState +{ + // Update UI by considering dataSource state + if (roomDataSource && roomDataSource.state == MXKDataSourceStateReady) + { + [self stopActivityIndicator]; + + if (titleView) + { + titleView.mxRoom = roomDataSource.room; + titleView.editable = YES; + titleView.hidden = NO; + } + else + { + // set default title + self.navigationItem.title = roomDataSource.room.summary.displayname; + } + + // Show input tool bar + inputToolbarView.hidden = NO; + } + else + { + // Update the title except if the room has just been left + if (!_leftRoomReasonLabel) + { + if (roomDataSource && roomDataSource.state == MXKDataSourceStatePreparing) + { + if (titleView) + { + titleView.mxRoom = roomDataSource.room; + titleView.hidden = (!titleView.mxRoom); + } + else + { + self.navigationItem.title = roomDataSource.room.summary.displayname; + } + } + else + { + if (titleView) + { + titleView.mxRoom = nil; + titleView.hidden = NO; + } + else + { + self.navigationItem.title = nil; + } + } + } + titleView.editable = NO; + + // Hide input tool bar + inputToolbarView.hidden = YES; + } + + // Finalize room title refresh + [titleView refreshDisplay]; + + if (activitiesView) + { + // Hide by default the activity view when no room is displayed + activitiesView.hidden = (roomDataSource == nil); + } +} + +- (void)onTimelineError:(NSNotification *)notif +{ + if (notif.object == roomDataSource) + { + [self stopActivityIndicator]; + + // Compute the message to display to the end user + NSString *errorTitle; + NSString *errorMessage; + + NSError *error = notif.userInfo[kMXKRoomDataSourceTimelineErrorErrorKey]; + if ([MXError isMXError:error]) + { + MXError *mxError = [[MXError alloc] initWithNSError:error]; + if ([mxError.errcode isEqualToString:kMXErrCodeStringNotFound]) + { + errorTitle = [MatrixKitL10n roomErrorTimelineEventNotFoundTitle]; + errorMessage = [MatrixKitL10n roomErrorTimelineEventNotFound]; + } + else + { + errorTitle = [MatrixKitL10n roomErrorCannotLoadTimeline]; + errorMessage = mxError.error; + } + } + else + { + errorTitle = [MatrixKitL10n roomErrorCannotLoadTimeline]; + } + + // And show it + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + + __weak typeof(self) weakSelf = self; + UIAlertController *errorAlert = [UIAlertController alertControllerWithTitle:errorTitle + message:errorMessage + preferredStyle:UIAlertControllerStyleAlert]; + + [errorAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + }]]; + + [self presentViewController:errorAlert animated:YES completion:nil]; + currentAlert = errorAlert; + } +} + +- (void)joinRoom:(void(^)(MXKRoomViewControllerJoinRoomResult result))completion +{ + if (joinRoomRequest != nil) + { + if (completion) + { + completion(MXKRoomViewControllerJoinRoomResultFailureJoinInProgress); + } + return; + } + + [self startActivityIndicator]; + + joinRoomRequest = [roomDataSource.room join:^{ + + self->joinRoomRequest = nil; + [self stopActivityIndicator]; + + [self triggerInitialBackPagination]; + + if (completion) + { + completion(MXKRoomViewControllerJoinRoomResultSuccess); + } + + } failure:^(NSError *error) { + MXLogDebug(@"[MXKRoomVC] Failed to join room (%@)", self->roomDataSource.room.summary.displayname); + [self processRoomJoinFailureWithError:error completion:completion]; + }]; +} + +- (void)joinRoomWithRoomIdOrAlias:(NSString*)roomIdOrAlias + viaServers:(NSArray*)viaServers + andSignUrl:(NSString*)signUrl + completion:(void(^)(MXKRoomViewControllerJoinRoomResult result))completion +{ + if (joinRoomRequest != nil) + { + if (completion) + { + completion(MXKRoomViewControllerJoinRoomResultFailureJoinInProgress); + } + + return; + } + + [self startActivityIndicator]; + + void (^success)(MXRoom *room) = ^(MXRoom *room) { + + self->joinRoomRequest = nil; + [self stopActivityIndicator]; + + MXWeakify(self); + + // The room is now part of the user's room + MXKRoomDataSourceManager *roomDataSourceManager = [MXKRoomDataSourceManager sharedManagerForMatrixSession:self.mainSession]; + + [roomDataSourceManager roomDataSourceForRoom:room.roomId create:YES onComplete:^(MXKRoomDataSource *newRoomDataSource) { + + MXStrongifyAndReturnIfNil(self); + + // And can be displayed + [self displayRoom:newRoomDataSource]; + + if (completion) + { + completion(MXKRoomViewControllerJoinRoomResultSuccess); + } + }]; + }; + + void (^failure)(NSError *error) = ^(NSError *error) { + MXLogDebug(@"[MXKRoomVC] Failed to join room (%@)", roomIdOrAlias); + [self processRoomJoinFailureWithError:error completion:completion]; + }; + + // Does the join need to be validated before? + if (signUrl) + { + joinRoomRequest = [self.mainSession joinRoom:roomIdOrAlias viaServers:viaServers withSignUrl:signUrl success:success failure:failure]; + } + else + { + joinRoomRequest = [self.mainSession joinRoom:roomIdOrAlias viaServers:viaServers success:success failure:failure]; + } +} + +- (void)processRoomJoinFailureWithError:(NSError *)error completion:(void(^)(MXKRoomViewControllerJoinRoomResult result))completion +{ + self->joinRoomRequest = nil; + [self stopActivityIndicator]; + + // Show the error to the end user + NSString *msg = [error.userInfo valueForKey:NSLocalizedDescriptionKey]; + + // FIXME: We should hide this inside the SDK and expose it as a domain specific error + BOOL isRoomEmpty = [msg isEqualToString:@"No known servers"]; + if (isRoomEmpty) + { + // minging kludge until https://matrix.org/jira/browse/SYN-678 is fixed + // 'Error when trying to join an empty room should be more explicit' + msg = [MatrixKitL10n roomErrorJoinFailedEmptyRoom]; + } + + MXWeakify(self); + [self->currentAlert dismissViewControllerAnimated:NO completion:nil]; + + UIAlertController *errorAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n roomErrorJoinFailedTitle] + message:msg + preferredStyle:UIAlertControllerStyleAlert]; + + [errorAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + self->currentAlert = nil; + + if (completion) + { + completion((isRoomEmpty ? MXKRoomViewControllerJoinRoomResultFailureRoomEmpty : MXKRoomViewControllerJoinRoomResultFailureGeneric)); + } + }]]; + + [self presentViewController:errorAlert animated:YES completion:nil]; + currentAlert = errorAlert; +} + +- (void)leaveRoomOnEvent:(MXEvent*)event +{ + [self dismissTemporarySubViews]; + + NSString *reason = nil; + if (event) + { + MXKEventFormatterError error; + reason = [roomDataSource.eventFormatter stringFromEvent:event withRoomState:roomDataSource.roomState error:&error]; + if (error != MXKEventFormatterErrorNone) + { + reason = nil; + } + } + + if (!reason.length) + { + if (self.roomDataSource.room.isDirect) + { + reason = [MatrixKitL10n roomLeftForDm]; + } + else + { + reason = [MatrixKitL10n roomLeft]; + } + } + + + _bubblesTableView.dataSource = nil; + _bubblesTableView.delegate = nil; + + if (self.hasRoomDataSourceOwnership) + { + // Release the room data source + [roomDataSource destroy]; + } + else if (roomDataSource.delegate == self) + { + roomDataSource.delegate = nil; + } + roomDataSource = nil; + + // Add reason label + _leftRoomReasonLabel = [[UILabel alloc] initWithFrame:CGRectMake(10, 5, self.view.frame.size.width - 20, 70)]; + _leftRoomReasonLabel.numberOfLines = 0; + _leftRoomReasonLabel.text = reason; + _leftRoomReasonLabel.autoresizingMask = UIViewAutoresizingFlexibleWidth; + _bubblesTableView.tableHeaderView = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, self.view.frame.size.width, 80)]; + [_bubblesTableView.tableHeaderView addSubview:_leftRoomReasonLabel]; + [_bubblesTableView reloadData]; + + [self updateViewControllerAppearanceOnRoomDataSourceState]; +} + +- (void)setPaginationLimit:(NSUInteger)paginationLimit +{ + _paginationLimit = paginationLimit; + + // Use the same value when loading messages around the initial event + roomDataSource.paginationLimitAroundInitialEvent = _paginationLimit; +} + +- (void)setRoomTitleViewClass:(Class)roomTitleViewClass +{ + // Sanity check: accept only MXKRoomTitleView classes or sub-classes + NSParameterAssert([roomTitleViewClass isSubclassOfClass:MXKRoomTitleView.class]); + + // Remove potential title view + if (titleView) + { + [NSLayoutConstraint deactivateConstraints:titleView.constraints]; + + [titleView dismissKeyboard]; + [titleView removeFromSuperview]; + [titleView destroy]; + } + + titleView = self.navigationItem.titleView = [roomTitleViewClass roomTitleView]; + titleView.delegate = self; + + // Define directly the navigation titleView with the custom title view instance. Do not use anymore a container. + self.navigationItem.titleView = titleView; + + [self updateViewControllerAppearanceOnRoomDataSourceState]; +} + +- (void)setRoomInputToolbarViewClass:(Class)roomInputToolbarViewClass +{ + if (!_roomInputToolbarContainer) + { + MXLogDebug(@"[MXKRoomVC] Set roomInputToolbarViewClass failed: container is missing"); + return; + } + + // Remove potential toolbar + if (inputToolbarView) + { + MXLogDebug(@"[MXKRoomVC] setRoomInputToolbarViewClass: Set inputToolbarView with class %@ to nil", [self.inputToolbarView class]); + + [NSLayoutConstraint deactivateConstraints:inputToolbarView.constraints]; + [inputToolbarView dismissKeyboard]; + [inputToolbarView removeFromSuperview]; + [inputToolbarView destroy]; + inputToolbarView = nil; + } + + if (roomDataSource && (!roomDataSource.isLive || roomDataSource.isPeeking)) + { + // Do not show the input toolbar if the displayed timeline is not a live one, or in case of peeking. + // We do not let the user type message in this case. + roomInputToolbarViewClass = nil; + } + + if (roomInputToolbarViewClass) + { + // Sanity check: accept only MXKRoomInputToolbarView classes or sub-classes + NSParameterAssert([roomInputToolbarViewClass isSubclassOfClass:MXKRoomInputToolbarView.class]); + + MXLogDebug(@"[MXKRoomVC] setRoomInputToolbarViewClass: Set inputToolbarView to class %@", roomInputToolbarViewClass); + + id inputToolbarView = [roomInputToolbarViewClass roomInputToolbarView]; + self->inputToolbarView = inputToolbarView; + self->inputToolbarView.delegate = self; + + // Add the input toolbar view and define edge constraints + [_roomInputToolbarContainer addSubview:inputToolbarView]; + [_roomInputToolbarContainer addConstraint:[NSLayoutConstraint constraintWithItem:_roomInputToolbarContainer + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:inputToolbarView + attribute:NSLayoutAttributeBottom + multiplier:1.0f + constant:0.0f]]; + [_roomInputToolbarContainer addConstraint:[NSLayoutConstraint constraintWithItem:_roomInputToolbarContainer + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:inputToolbarView + attribute:NSLayoutAttributeTop + multiplier:1.0f + constant:0.0f]]; + [_roomInputToolbarContainer addConstraint:[NSLayoutConstraint constraintWithItem:_roomInputToolbarContainer + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:inputToolbarView + attribute:NSLayoutAttributeLeading + multiplier:1.0f + constant:0.0f]]; + [_roomInputToolbarContainer addConstraint:[NSLayoutConstraint constraintWithItem:_roomInputToolbarContainer + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:inputToolbarView + attribute:NSLayoutAttributeTrailing + multiplier:1.0f + constant:0.0f]]; + } + + [_roomInputToolbarContainer setNeedsUpdateConstraints]; +} + + +- (void)setRoomActivitiesViewClass:(Class)roomActivitiesViewClass +{ + if (!_roomActivitiesContainer) + { + MXLogDebug(@"[MXKRoomVC] Set RoomActivitiesViewClass failed: container is missing"); + return; + } + + // Remove potential toolbar + if (activitiesView) + { + [NSLayoutConstraint deactivateConstraints:activitiesView.constraints]; + [activitiesView removeFromSuperview]; + [activitiesView destroy]; + activitiesView = nil; + } + + if (roomActivitiesViewClass) + { + // Sanity check: accept only MXKRoomExtraInfoView classes or sub-classes + NSParameterAssert([roomActivitiesViewClass isSubclassOfClass:MXKRoomActivitiesView.class]); + + activitiesView = [roomActivitiesViewClass roomActivitiesView]; + + // Add the view and define edge constraints + activitiesView.translatesAutoresizingMaskIntoConstraints = NO; + [_roomActivitiesContainer addSubview:activitiesView]; + + NSLayoutConstraint* topConstraint = [NSLayoutConstraint constraintWithItem:_roomActivitiesContainer + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:activitiesView + attribute:NSLayoutAttributeTop + multiplier:1.0f + constant:0.0f]; + + + NSLayoutConstraint* leadingConstraint = [NSLayoutConstraint constraintWithItem:_roomActivitiesContainer + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:activitiesView + attribute:NSLayoutAttributeLeading + multiplier:1.0f + constant:0.0f]; + + NSLayoutConstraint* widthConstraint = [NSLayoutConstraint constraintWithItem:_roomActivitiesContainer + attribute:NSLayoutAttributeWidth + relatedBy:NSLayoutRelationEqual + toItem:activitiesView + attribute:NSLayoutAttributeWidth + multiplier:1.0f + constant:0.0f]; + + NSLayoutConstraint* heightConstraint = [NSLayoutConstraint constraintWithItem:_roomActivitiesContainer + attribute:NSLayoutAttributeHeight + relatedBy:NSLayoutRelationEqual + toItem:activitiesView + attribute:NSLayoutAttributeHeight + multiplier:1.0f + constant:0.0f]; + + + [NSLayoutConstraint activateConstraints:@[topConstraint, leadingConstraint, widthConstraint, heightConstraint]]; + + // let the provide view to define a height. + // it could have no constrainst if there is no defined xib + _roomActivitiesContainerHeightConstraint.constant = activitiesView.height; + + // Listen to activities view change + activitiesView.delegate = self; + } + else + { + _roomActivitiesContainerHeightConstraint.constant = 0; + } + + _bubblesTableViewBottomConstraint.constant = _roomInputToolbarContainerBottomConstraint.constant + _roomInputToolbarContainerHeightConstraint.constant +_roomActivitiesContainerHeightConstraint.constant; + + [_roomActivitiesContainer setNeedsUpdateConstraints]; +} + +- (void)setAttachmentsViewerClass:(Class)theAttachmentsViewerClass +{ + if (theAttachmentsViewerClass) + { + // Sanity check: accept only MXKAttachmentsViewController classes or sub-classes + NSParameterAssert([theAttachmentsViewerClass isSubclassOfClass:MXKAttachmentsViewController.class]); + } + + attachmentsViewerClass = theAttachmentsViewerClass; +} + +- (void)setEventDetailsViewClass:(Class)eventDetailsViewClass +{ + if (eventDetailsViewClass) + { + // Sanity check: accept only MXKEventDetailsView classes or sub-classes + NSParameterAssert([eventDetailsViewClass isSubclassOfClass:MXKEventDetailsView.class]); + } + + customEventDetailsViewClass = eventDetailsViewClass; +} + +- (BOOL)isIRCStyleCommand:(NSString*)string +{ + // Check whether the provided text may be an IRC-style command + if ([string hasPrefix:@"/"] == NO || [string hasPrefix:@"//"] == YES) + { + return NO; + } + + // Parse command line + NSArray *components = [string componentsSeparatedByString:@" "]; + NSString *cmd = [components objectAtIndex:0]; + NSUInteger index = 1; + + // TODO: display an alert with the cmd usage in case of error or unrecognized cmd. + NSString *cmdUsage; + + if ([cmd isEqualToString:kMXKSlashCmdEmote]) + { + // send message as an emote + [self sendTextMessage:string]; + } + else if ([string hasPrefix:kMXKSlashCmdChangeDisplayName]) + { + // Change display name + NSString *displayName; + + // Sanity check + if (string.length > kMXKSlashCmdChangeDisplayName.length) + { + displayName = [string substringFromIndex:kMXKSlashCmdChangeDisplayName.length + 1]; + + // Remove white space from both ends + displayName = [displayName stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + } + + if (displayName.length) + { + [roomDataSource.mxSession.matrixRestClient setDisplayName:displayName success:^{ + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Set displayName failed"); + // Notify MatrixKit user + NSString *myUserId = self->roomDataSource.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + else + { + // Display cmd usage in text input as placeholder + cmdUsage = @"Usage: /nick "; + } + } + else if ([string hasPrefix:kMXKSlashCmdJoinRoom]) + { + // Join a room + NSString *roomAlias; + + // Sanity check + if (string.length > kMXKSlashCmdJoinRoom.length) + { + roomAlias = [string substringFromIndex:kMXKSlashCmdJoinRoom.length + 1]; + + // Remove white space from both ends + roomAlias = [roomAlias stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + } + + // Check + if (roomAlias.length) + { + // TODO: /join command does not support via parameters yet + [roomDataSource.mxSession joinRoom:roomAlias viaServers:nil success:^(MXRoom *room) { + // Do nothing by default when we succeed to join the room + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Join roomAlias (%@) failed", roomAlias); + // Notify MatrixKit user + NSString *myUserId = self->roomDataSource.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + else + { + // Display cmd usage in text input as placeholder + cmdUsage = @"Usage: /join "; + } + } + else if ([string hasPrefix:kMXKSlashCmdPartRoom]) + { + // Leave this room or another one + NSString *roomId; + NSString *roomIdOrAlias; + + // Sanity check + if (string.length > kMXKSlashCmdPartRoom.length) + { + roomIdOrAlias = [string substringFromIndex:kMXKSlashCmdPartRoom.length + 1]; + + // Remove white space from both ends + roomIdOrAlias = [roomIdOrAlias stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + } + + // Check + if (roomIdOrAlias.length) + { + // Leave another room + if ([MXTools isMatrixRoomAlias:roomIdOrAlias]) + { + // Convert the alias to a room ID + MXRoom *room = [roomDataSource.mxSession roomWithAlias:roomIdOrAlias]; + if (room) + { + roomId = room.roomId; + } + } + else if ([MXTools isMatrixRoomIdentifier:roomIdOrAlias]) + { + roomId = roomIdOrAlias; + } + } + else + { + // Leave the current room + roomId = roomDataSource.roomId; + } + + if (roomId.length) + { + [roomDataSource.mxSession leaveRoom:roomId success:^{ + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Part room_alias (%@ / %@) failed", roomIdOrAlias, roomId); + // Notify MatrixKit user + NSString *myUserId = self->roomDataSource.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + else + { + // Display cmd usage in text input as placeholder + cmdUsage = @"Usage: /part []"; + } + } + else if ([string hasPrefix:kMXKSlashCmdChangeRoomTopic]) + { + // Change topic + NSString *topic; + + // Sanity check + if (string.length > kMXKSlashCmdChangeRoomTopic.length) + { + topic = [string substringFromIndex:kMXKSlashCmdChangeRoomTopic.length + 1]; + // Remove white space from both ends + topic = [topic stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + } + + if (topic.length) + { + [roomDataSource.room setTopic:topic success:^{ + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Set topic failed"); + // Notify MatrixKit user + NSString *myUserId = self->roomDataSource.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + else + { + // Display cmd usage in text input as placeholder + cmdUsage = @"Usage: /topic "; + } + } + else + { + // Retrieve userId + NSString *userId = nil; + while (index < components.count) + { + userId = [components objectAtIndex:index++]; + if (userId.length) + { + // done + break; + } + // reset + userId = nil; + } + + if ([cmd isEqualToString:kMXKSlashCmdInviteUser]) + { + if (userId) + { + // Invite the user + [roomDataSource.room inviteUser:userId success:^{ + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Invite user (%@) failed", userId); + // Notify MatrixKit user + NSString *myUserId = self->roomDataSource.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + else + { + // Display cmd usage in text input as placeholder + cmdUsage = @"Usage: /invite "; + } + } + else if ([cmd isEqualToString:kMXKSlashCmdKickUser]) + { + if (userId) + { + // Retrieve potential reason + NSString *reason = nil; + while (index < components.count) + { + if (reason) + { + reason = [NSString stringWithFormat:@"%@ %@", reason, [components objectAtIndex:index++]]; + } + else + { + reason = [components objectAtIndex:index++]; + } + } + // Kick the user + [roomDataSource.room kickUser:userId reason:reason success:^{ + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Kick user (%@) failed", userId); + // Notify MatrixKit user + NSString *myUserId = self->roomDataSource.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + else + { + // Display cmd usage in text input as placeholder + cmdUsage = @"Usage: /kick []"; + } + } + else if ([cmd isEqualToString:kMXKSlashCmdBanUser]) + { + if (userId) + { + // Retrieve potential reason + NSString *reason = nil; + while (index < components.count) + { + if (reason) + { + reason = [NSString stringWithFormat:@"%@ %@", reason, [components objectAtIndex:index++]]; + } + else + { + reason = [components objectAtIndex:index++]; + } + } + // Ban the user + [roomDataSource.room banUser:userId reason:reason success:^{ + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Ban user (%@) failed", userId); + // Notify MatrixKit user + NSString *myUserId = self->roomDataSource.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + else + { + // Display cmd usage in text input as placeholder + cmdUsage = @"Usage: /ban []"; + } + } + else if ([cmd isEqualToString:kMXKSlashCmdUnbanUser]) + { + if (userId) + { + // Unban the user + [roomDataSource.room unbanUser:userId success:^{ + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Unban user (%@) failed", userId); + // Notify MatrixKit user + NSString *myUserId = self->roomDataSource.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + else + { + // Display cmd usage in text input as placeholder + cmdUsage = @"Usage: /unban "; + } + } + else if ([cmd isEqualToString:kMXKSlashCmdSetUserPowerLevel]) + { + // Retrieve power level + NSString *powerLevel = nil; + while (index < components.count) + { + powerLevel = [components objectAtIndex:index++]; + if (powerLevel.length) + { + // done + break; + } + // reset + powerLevel = nil; + } + // Set power level + if (userId && powerLevel) + { + // Set user power level + [roomDataSource.room setPowerLevelOfUserWithUserID:userId powerLevel:[powerLevel integerValue] success:^{ + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Set user power (%@) failed", userId); + // Notify MatrixKit user + NSString *myUserId = self->roomDataSource.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + else + { + // Display cmd usage in text input as placeholder + cmdUsage = @"Usage: /op "; + } + } + else if ([cmd isEqualToString:kMXKSlashCmdResetUserPowerLevel]) + { + if (userId) + { + // Reset user power level + [roomDataSource.room setPowerLevelOfUserWithUserID:userId powerLevel:0 success:^{ + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Reset user power (%@) failed", userId); + // Notify MatrixKit user + NSString *myUserId = self->roomDataSource.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + else + { + // Display cmd usage in text input as placeholder + cmdUsage = @"Usage: /deop "; + } + } + else + { + MXLogDebug(@"[MXKRoomVC] Unrecognised IRC-style command: %@", string); +// cmdUsage = [NSString stringWithFormat:@"Unrecognised IRC-style command: %@", cmd]; + return NO; + } + } + return YES; +} + +- (void)mention:(MXRoomMember*)roomMember +{ + NSString *memberName = roomMember.displayname.length ? roomMember.displayname : roomMember.userId; + + if (inputToolbarView.textMessage.length) + { + [inputToolbarView pasteText:memberName]; + } + else if ([roomMember.userId isEqualToString:self.mainSession.myUser.userId]) + { + // Prepare emote + inputToolbarView.textMessage = @"/me "; + } + else + { + // Bing the member + inputToolbarView.textMessage = [NSString stringWithFormat:@"%@: ", memberName]; + } + + [inputToolbarView becomeFirstResponder]; +} + +- (void)dismissKeyboard +{ + [titleView dismissKeyboard]; + [inputToolbarView dismissKeyboard]; +} + +- (BOOL)isBubblesTableScrollViewAtTheBottom +{ + if (_bubblesTableView.contentSize.height) + { + // Check whether the most recent message is visible. + // Compute the max vertical position visible according to contentOffset + CGFloat maxPositionY = _bubblesTableView.contentOffset.y + (_bubblesTableView.frame.size.height - _bubblesTableView.adjustedContentInset.bottom); + // Be a bit less retrictive, consider the table view at the bottom even if the most recent message is partially hidden + maxPositionY += 44; + BOOL isScrolledToBottom = (maxPositionY >= _bubblesTableView.contentSize.height); + + // Consider the table view at the bottom if a scrolling to bottom is in progress too + return (isScrolledToBottom || isScrollingToBottom); + } + + // Consider empty table view as at the bottom. Only do this after it has appeared. + // Returning YES here before the view has appeared allows calls to scrollBubblesTableViewToBottomAnimated + // before the view knows its final size, resulting in a position offset the second time a room is shown (#4524). + return hasAppearedOnce; +} + +- (void)scrollBubblesTableViewToBottomAnimated:(BOOL)animated +{ + if (_bubblesTableView.contentSize.height) + { + CGFloat visibleHeight = _bubblesTableView.frame.size.height - _bubblesTableView.adjustedContentInset.top - _bubblesTableView.adjustedContentInset.bottom; + if (visibleHeight < _bubblesTableView.contentSize.height) + { + CGFloat wantedOffsetY = _bubblesTableView.contentSize.height - visibleHeight - _bubblesTableView.adjustedContentInset.top; + CGFloat currentOffsetY = _bubblesTableView.contentOffset.y; + if (wantedOffsetY != currentOffsetY) + { + isScrollingToBottom = YES; + BOOL savedBubbleTableViewDisplayInTransition = self.isBubbleTableViewDisplayInTransition; + self.bubbleTableViewDisplayInTransition = YES; + [self setBubbleTableViewContentOffset:CGPointMake(0, wantedOffsetY) animated:animated]; + self.bubbleTableViewDisplayInTransition = savedBubbleTableViewDisplayInTransition; + } + else + { + // upateCurrentEventIdAtTableBottom must be called here (it is usually called by the scrollview delegate at the end of scrolling). + [self updateCurrentEventIdAtTableBottom:YES]; + } + } + else + { + [self setBubbleTableViewContentOffset:CGPointMake(0, -_bubblesTableView.adjustedContentInset.top) animated:animated]; + } + + shouldScrollToBottomOnTableRefresh = NO; + } +} + +- (void)dismissTemporarySubViews +{ + [self dismissKeyboard]; + + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + currentAlert = nil; + } + + if (eventDetailsView) + { + [eventDetailsView removeFromSuperview]; + eventDetailsView = nil; + } + + if (_leftRoomReasonLabel) + { + [_leftRoomReasonLabel removeFromSuperview]; + _leftRoomReasonLabel = nil; + _bubblesTableView.tableHeaderView = nil; + } + + // Dispose potential keyboard view + self.keyboardView = nil; +} + +- (void)setBubbleTableViewContentOffset:(CGPoint)contentOffset animated:(BOOL)animated +{ + if (preventBubblesTableViewScroll) + { + return; + } + + [self.bubblesTableView setContentOffset:contentOffset animated:animated]; +} + +#pragma mark - properties + +- (void)setBubbleTableViewDisplayInTransition:(BOOL)bubbleTableViewDisplayInTransition +{ + if (_bubbleTableViewDisplayInTransition != bubbleTableViewDisplayInTransition) + { + _bubbleTableViewDisplayInTransition = bubbleTableViewDisplayInTransition; + + [self updateCurrentEventIdAtTableBottom:YES]; + } +} + +- (void)setUpdateRoomReadMarker:(BOOL)updateRoomReadMarker +{ + if (_updateRoomReadMarker != updateRoomReadMarker) + { + _updateRoomReadMarker = updateRoomReadMarker; + + if (updateRoomReadMarker == YES) + { + if (currentEventIdAtTableBottom) + { + [self.roomDataSource.room moveReadMarkerToEventId:currentEventIdAtTableBottom]; + } + else + { + // Look for the last displayed event. + [self updateCurrentEventIdAtTableBottom:YES]; + } + } + } +} + +#pragma mark - activity indicator + +- (void)stopActivityIndicator +{ + // Keep the loading wheel displayed while we are joining the room + if (joinRoomRequest) + { + return; + } + + // Check internal processes before stopping the loading wheel + if (isPaginationInProgress || isInputToolbarProcessing) + { + // Keep activity indicator running + return; + } + + // Leave super decide + [super stopActivityIndicator]; +} + +#pragma mark - Pagination + +- (void)triggerInitialBackPagination +{ + // Trigger back pagination to fill all the screen + CGRect frame = [[UIScreen mainScreen] bounds]; + + MXWeakify(self); + + isPaginationInProgress = YES; + [self startActivityIndicator]; + [roomDataSource paginateToFillRect:frame + direction:MXTimelineDirectionBackwards + withMinRequestMessagesCount:_paginationLimit + success:^{ + + MXStrongifyAndReturnIfNil(self); + + // Stop spinner + self->isPaginationInProgress = NO; + [self stopActivityIndicator]; + + self.bubbleTableViewDisplayInTransition = YES; + + // Reload table + [self reloadBubblesTable:YES]; + + if (self->roomDataSource.timeline.initialEventId) + { + // Center the table view to the cell that contains this event + NSInteger index = [self->roomDataSource indexOfCellDataWithEventId:self->roomDataSource.timeline.initialEventId]; + if (index != NSNotFound) + { + // Let iOS put the cell at the top of the table view + [self.bubblesTableView scrollToRowAtIndexPath: [NSIndexPath indexPathForRow:index inSection:0] atScrollPosition:UITableViewScrollPositionTop animated:NO]; + + // Apply an offset to move the targeted component at the center of the screen. + UITableViewCell *cell = [self->_bubblesTableView cellForRowAtIndexPath:[NSIndexPath indexPathForRow:index inSection:0]]; + + CGPoint contentOffset = self->_bubblesTableView.contentOffset; + CGFloat firstVisibleContentRowOffset = self->_bubblesTableView.contentOffset.y + self->_bubblesTableView.adjustedContentInset.top; + CGFloat lastVisibleContentRowOffset = self->_bubblesTableView.frame.size.height - self->_bubblesTableView.adjustedContentInset.bottom; + + CGFloat localPositionOfEvent = 0.0; + + if ([cell isKindOfClass:MXKRoomBubbleTableViewCell.class]) + { + MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; + + if (self->_centerBubblesTableViewContentOnTheInitialEventBottom) + { + localPositionOfEvent = [roomBubbleTableViewCell bottomPositionOfEvent:self->roomDataSource.timeline.initialEventId]; + } + else + { + localPositionOfEvent = [roomBubbleTableViewCell topPositionOfEvent:self->roomDataSource.timeline.initialEventId]; + } + } + + contentOffset.y += localPositionOfEvent - (lastVisibleContentRowOffset / 2 - (cell.frame.origin.y - firstVisibleContentRowOffset)); + + // Sanity check + if (contentOffset.y + lastVisibleContentRowOffset > self->_bubblesTableView.contentSize.height) + { + contentOffset.y = self->_bubblesTableView.contentSize.height - lastVisibleContentRowOffset; + } + + [self setBubbleTableViewContentOffset:contentOffset animated:NO]; + + + // Update the read receipt and potentially the read marker. + [self updateCurrentEventIdAtTableBottom:YES]; + } + } + + self.bubbleTableViewDisplayInTransition = NO; + } + failure:^(NSError *error) { + + MXStrongifyAndReturnIfNil(self); + + // Stop spinner + self->isPaginationInProgress = NO; + [self stopActivityIndicator]; + + self.bubbleTableViewDisplayInTransition = YES; + + // Reload table + [self reloadBubblesTable:YES]; + + self.bubbleTableViewDisplayInTransition = NO; + + }]; +} + +/** + Trigger an inconspicuous pagination. + The retrieved history is added discretely to the top or the bottom of bubbles table without change the current display. + + @param limit the maximum number of messages to retrieve. + @param direction backwards or forwards. + */ +- (void)triggerPagination:(NSUInteger)limit direction:(MXTimelineDirection)direction +{ + // Paginate only if possible + if (isPaginationInProgress || roomDataSource.state != MXKDataSourceStateReady || NO == [roomDataSource.timeline canPaginate:direction]) + { + return; + } + + // Store the current height of the first bubble (if any) + backPaginationSavedFirstBubbleHeight = 0; + if (direction == MXTimelineDirectionBackwards && [roomDataSource tableView:_bubblesTableView numberOfRowsInSection:0]) + { + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0]; + backPaginationSavedFirstBubbleHeight = [self tableView:_bubblesTableView heightForRowAtIndexPath:indexPath]; + } + + isPaginationInProgress = YES; + + MXWeakify(self); + + // Trigger pagination + [roomDataSource paginate:limit direction:direction onlyFromStore:NO success:^(NSUInteger addedCellNumber) { + + MXStrongifyAndReturnIfNil(self); + + // We will adjust the vertical offset in order to unchange the current display (pagination should be inconspicuous) + CGFloat verticalOffset = 0; + + if (direction == MXTimelineDirectionBackwards) + { + // Compute the cumulative height of the added messages + for (NSUInteger index = 0; index < addedCellNumber; index++) + { + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:index inSection:0]; + verticalOffset += [self tableView:self->_bubblesTableView heightForRowAtIndexPath:indexPath]; + } + + // Add delta of the height of the previous first cell (if any) + if (addedCellNumber < [self->roomDataSource tableView:self->_bubblesTableView numberOfRowsInSection:0]) + { + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:addedCellNumber inSection:0]; + verticalOffset += ([self tableView:self->_bubblesTableView heightForRowAtIndexPath:indexPath] - self->backPaginationSavedFirstBubbleHeight); + } + + self->_bubblesTableView.tableHeaderView = self->backPaginationActivityView = nil; + } + else + { + self->_bubblesTableView.tableFooterView = self->reconnectingView = nil; + } + + // Trigger a full table reload. We could not only insert new cells related to pagination, + // because some other changes may have been ignored during pagination (see[dataSource:didCellChange:]). + self.bubbleTableViewDisplayInTransition = YES; + + // Disable temporarily scrolling and hide the scroll indicator during refresh to prevent flickering + [self.bubblesTableView setShowsVerticalScrollIndicator:NO]; + [self.bubblesTableView setScrollEnabled:NO]; + + CGPoint contentOffset = self.bubblesTableView.contentOffset; + + BOOL hasBeenScrolledToBottom = [self reloadBubblesTable:NO]; + + if (direction == MXTimelineDirectionBackwards) + { + // Backwards pagination adds cells at the top of the tableview content. + // Vertical content offset needs to be updated (except if the table has been scrolled to bottom) + if ((!hasBeenScrolledToBottom && verticalOffset > 0) || direction == MXTimelineDirectionForwards) + { + // Adjust vertical offset in order to compensate scrolling + contentOffset.y += verticalOffset; + [self setBubbleTableViewContentOffset:contentOffset animated:NO]; + } + } + else + { + [self setBubbleTableViewContentOffset:contentOffset animated:NO]; + } + + // Restore scrolling and the scroll indicator + [self.bubblesTableView setShowsVerticalScrollIndicator:YES]; + [self.bubblesTableView setScrollEnabled:YES]; + + self.bubbleTableViewDisplayInTransition = NO; + self->isPaginationInProgress = NO; + + // Force the update of the current visual position + // Else there is a scroll jump on incoming message (see https://github.com/vector-im/vector-ios/issues/79) + if (direction == MXTimelineDirectionBackwards) + { + [self updateCurrentEventIdAtTableBottom:NO]; + } + + } failure:^(NSError *error) { + + MXStrongifyAndReturnIfNil(self); + + self.bubbleTableViewDisplayInTransition = YES; + + // Reload table on failure because some changes may have been ignored during pagination (see[dataSource:didCellChange:]) + self->isPaginationInProgress = NO; + self->_bubblesTableView.tableHeaderView = self->backPaginationActivityView = nil; + + [self reloadBubblesTable:NO]; + + self.bubbleTableViewDisplayInTransition = NO; + + }]; +} + +- (void)triggerAttachmentBackPagination:(NSString*)eventId +{ + // Paginate only if possible + if (NO == [roomDataSource.timeline canPaginate:MXTimelineDirectionBackwards] && self.attachmentsViewer) + { + return; + } + + isPaginationInProgress = YES; + + MXWeakify(self); + + // Trigger back pagination to find previous attachments + [roomDataSource paginate:_paginationLimit direction:MXTimelineDirectionBackwards onlyFromStore:NO success:^(NSUInteger addedCellNumber) { + + MXStrongifyAndReturnIfNil(self); + + // Check whether attachments viewer is still visible + if (self.attachmentsViewer) + { + // Check whether some older attachments have been added. + // Note: the stickers are excluded from the attachments list returned by the room datasource. + BOOL isDone = NO; + NSArray *attachmentsWithThumbnail = self.roomDataSource.attachmentsWithThumbnail; + if (attachmentsWithThumbnail.count) + { + MXKAttachment *attachment = attachmentsWithThumbnail.firstObject; + isDone = ![attachment.eventId isEqualToString:eventId]; + } + + // Check whether pagination is still available + self.attachmentsViewer.complete = ([self->roomDataSource.timeline canPaginate:MXTimelineDirectionBackwards] == NO); + + if (isDone || self.attachmentsViewer.complete) + { + // Refresh the current attachments list. + [self.attachmentsViewer displayAttachments:attachmentsWithThumbnail focusOn:nil]; + + // Trigger a full table reload without scrolling. We could not only insert new cells related to back pagination, + // because some other changes may have been ignored during back pagination (see[dataSource:didCellChange:]). + self.bubbleTableViewDisplayInTransition = YES; + self->isPaginationInProgress = NO; + [self reloadBubblesTable:YES]; + self.bubbleTableViewDisplayInTransition = NO; + + // Done + return; + } + + // Here a new back pagination is required + [self triggerAttachmentBackPagination:eventId]; + } + else + { + // Trigger a full table reload without scrolling. We could not only insert new cells related to back pagination, + // because some other changes may have been ignored during back pagination (see[dataSource:didCellChange:]). + self.bubbleTableViewDisplayInTransition = YES; + self->isPaginationInProgress = NO; + [self reloadBubblesTable:YES]; + self.bubbleTableViewDisplayInTransition = NO; + } + + } failure:^(NSError *error) { + + MXStrongifyAndReturnIfNil(self); + + // Reload table on failure because some changes may have been ignored during back pagination (see[dataSource:didCellChange:]) + self.bubbleTableViewDisplayInTransition = YES; + self->isPaginationInProgress = NO; + [self reloadBubblesTable:YES]; + self.bubbleTableViewDisplayInTransition = NO; + + if (self.attachmentsViewer) + { + // Force attachments update to cancel potential loading wheel + [self.attachmentsViewer displayAttachments:self.attachmentsViewer.attachments focusOn:nil]; + } + + }]; +} + +#pragma mark - Post messages + +- (void)sendTextMessage:(NSString*)msgTxt +{ + // Let the datasource send it and manage the local echo + [roomDataSource sendTextMessage:msgTxt success:nil failure:^(NSError *error) + { + // Just log the error. The message will be displayed in red in the room history + MXLogDebug(@"[MXKRoomViewController] sendTextMessage failed."); + }]; +} + +# pragma mark - Event handling + +- (void)showEventDetails:(MXEvent *)event +{ + [self dismissKeyboard]; + + // Remove potential existing subviews + [self dismissTemporarySubViews]; + + MXKEventDetailsView *eventDetailsView; + + if (customEventDetailsViewClass) + { + eventDetailsView = [[customEventDetailsViewClass alloc] initWithEvent:event andMatrixSession:roomDataSource.mxSession]; + } + else + { + eventDetailsView = [[MXKEventDetailsView alloc] initWithEvent:event andMatrixSession:roomDataSource.mxSession]; + } + + // Add shadow on event details view + eventDetailsView.layer.cornerRadius = 5; + eventDetailsView.layer.shadowOffset = CGSizeMake(0, 1); + eventDetailsView.layer.shadowOpacity = 0.5f; + + // Add the view and define edge constraints + [self.view addSubview:eventDetailsView]; + + self->eventDetailsView = eventDetailsView; + + [self.view addConstraint:[NSLayoutConstraint constraintWithItem:eventDetailsView + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self.topLayoutGuide + attribute:NSLayoutAttributeBottom + multiplier:1.0f + constant:10.0f]]; + + [self.view addConstraint:[NSLayoutConstraint constraintWithItem:eventDetailsView + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:self.bottomLayoutGuide + attribute:NSLayoutAttributeTop + multiplier:1.0f + constant:-10.0f]]; + + [self.view addConstraint:[NSLayoutConstraint constraintWithItem:self.view + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:eventDetailsView + attribute:NSLayoutAttributeLeading + multiplier:1.0f + constant:-10.0f]]; + + [self.view addConstraint:[NSLayoutConstraint constraintWithItem:self.view + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:eventDetailsView + attribute:NSLayoutAttributeTrailing + multiplier:1.0f + constant:10.0f]]; + [self.view setNeedsUpdateConstraints]; +} + +- (void)promptUserToResendEvent:(NSString *)eventId +{ + MXEvent *event = [roomDataSource eventWithEventId:eventId]; + + MXLogDebug(@"[MXKRoomViewController] promptUserToResendEvent: %@", event); + + if (event && event.eventType == MXEventTypeRoomMessage) + { + NSString *msgtype = event.content[@"msgtype"]; + + NSString* textMessage; + if ([msgtype isEqualToString:kMXMessageTypeText]) + { + textMessage = event.content[@"body"]; + } + + // Show a confirmation popup to the end user + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + } + + __weak typeof(self) weakSelf = self; + + UIAlertController *resendAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n resendMessage] + message:textMessage + preferredStyle:UIAlertControllerStyleAlert]; + + [resendAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + }]]; + + [resendAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Let the datasource resend. It will manage local echo, etc. + [self->roomDataSource resendEventWithEventId:eventId success:nil failure:nil]; + + }]]; + + [self presentViewController:resendAlert animated:YES completion:nil]; + currentAlert = resendAlert; + } +} + +#pragma mark - bubbles table + +- (BOOL)reloadBubblesTable:(BOOL)useBottomAnchor +{ + return [self reloadBubblesTable:useBottomAnchor invalidateBubblesCellDataCache:NO]; +} + +- (BOOL)reloadBubblesTable:(BOOL)useBottomAnchor invalidateBubblesCellDataCache:(BOOL)invalidateBubblesCellDataCache +{ + BOOL shouldScrollToBottom = shouldScrollToBottomOnTableRefresh; + + // When no size transition is in progress, check if the bottom of the content is currently visible. + // If this is the case, we will scroll automatically to the bottom after table refresh. + if (!isSizeTransitionInProgress && !shouldScrollToBottom) + { + shouldScrollToBottom = [self isBubblesTableScrollViewAtTheBottom]; + } + + // Force bubblesCellData message recalculation if requested + if (invalidateBubblesCellDataCache) + { + [self.roomDataSource invalidateBubblesCellDataCache]; + } + + // When scroll to bottom is not active, check whether we should keep the current event displayed at the bottom of the table + if (!shouldScrollToBottom && useBottomAnchor && currentEventIdAtTableBottom) + { + // Update content offset after refresh in order to keep visible the current event displayed at the bottom + + [_bubblesTableView reloadData]; + + // Retrieve the new cell index of the event displayed previously at the bottom of table + NSInteger rowIndex = [roomDataSource indexOfCellDataWithEventId:currentEventIdAtTableBottom]; + if (rowIndex != NSNotFound) + { + // Retrieve the corresponding cell + UITableViewCell *cell = [_bubblesTableView cellForRowAtIndexPath:[NSIndexPath indexPathForRow:rowIndex inSection:0]]; + UITableViewCell *cellTmp; + if (!cell) + { + NSString *reuseIdentifier = [self cellReuseIdentifierForCellData:[roomDataSource cellDataAtIndex:rowIndex]]; + // Create temporarily the cell (this cell will released at the end, to be reusable) + // Do not pass in the indexPath when creating this cell, as there is a possible crash by dequeuing + // multiple cells for the same index path if rotating the device coincides with reloading the data. + cellTmp = [_bubblesTableView dequeueReusableCellWithIdentifier:reuseIdentifier]; + cell = cellTmp; + } + + if (cell) + { + CGFloat eventTopPosition = cell.frame.origin.y; + CGFloat eventBottomPosition = eventTopPosition + cell.frame.size.height; + + // Compute accurate event positions in case of bubble with multiple components + if ([cell isKindOfClass:MXKRoomBubbleTableViewCell.class]) + { + MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; + NSArray *bubbleComponents = roomBubbleTableViewCell.bubbleData.bubbleComponents; + + if (bubbleComponents.count > 1) + { + // Check and update each component position + [roomBubbleTableViewCell.bubbleData prepareBubbleComponentsPosition]; + + NSInteger index = bubbleComponents.count - 1; + MXKRoomBubbleComponent *component = bubbleComponents[index]; + + if ([component.event.eventId isEqualToString:currentEventIdAtTableBottom]) + { + eventTopPosition += roomBubbleTableViewCell.msgTextViewTopConstraint.constant + component.position.y; + } + else + { + while (index--) + { + MXKRoomBubbleComponent *previousComponent = bubbleComponents[index]; + if ([previousComponent.event.eventId isEqualToString:currentEventIdAtTableBottom]) + { + // Update top position if this is not the first component + if (index) + { + eventTopPosition += roomBubbleTableViewCell.msgTextViewTopConstraint.constant + previousComponent.position.y; + } + + eventBottomPosition = cell.frame.origin.y + roomBubbleTableViewCell.msgTextViewTopConstraint.constant + component.position.y; + break; + } + + component = previousComponent; + } + } + } + } + + // Compute the offset of the content displayed at the bottom. + CGFloat contentBottomOffsetY = _bubblesTableView.contentOffset.y + (_bubblesTableView.frame.size.height - _bubblesTableView.adjustedContentInset.bottom); + if (contentBottomOffsetY > _bubblesTableView.contentSize.height) + { + contentBottomOffsetY = _bubblesTableView.contentSize.height; + } + + // Check whether this event is no more displayed at the bottom + if ((contentBottomOffsetY <= eventTopPosition ) || (eventBottomPosition < contentBottomOffsetY)) + { + // Compute the top content offset to display again this event at the table bottom + CGFloat contentOffsetY = eventBottomPosition - (_bubblesTableView.frame.size.height - _bubblesTableView.adjustedContentInset.bottom); + + // Check if there are enought data to fill the top + if (contentOffsetY < -_bubblesTableView.adjustedContentInset.top) + { + // Scroll to the top + contentOffsetY = -_bubblesTableView.adjustedContentInset.top; + } + + CGPoint contentOffset = _bubblesTableView.contentOffset; + contentOffset.y = contentOffsetY; + [self setBubbleTableViewContentOffset:contentOffset animated:NO]; + } + + if (cellTmp && [cellTmp conformsToProtocol:@protocol(MXKCellRendering)] && [cellTmp respondsToSelector:@selector(didEndDisplay)]) + { + // Release here resources, and restore reusable cells + [(id)cellTmp didEndDisplay]; + } + } + } + } + else + { + // Do a full reload + [_bubblesTableView reloadData]; + } + + if (shouldScrollToBottom) + { + [self scrollBubblesTableViewToBottomAnimated:NO]; + } + + return shouldScrollToBottom; +} + +- (void)updateCurrentEventIdAtTableBottom:(BOOL)acknowledge +{ + // Update the identifier of the event displayed at the bottom of the table, except if a rotation or other size transition is in progress. + if (!isSizeTransitionInProgress && !self.isBubbleTableViewDisplayInTransition) + { + // Compute the content offset corresponding to the line displayed at the table bottom (just above the toolbar). + CGFloat contentBottomOffsetY = _bubblesTableView.contentOffset.y + (_bubblesTableView.frame.size.height - _bubblesTableView.adjustedContentInset.bottom); + if (contentBottomOffsetY > _bubblesTableView.contentSize.height) + { + contentBottomOffsetY = _bubblesTableView.contentSize.height; + } + // Be a bit less retrictive, consider visible an event at the bottom even if is partially hidden. + contentBottomOffsetY += 8; + + // Reset the current event id + currentEventIdAtTableBottom = nil; + + // Consider the visible cells (starting by those displayed at the bottom) + NSArray *visibleCells = [_bubblesTableView visibleCells]; + NSInteger index = visibleCells.count; + UITableViewCell *cell; + while (index--) + { + cell = visibleCells[index]; + + // Check whether the cell is actually visible + if (cell && (cell.frame.origin.y < contentBottomOffsetY)) + { + if (![cell isKindOfClass:MXKTableViewCell.class]) + { + continue; + } + + MXKCellData *cellData = ((MXKTableViewCell *)cell).mxkCellData; + + // Only 'MXKRoomBubbleCellData' is supported here for the moment. + if (![cellData isKindOfClass:MXKRoomBubbleCellData.class]) + { + continue; + } + + MXKRoomBubbleCellData *bubbleData = (MXKRoomBubbleCellData*)cellData; + + // Check which bubble component is displayed at the bottom. + // For that update each component position. + [bubbleData prepareBubbleComponentsPosition]; + + NSArray *bubbleComponents = bubbleData.bubbleComponents; + NSInteger componentIndex = bubbleComponents.count; + + CGFloat bottomPositionY = cell.frame.size.height; + + MXKRoomBubbleComponent *component; + + while (componentIndex --) + { + component = bubbleComponents[componentIndex]; + if (![cell isKindOfClass:MXKRoomBubbleTableViewCell.class]) + { + continue; + } + + MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; + + // Check whether the bottom part of the component is visible. + CGFloat pos = cell.frame.origin.y + bottomPositionY; + if (pos <= contentBottomOffsetY) + { + // We found the component + currentEventIdAtTableBottom = component.event.eventId; + break; + } + + // Prepare the bottom position for the next component + bottomPositionY = roomBubbleTableViewCell.msgTextViewTopConstraint.constant + component.position.y; + } + + if (currentEventIdAtTableBottom) + { + if (acknowledge && self.isEventsAcknowledgementEnabled) + { + // Indicate to the homeserver that the user has read this event. + + // Check whether the read marker must be updated. + BOOL updateReadMarker = _updateRoomReadMarker; + if (updateReadMarker && roomDataSource.room.accountData.readMarkerEventId) + { + MXEvent *currentReadMarkerEvent = [roomDataSource.mxSession.store eventWithEventId:roomDataSource.room.accountData.readMarkerEventId inRoom:roomDataSource.roomId]; + if (!currentReadMarkerEvent) + { + currentReadMarkerEvent = [roomDataSource eventWithEventId:roomDataSource.room.accountData.readMarkerEventId]; + } + + // Update the read marker only if the current event is available, and the new event is posterior to it. + updateReadMarker = (currentReadMarkerEvent && (currentReadMarkerEvent.originServerTs <= component.event.originServerTs)); + } + + [roomDataSource.room acknowledgeEvent:component.event andUpdateReadMarker:updateReadMarker]; + } + break; + } + // else we consider the previous cell. + } + } + } +} + +#pragma mark - MXKDataSourceDelegate + +- (Class)cellViewClassForCellData:(MXKCellData*)cellData +{ + Class cellViewClass = nil; + + // Sanity check + if ([cellData conformsToProtocol:@protocol(MXKRoomBubbleCellDataStoring)]) + { + id bubbleData = (id)cellData; + + // Select the suitable table view cell class + if (bubbleData.isIncoming) + { + if (bubbleData.isAttachmentWithThumbnail) + { + if (bubbleData.shouldHideSenderInformation) + { + cellViewClass = MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.class; + } + else + { + cellViewClass = MXKRoomIncomingAttachmentBubbleCell.class; + } + } + else + { + if (bubbleData.shouldHideSenderInformation) + { + cellViewClass = MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.class; + } + else + { + cellViewClass = MXKRoomIncomingTextMsgBubbleCell.class; + } + } + } + else if (bubbleData.isAttachmentWithThumbnail) + { + if (bubbleData.shouldHideSenderInformation) + { + cellViewClass = MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.class; + } + else + { + cellViewClass = MXKRoomOutgoingAttachmentBubbleCell.class; + } + } + else + { + if (bubbleData.shouldHideSenderInformation) + { + cellViewClass = MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.class; + } + else + { + cellViewClass = MXKRoomOutgoingTextMsgBubbleCell.class; + } + } + } + + return cellViewClass; +} + +- (NSString *)cellReuseIdentifierForCellData:(MXKCellData*)cellData +{ + Class class = [self cellViewClassForCellData:cellData]; + + if ([class respondsToSelector:@selector(defaultReuseIdentifier)]) + { + return [class defaultReuseIdentifier]; + } + + return nil; +} + +- (void)dataSource:(MXKDataSource *)dataSource didCellChange:(id)changes +{ + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication && sharedApplication.applicationState != UIApplicationStateActive) + { + // Do nothing at the UI level if the application do a sync in background + return; + } + + if (isPaginationInProgress) + { + // Ignore these changes, the table will be full updated at the end of pagination. + return; + } + + if (self.attachmentsViewer) + { + // Refresh the current attachments list without changing the current displayed attachment (see focus = nil). + NSArray *attachmentsWithThumbnail = self.roomDataSource.attachmentsWithThumbnail; + [self.attachmentsViewer displayAttachments:attachmentsWithThumbnail focusOn:nil]; + } + + self.bubbleTableViewDisplayInTransition = YES; + + CGPoint contentOffset = self.bubblesTableView.contentOffset; + + BOOL hasScrolledToTheBottom = [self reloadBubblesTable:YES]; + + // If the user is scrolling while we reload the data for a new incoming message for example, + // there will be a jump in the table view display. + // Resetting the contentOffset after the reload fixes the issue. + if (hasScrolledToTheBottom == NO) + { + [self setBubbleTableViewContentOffset:contentOffset animated:NO]; + } + + self.bubbleTableViewDisplayInTransition = NO; +} + +- (void)dataSource:(MXKDataSource *)dataSource didStateChange:(MXKDataSourceState)state +{ + [self updateViewControllerAppearanceOnRoomDataSourceState]; + + if (state == MXKDataSourceStateReady) + { + [self onRoomDataSourceReady]; + } +} + +- (void)dataSource:(MXKDataSource *)dataSource didRecognizeAction:(NSString *)actionIdentifier inCell:(id)cell userInfo:(NSDictionary *)userInfo +{ + MXLogDebug(@"Gesture %@ has been recognized in %@. UserInfo: %@", actionIdentifier, cell, userInfo); + + if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnMessageTextView]) + { + MXLogDebug(@" -> A message has been tapped"); + } + else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnSenderNameLabel] || [actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnAvatarView]) + { +// MXLogDebug(@" -> Name or avatar of %@ has been tapped", userInfo[kMXKRoomBubbleCellUserIdKey]); + + // Add the member display name in text input + MXRoomMember *selectedRoomMember = [roomDataSource.roomState.members memberWithUserId:userInfo[kMXKRoomBubbleCellUserIdKey]]; + if (selectedRoomMember) + { + [self mention:selectedRoomMember]; + } + } + else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnDateTimeContainer]) + { + roomDataSource.showBubblesDateTime = !roomDataSource.showBubblesDateTime; + MXLogDebug(@" -> Turn %@ cells date", roomDataSource.showBubblesDateTime ? @"ON" : @"OFF"); + } + else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnAttachmentView] && [cell isKindOfClass:MXKRoomBubbleTableViewCell.class]) + { + [self showAttachmentInCell:(MXKRoomBubbleTableViewCell *)cell]; + } + else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellLongPressOnProgressView] && [cell isKindOfClass:MXKRoomBubbleTableViewCell.class]) + { + MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; + + // Check if there is a download in progress, then offer to cancel it + NSString *downloadId = roomBubbleTableViewCell.bubbleData.attachment.downloadId; + if ([MXMediaManager existingDownloaderWithIdentifier:downloadId]) + { + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + } + + __weak __typeof(self) weakSelf = self; + UIAlertController *cancelAlert = [UIAlertController alertControllerWithTitle:nil + message:[MatrixKitL10n attachmentCancelDownload] + preferredStyle:UIAlertControllerStyleAlert]; + + [cancelAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n no] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + }]]; + + [cancelAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n yes] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Get again the loader + MXMediaLoader *loader = [MXMediaManager existingDownloaderWithIdentifier:downloadId]; + if (loader) + { + [loader cancel]; + } + + // Hide the progress animation + roomBubbleTableViewCell.progressView.hidden = YES; + + }]]; + + [self presentViewController:cancelAlert animated:YES completion:nil]; + currentAlert = cancelAlert; + } + else if (roomBubbleTableViewCell.bubbleData.attachment.eventSentState == MXEventSentStatePreparing || + roomBubbleTableViewCell.bubbleData.attachment.eventSentState == MXEventSentStateEncrypting || + roomBubbleTableViewCell.bubbleData.attachment.eventSentState == MXEventSentStateUploading) + { + // Offer to cancel the upload in progress + // Upload id is stored in attachment url (nasty trick) + NSString *uploadId = roomBubbleTableViewCell.bubbleData.attachment.contentURL; + if ([MXMediaManager existingUploaderWithId:uploadId]) + { + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + } + + __weak __typeof(self) weakSelf = self; + UIAlertController *cancelAlert = [UIAlertController alertControllerWithTitle:nil + message:[MatrixKitL10n attachmentCancelUpload] + preferredStyle:UIAlertControllerStyleAlert]; + + [cancelAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n no] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + }]]; + + [cancelAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n yes] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + // TODO cancel the attachment encryption if it is in progress. + + // Get again the loader + MXMediaLoader *loader = [MXMediaManager existingUploaderWithId:uploadId]; + if (loader) + { + [loader cancel]; + } + + // Hide the progress animation + roomBubbleTableViewCell.progressView.hidden = YES; + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Remove the outgoing message and its related cached file. + [[NSFileManager defaultManager] removeItemAtPath:roomBubbleTableViewCell.bubbleData.attachment.cacheFilePath error:nil]; + [[NSFileManager defaultManager] removeItemAtPath:roomBubbleTableViewCell.bubbleData.attachment.thumbnailCachePath error:nil]; + [self.roomDataSource removeEventWithEventId:roomBubbleTableViewCell.bubbleData.attachment.eventId]; + } + + }]]; + + [self presentViewController:cancelAlert animated:YES completion:nil]; + currentAlert = cancelAlert; + } + } + } + else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellLongPressOnEvent] && [cell isKindOfClass:MXKRoomBubbleTableViewCell.class]) + { + [self dismissKeyboard]; + + MXEvent *selectedEvent = userInfo[kMXKRoomBubbleCellEventKey]; + MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; + MXKAttachment *attachment = roomBubbleTableViewCell.bubbleData.attachment; + + if (selectedEvent) + { + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + currentAlert = nil; + + // Cancel potential text selection in other bubbles + for (MXKRoomBubbleTableViewCell *bubble in self.bubblesTableView.visibleCells) + { + [bubble highlightTextMessageForEvent:nil]; + } + } + + __weak __typeof(self) weakSelf = self; + UIAlertController *actionSheet = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + + // Add actions for a failed event + if (selectedEvent.sentState == MXEventSentStateFailed) + { + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n resend] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Let the datasource resend. It will manage local echo, etc. + [self.roomDataSource resendEventWithEventId:selectedEvent.eventId success:nil failure:nil]; + + }]]; + + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n delete] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + [self.roomDataSource removeEventWithEventId:selectedEvent.eventId]; + + }]]; + } + + // Add actions for text message + if (!attachment) + { + // Highlight the select event + [roomBubbleTableViewCell highlightTextMessageForEvent:selectedEvent.eventId]; + + // Retrieved data related to the selected event + NSArray *components = roomBubbleTableViewCell.bubbleData.bubbleComponents; + MXKRoomBubbleComponent *selectedComponent; + for (selectedComponent in components) + { + if ([selectedComponent.event.eventId isEqualToString:selectedEvent.eventId]) + { + break; + } + selectedComponent = nil; + } + + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n copy] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Cancel event highlighting + [roomBubbleTableViewCell highlightTextMessageForEvent:nil]; + + NSString *textMessage = selectedComponent.textMessage; + + if (textMessage) + { + MXKPasteboardManager.shared.pasteboard.string = textMessage; + } + else + { + MXLogDebug(@"[MXKRoomViewController] Copy text failed. Text is nil for room id/event id: %@/%@", selectedComponent.event.roomId, selectedComponent.event.eventId); + } + }]]; + + if ([MXKAppSettings standardAppSettings].messageDetailsAllowSharing) + { + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n share] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Cancel event highlighting + [roomBubbleTableViewCell highlightTextMessageForEvent:nil]; + + NSArray *activityItems = [NSArray arrayWithObjects:selectedComponent.textMessage, nil]; + + UIActivityViewController *activityViewController = [[UIActivityViewController alloc] initWithActivityItems:activityItems applicationActivities:nil]; + if (activityViewController) + { + activityViewController.modalTransitionStyle = UIModalTransitionStyleCoverVertical; + activityViewController.popoverPresentationController.sourceView = roomBubbleTableViewCell; + activityViewController.popoverPresentationController.sourceRect = roomBubbleTableViewCell.bounds; + + [self presentViewController:activityViewController animated:YES completion:nil]; + } + + }]]; + } + + if (components.count > 1) + { + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n selectAll] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + [self selectAllTextMessageInCell:cell]; + + }]]; + } + } + else // Add action for attachment + { + if (attachment.type == MXKAttachmentTypeImage || attachment.type == MXKAttachmentTypeVideo) + { + if ([MXKAppSettings standardAppSettings].messageDetailsAllowSaving) + { + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n save] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + [self startActivityIndicator]; + + [attachment save:^{ + + typeof(self) self = weakSelf; + [self stopActivityIndicator]; + + } failure:^(NSError *error) { + + typeof(self) self = weakSelf; + [self stopActivityIndicator]; + + // Notify MatrixKit user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + + }]; + + // Start animation in case of download during attachment preparing + [roomBubbleTableViewCell startProgressUI]; + + }]]; + } + } + + if (attachment.type != MXKAttachmentTypeSticker) + { + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n copyButtonName] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + [self startActivityIndicator]; + + [attachment copy:^{ + + typeof(self) self = weakSelf; + [self stopActivityIndicator]; + + } failure:^(NSError *error) { + + typeof(self) self = weakSelf; + [self stopActivityIndicator]; + + // Notify MatrixKit user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + + }]; + + // Start animation in case of download during attachment preparing + [roomBubbleTableViewCell startProgressUI]; + + }]]; + + if ([MXKAppSettings standardAppSettings].messageDetailsAllowSharing) + { + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n share] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + [attachment prepareShare:^(NSURL *fileURL) { + + typeof(self) self = weakSelf; + self->documentInteractionController = [UIDocumentInteractionController interactionControllerWithURL:fileURL]; + [self->documentInteractionController setDelegate:self]; + self->currentSharedAttachment = attachment; + + if (![self->documentInteractionController presentOptionsMenuFromRect:self.view.frame inView:self.view animated:YES]) + { + self->documentInteractionController = nil; + [attachment onShareEnded]; + self->currentSharedAttachment = nil; + } + + } failure:^(NSError *error) { + + // Notify MatrixKit user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + + }]; + + // Start animation in case of download during attachment preparing + [roomBubbleTableViewCell startProgressUI]; + + }]]; + } + } + + // Check status of the selected event + if (selectedEvent.sentState == MXEventSentStatePreparing || + selectedEvent.sentState == MXEventSentStateEncrypting || + selectedEvent.sentState == MXEventSentStateUploading) + { + // Upload id is stored in attachment url (nasty trick) + NSString *uploadId = roomBubbleTableViewCell.bubbleData.attachment.contentURL; + if ([MXMediaManager existingUploaderWithId:uploadId]) + { + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancelUpload] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + // TODO cancel the attachment encryption if it is in progress. + + // Cancel the loader + MXMediaLoader *loader = [MXMediaManager existingUploaderWithId:uploadId]; + if (loader) + { + [loader cancel]; + } + + // Hide the progress animation + roomBubbleTableViewCell.progressView.hidden = YES; + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Remove the outgoing message and its related cached file. + [[NSFileManager defaultManager] removeItemAtPath:roomBubbleTableViewCell.bubbleData.attachment.cacheFilePath error:nil]; + [[NSFileManager defaultManager] removeItemAtPath:roomBubbleTableViewCell.bubbleData.attachment.thumbnailCachePath error:nil]; + [self.roomDataSource removeEventWithEventId:selectedEvent.eventId]; + } + + }]]; + } + } + } + + // Check status of the selected event + if (selectedEvent.sentState == MXEventSentStateSent) + { + // Check whether download is in progress + if (selectedEvent.isMediaAttachment) + { + NSString *downloadId = roomBubbleTableViewCell.bubbleData.attachment.downloadId; + if ([MXMediaManager existingDownloaderWithIdentifier:downloadId]) + { + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancelDownload] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Get again the loader + MXMediaLoader *loader = [MXMediaManager existingDownloaderWithIdentifier:downloadId]; + if (loader) + { + [loader cancel]; + } + // Hide the progress animation + roomBubbleTableViewCell.progressView.hidden = YES; + + }]]; + } + } + + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n showDetails] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Cancel event highlighting (if any) + [roomBubbleTableViewCell highlightTextMessageForEvent:nil]; + + // Display event details + [self showEventDetails:selectedEvent]; + + }]]; + } + + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleCancel + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Cancel event highlighting (if any) + [roomBubbleTableViewCell highlightTextMessageForEvent:nil]; + + }]]; + + // Do not display empty action sheet + if (actionSheet.actions.count > 1) + { + [actionSheet popoverPresentationController].sourceView = roomBubbleTableViewCell; + [actionSheet popoverPresentationController].sourceRect = roomBubbleTableViewCell.bounds; + [self presentViewController:actionSheet animated:YES completion:nil]; + currentAlert = actionSheet; + } + } + } + else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellLongPressOnAvatarView]) + { + MXLogDebug(@" -> Avatar of %@ has been long pressed", userInfo[kMXKRoomBubbleCellUserIdKey]); + } + else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellUnsentButtonPressed]) + { + MXEvent *selectedEvent = userInfo[kMXKRoomBubbleCellEventKey]; + if (selectedEvent) + { + // The user may want to resend it + [self promptUserToResendEvent:selectedEvent.eventId]; + } + } +} + +#pragma mark - Clipboard + +- (void)selectAllTextMessageInCell:(id)cell +{ + if (![MXKAppSettings standardAppSettings].messageDetailsAllowSharing) + { + return; + } + + if ([cell isKindOfClass:MXKRoomBubbleTableViewCell.class]) + { + MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; + selectedText = roomBubbleTableViewCell.bubbleData.textMessage; + roomBubbleTableViewCell.allTextHighlighted = YES; + + // Display Menu (dispatch is required here, else the attributed text change hides the menu) + dispatch_async(dispatch_get_main_queue(), ^{ + MXWeakify(self); + self.uiMenuControllerDidHideMenuNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:UIMenuControllerDidHideMenuNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXStrongifyAndReturnIfNil(self); + // Deselect text + roomBubbleTableViewCell.allTextHighlighted = NO; + self->selectedText = nil; + + [UIMenuController sharedMenuController].menuItems = nil; + + [[NSNotificationCenter defaultCenter] removeObserver:self.uiMenuControllerDidHideMenuNotificationObserver]; + }]; + + [self becomeFirstResponder]; + UIMenuController *menu = [UIMenuController sharedMenuController]; + menu.menuItems = @[[[UIMenuItem alloc] initWithTitle:[MatrixKitL10n share] action:@selector(share:)]]; + [menu setTargetRect:roomBubbleTableViewCell.messageTextView.frame inView:roomBubbleTableViewCell]; + [menu setMenuVisible:YES animated:YES]; + }); + } +} + +- (void)copy:(id)sender +{ + if (selectedText) + { + MXKPasteboardManager.shared.pasteboard.string = selectedText; + } + else + { + MXLogDebug(@"[MXKRoomViewController] Selected text copy failed. Selected text is nil"); + } +} + +- (void)share:(id)sender +{ + if (selectedText) + { + NSArray *activityItems = [NSArray arrayWithObjects:selectedText, nil]; + + UIActivityViewController *activityViewController = [[UIActivityViewController alloc] initWithActivityItems:activityItems applicationActivities:nil]; + if (activityViewController) + { + activityViewController.modalTransitionStyle = UIModalTransitionStyleCoverVertical; + activityViewController.popoverPresentationController.sourceView = self.view; + activityViewController.popoverPresentationController.sourceRect = self.view.bounds; + + [self presentViewController:activityViewController animated:YES completion:nil]; + } + } +} + +- (BOOL)canPerformAction:(SEL)action withSender:(id)sender +{ + if (selectedText.length && (action == @selector(copy:) || action == @selector(share:))) + { + return YES; + } + return NO; +} + +- (BOOL)canBecomeFirstResponder +{ + return (selectedText.length != 0); +} + +#pragma mark - UITableView delegate + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (tableView == _bubblesTableView) + { + return [roomDataSource cellHeightAtIndex:indexPath.row withMaximumWidth:self.tableViewSafeAreaWidth]; + } + + return 0; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (tableView == _bubblesTableView) + { + // Dismiss keyboard when user taps on messages table view content + [self dismissKeyboard]; + } +} + +- (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath*)indexPath +{ + // Release here resources, and restore reusable cells + if ([cell respondsToSelector:@selector(didEndDisplay)]) + { + [(id)cell didEndDisplay]; + } +} + +- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset +{ + // Detect vertical bounce at the top of the tableview to trigger pagination + if (scrollView == _bubblesTableView) + { + // Detect top bounce + if (scrollView.contentOffset.y < -scrollView.adjustedContentInset.top) + { + // Shall we add back pagination spinner? + if (isPaginationInProgress && !backPaginationActivityView) + { + UIActivityIndicatorView* spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray]; + spinner.hidesWhenStopped = NO; + spinner.backgroundColor = [UIColor clearColor]; + [spinner startAnimating]; + + // no need to manage constraints here + // IOS defines them. + // since IOS7 the spinner is centered so need to create a background and add it. + _bubblesTableView.tableHeaderView = backPaginationActivityView = spinner; + } + } + else + { + // Shall we add forward pagination spinner? + if (!roomDataSource.isLive && isPaginationInProgress && scrollView.contentOffset.y + scrollView.frame.size.height > scrollView.contentSize.height + 64 && !reconnectingView) + { + [self addReconnectingView]; + } + else + { + [self detectPullToKick:scrollView]; + } + } + } +} + +- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate +{ + if (scrollView == _bubblesTableView) + { + // if the user scrolls the history content without animation + // upateCurrentEventIdAtTableBottom must be called here (without dispatch). + // else it will be done in scrollViewDidEndDecelerating + if (!decelerate) + { + [self updateCurrentEventIdAtTableBottom:YES]; + } + } +} + +- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView +{ + if (scrollView == _bubblesTableView) + { + // do not dispatch the upateCurrentEventIdAtTableBottom call + // else it might triggers weird UI lags. + [self updateCurrentEventIdAtTableBottom:YES]; + [self managePullToKick:scrollView]; + } +} + +- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView +{ + if (scrollView == _bubblesTableView) + { + // do not dispatch the upateCurrentEventIdAtTableBottom call + // else it might triggers weird UI lags. + [self updateCurrentEventIdAtTableBottom:YES]; + } +} + +- (void)scrollViewDidScroll:(UIScrollView *)scrollView +{ + if (scrollView == _bubblesTableView) + { + BOOL wasScrollingToBottom = isScrollingToBottom; + + // Consider this callback to reset scrolling to bottom flag + isScrollingToBottom = NO; + + // shouldScrollToBottomOnTableRefresh is used to inhibit false detection of + // scrolling action from the user when the viewVC appears or rotates + if (scrollView == _bubblesTableView && scrollView.contentSize.height && !shouldScrollToBottomOnTableRefresh) + { + // when the content size if smaller that the frame + // scrollViewDidEndDecelerating is not called + // so test it when the content offset goes back to the screen top. + if ((scrollView.contentSize.height < scrollView.frame.size.height) && (-scrollView.contentOffset.y == scrollView.adjustedContentInset.top)) + { + [self managePullToKick:scrollView]; + } + + // Trigger inconspicuous pagination when user scrolls toward the top + if (scrollView.contentOffset.y < _paginationThreshold) + { + [self triggerPagination:_paginationLimit direction:MXTimelineDirectionBackwards]; + } + // Enable forwards pagination when displaying non live timeline + else if (!roomDataSource.isLive && !wasScrollingToBottom && ((scrollView.contentSize.height - scrollView.contentOffset.y - scrollView.frame.size.height) < _paginationThreshold)) + { + [self triggerPagination:_paginationLimit direction:MXTimelineDirectionForwards]; + } + } + + if (wasScrollingToBottom) + { + // When scrolling to the bottom is performed without animation, 'scrollViewDidEndScrollingAnimation' is not called. + // upateCurrentEventIdAtTableBottom must be called here (without dispatch). + [self updateCurrentEventIdAtTableBottom:YES]; + } + } +} + +#pragma mark - MXKRoomTitleViewDelegate + +- (void)roomTitleView:(MXKRoomTitleView*)titleView presentAlertController:(UIAlertController *)alertController +{ + [self dismissKeyboard]; + [self presentViewController:alertController animated:YES completion:nil]; +} + +- (BOOL)roomTitleViewShouldBeginEditing:(MXKRoomTitleView*)titleView +{ + return YES; +} + +- (void)roomTitleView:(MXKRoomTitleView*)titleView isSaving:(BOOL)saving +{ + if (saving) + { + [self startActivityIndicator]; + } + else + { + [self stopActivityIndicator]; + } +} + +#pragma mark - MXKRoomInputToolbarViewDelegate + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView *)toolbarView hideStatusBar:(BOOL)isHidden +{ + isStatusBarHidden = isHidden; + + // Trigger status bar update + [self setNeedsStatusBarAppearanceUpdate]; + + // Handle status bar with the historical method. + // TODO: remove this [UIApplication statusBarHidden] use (deprecated since iOS 9). + // Note: setting statusBarHidden does nothing if your application is using the default UIViewController-based status bar system. + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication) + { + sharedApplication.statusBarHidden = isHidden; + } +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView isTyping:(BOOL)typing +{ + if (_saveProgressTextInput && roomDataSource) + { + // Store the potential message partially typed in text input + roomDataSource.partialTextMessage = inputToolbarView.textMessage; + } + + [self handleTypingNotification:typing]; +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView heightDidChanged:(CGFloat)height completion:(void (^)(BOOL finished))completion +{ + _roomInputToolbarContainerHeightConstraint.constant = height; + + // Update layout with animation + [UIView animateWithDuration:self.resizeComposerAnimationDuration delay:0 options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseIn + animations:^{ + // We will scroll to bottom if the bottom of the table is currently visible + BOOL shouldScrollToBottom = [self isBubblesTableScrollViewAtTheBottom]; + + CGFloat bubblesTableViewBottomConst = self->_roomInputToolbarContainerBottomConstraint.constant + self->_roomInputToolbarContainerHeightConstraint.constant + self->_roomActivitiesContainerHeightConstraint.constant; + + self->_bubblesTableViewBottomConstraint.constant = bubblesTableViewBottomConst; + + // Force to render the view + [self.view layoutIfNeeded]; + + if (shouldScrollToBottom) + { + [self scrollBubblesTableViewToBottomAnimated:NO]; + } + } + completion:^(BOOL finished){ + if (completion) + { + completion(finished); + } + }]; +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendTextMessage:(NSString*)textMessage +{ + // Handle potential IRC commands in typed string + if ([self isIRCStyleCommand:textMessage] == NO) + { + // Send text message in the current room + [self sendTextMessage:textMessage]; + } +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendImage:(UIImage*)image +{ + // Let the datasource send it and manage the local echo + [roomDataSource sendImage:image success:nil failure:^(NSError *error) + { + // Nothing to do. The image is marked as unsent in the room history by the datasource + MXLogDebug(@"[MXKRoomViewController] sendImage failed."); + }]; +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendImage:(NSData*)imageData withMimeType:(NSString*)mimetype +{ + // Let the datasource send it and manage the local echo + [roomDataSource sendImage:imageData mimeType:mimetype success:nil failure:^(NSError *error) + { + // Nothing to do. The image is marked as unsent in the room history by the datasource + MXLogDebug(@"[MXKRoomViewController] sendImage with mimetype failed."); + }]; +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendVideo:(NSURL*)videoLocalURL withThumbnail:(UIImage*)videoThumbnail +{ + AVURLAsset *videoAsset = [AVURLAsset assetWithURL:videoLocalURL]; + [self roomInputToolbarView:toolbarView sendVideoAsset:videoAsset withThumbnail:videoThumbnail]; +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendVideoAsset:(AVAsset*)videoAsset withThumbnail:(UIImage*)videoThumbnail +{ + // Let the datasource send it and manage the local echo + [roomDataSource sendVideoAsset:videoAsset withThumbnail:videoThumbnail success:nil failure:^(NSError *error) + { + // Nothing to do. The video is marked as unsent in the room history by the datasource + MXLogDebug(@"[MXKRoomViewController] sendVideo failed."); + }]; +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendFile:(NSURL *)fileLocalURL withMimeType:(NSString*)mimetype +{ + // Let the datasource send it and manage the local echo + [roomDataSource sendFile:fileLocalURL mimeType:mimetype success:nil failure:^(NSError *error) + { + // Nothing to do. The file is marked as unsent in the room history by the datasource + MXLogDebug(@"[MXKRoomViewController] sendFile failed."); + }]; +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView presentAlertController:(UIAlertController *)alertController +{ + [self dismissKeyboard]; + [self presentViewController:alertController animated:YES completion:nil]; +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView presentViewController:(UIViewController*)viewControllerToPresent +{ + [self dismissKeyboard]; + [self presentViewController:viewControllerToPresent animated:YES completion:nil]; +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView dismissViewControllerAnimated:(BOOL)flag completion:(void (^)(void))completion +{ + [self dismissViewControllerAnimated:flag completion:completion]; +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView updateActivityIndicator:(BOOL)isAnimating +{ + isInputToolbarProcessing = isAnimating; + + if (isAnimating) + { + [self startActivityIndicator]; + } + else + { + [self stopActivityIndicator]; + } +} +# pragma mark - Typing notification + +- (void)handleTypingNotification:(BOOL)typing +{ + NSUInteger notificationTimeoutMS = -1; + if (typing) + { + // Check whether a typing event has been already reported to server (We wait for the end of the local timout before considering this new event) + if (typingTimer) + { + // Refresh date of the last observed typing + lastTypingDate = [[NSDate alloc] init]; + return; + } + + // No typing event has been yet reported -> share encryption keys if requested + if ([MXKAppSettings standardAppSettings].outboundGroupSessionKeyPreSharingStrategy == MXKKeyPreSharingWhenTyping) + { + [self shareEncryptionKeys]; + } + + // Launch a timer to prevent sending multiple typing notifications + NSTimeInterval timerTimeout = MXKROOMVIEWCONTROLLER_DEFAULT_TYPING_TIMEOUT_SEC; + if (lastTypingDate) + { + NSTimeInterval lastTypingAge = -[lastTypingDate timeIntervalSinceNow]; + if (lastTypingAge < timerTimeout) + { + // Subtract the time interval since last typing from the timer timeout + timerTimeout -= lastTypingAge; + } + else + { + timerTimeout = 0; + } + } + else + { + // Keep date of this typing event + lastTypingDate = [[NSDate alloc] init]; + } + + if (timerTimeout) + { + typingTimer = [NSTimer scheduledTimerWithTimeInterval:timerTimeout target:self selector:@selector(typingTimeout:) userInfo:self repeats:NO]; + // Compute the notification timeout in ms (consider the double of the local typing timeout) + notificationTimeoutMS = 2000 * MXKROOMVIEWCONTROLLER_DEFAULT_TYPING_TIMEOUT_SEC; + } + else + { + // This typing event is too old, we will ignore it + typing = NO; + MXLogDebug(@"[MXKRoomVC] Ignore typing event (too old)"); + } + } + else + { + // Cancel any typing timer + [typingTimer invalidate]; + typingTimer = nil; + // Reset last typing date + lastTypingDate = nil; + } + + MXWeakify(self); + + // Send typing notification to server + [roomDataSource.room sendTypingNotification:typing + timeout:notificationTimeoutMS + success:^{ + + MXStrongifyAndReturnIfNil(self); + // Reset last typing date + self->lastTypingDate = nil; + } failure:^(NSError *error) + { + MXStrongifyAndReturnIfNil(self); + + MXLogDebug(@"[MXKRoomVC] Failed to send typing notification (%d)", typing); + + // Cancel timer (if any) + [self->typingTimer invalidate]; + self->typingTimer = nil; + }]; +} + +- (IBAction)typingTimeout:(id)sender +{ + [typingTimer invalidate]; + typingTimer = nil; + + // Check whether a new typing event has been observed + BOOL typing = (lastTypingDate != nil); + // Post a new typing notification + [self handleTypingNotification:typing]; +} + + +# pragma mark - Attachment handling + +- (void)showAttachmentInCell:(UITableViewCell*)cell +{ + [self dismissKeyboard]; + + // Retrieve the attachment information from the associated cell data + if ([cell isKindOfClass:MXKTableViewCell.class]) + { + MXKCellData *cellData = ((MXKTableViewCell*)cell).mxkCellData; + + // Only 'MXKRoomBubbleCellData' is supported here for the moment. + if ([cellData isKindOfClass:MXKRoomBubbleCellData.class]) + { + MXKRoomBubbleCellData *bubbleData = (MXKRoomBubbleCellData*)cellData; + + MXKAttachment *selectedAttachment = bubbleData.attachment; + + if (bubbleData.isAttachmentWithThumbnail) + { + // The attachments viewer is opened only on a valid attachment. It does not display the stickers. + if (selectedAttachment.eventSentState == MXEventSentStateSent && selectedAttachment.type != MXKAttachmentTypeSticker) + { + // Note: the stickers are presently excluded from the attachments list returned by the room dataSource. + NSArray *attachmentsWithThumbnail = self.roomDataSource.attachmentsWithThumbnail; + + MXKAttachmentsViewController *attachmentsViewer; + + // Present an attachment viewer + if (attachmentsViewerClass) + { + attachmentsViewer = [attachmentsViewerClass animatedAttachmentsViewControllerWithSourceViewController:self]; + } + else + { + attachmentsViewer = [MXKAttachmentsViewController animatedAttachmentsViewControllerWithSourceViewController:self]; + } + + attachmentsViewer.delegate = self; + attachmentsViewer.complete = ([roomDataSource.timeline canPaginate:MXTimelineDirectionBackwards] == NO); + attachmentsViewer.hidesBottomBarWhenPushed = YES; + [attachmentsViewer displayAttachments:attachmentsWithThumbnail focusOn:selectedAttachment.eventId]; + + // Keep here the image view used to display the attachment in the selected cell. + // Note: Only `MXKRoomBubbleTableViewCell` and `MXKSearchTableViewCell` are supported for the moment. + if ([cell isKindOfClass:MXKRoomBubbleTableViewCell.class]) + { + self.openedAttachmentImageView = ((MXKRoomBubbleTableViewCell *)cell).attachmentView.imageView; + } + else if ([cell isKindOfClass:MXKSearchTableViewCell.class]) + { + self.openedAttachmentImageView = ((MXKSearchTableViewCell *)cell).attachmentImageView.imageView; + } + + self.openedAttachmentEventId = selectedAttachment.eventId; + + // "Initializing" closedAttachmentEventId so it is equal to openedAttachmentEventId at the beginning + self.closedAttachmentEventId = self.openedAttachmentEventId; + + if (@available(iOS 13.0, *)) + { + attachmentsViewer.modalPresentationStyle = UIModalPresentationFullScreen; + } + + [self presentViewController:attachmentsViewer animated:YES completion:nil]; + + self.attachmentsViewer = attachmentsViewer; + } + else + { + // Let's the application do something + MXLogDebug(@"[MXKRoomVC] showAttachmentInCell on an unsent media"); + } + } + else if (selectedAttachment.type == MXKAttachmentTypeLocation) + { + } + else if (selectedAttachment.type == MXKAttachmentTypeFile || selectedAttachment.type == MXKAttachmentTypeAudio) + { + // Start activity indicator as feedback on file selection. + [self startActivityIndicator]; + + [selectedAttachment prepareShare:^(NSURL *fileURL) { + + [self stopActivityIndicator]; + + MXWeakify(self); + void(^viewAttachment)(void) = ^() { + + MXStrongifyAndReturnIfNil(self); + + if (![self canPreviewFileAttachment:selectedAttachment withLocalFileURL:fileURL]) + { + // When we don't support showing a preview for a file, show a share + // sheet if allowed, otherwise display an error to inform the user. + if (self.allowActionsInDocumentPreview) + { + UIActivityViewController *shareSheet = [[UIActivityViewController alloc] initWithActivityItems:@[fileURL] + applicationActivities:nil]; + MXWeakify(self); + shareSheet.completionWithItemsHandler = ^(UIActivityType activityType, BOOL completed, NSArray *returnedItems, NSError *activityError) { + MXStrongifyAndReturnIfNil(self); + [selectedAttachment onShareEnded]; + self->currentSharedAttachment = nil; + }; + + self->currentSharedAttachment = selectedAttachment; + [self presentViewController:shareSheet animated:YES completion:nil]; + } + else + { + UIAlertController *alert = [UIAlertController alertControllerWithTitle:MatrixKitL10n.attachmentUnsupportedPreviewTitle + message:MatrixKitL10n.attachmentUnsupportedPreviewMessage + preferredStyle:UIAlertControllerStyleAlert]; + MXWeakify(self); + [alert addAction:[UIAlertAction actionWithTitle:MatrixKitL10n.ok style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) { + MXStrongifyAndReturnIfNil(self); + [selectedAttachment onShareEnded]; + self->currentAlert = nil; + }]]; + + [self presentViewController:alert animated:YES completion:nil]; + self->currentAlert = alert; + } + + return; + } + + if (self.allowActionsInDocumentPreview) + { + // We could get rid of this part of code and use only a MXKPreviewViewController + // Nevertheless, MXKRoomViewController is compliant to UIDocumentInteractionControllerDelegate + // and remove all this code could have effect on some custom implementations. + self->documentInteractionController = [UIDocumentInteractionController interactionControllerWithURL:fileURL]; + [self->documentInteractionController setDelegate:self]; + self->currentSharedAttachment = selectedAttachment; + + if (![self->documentInteractionController presentPreviewAnimated:YES]) + { + if (![self->documentInteractionController presentOptionsMenuFromRect:self.view.frame inView:self.view animated:YES]) + { + self->documentInteractionController = nil; + [selectedAttachment onShareEnded]; + self->currentSharedAttachment = nil; + } + } + } + else + { + self->currentSharedAttachment = selectedAttachment; + [MXKPreviewViewController presentFrom:self fileUrl:fileURL allowActions:self.allowActionsInDocumentPreview delegate:self]; + } + }; + + if (self->roomDataSource.mxSession.crypto + && [selectedAttachment.contentInfo[@"mimetype"] isEqualToString:@"text/plain"] + && [MXMegolmExportEncryption isMegolmKeyFile:fileURL]) + { + // The file is a megolm key file + // Ask the user if they wants to view the file as a classic file attachment + // or open an import process + [self->currentAlert dismissViewControllerAnimated:NO completion:nil]; + + __weak typeof(self) weakSelf = self; + UIAlertController *keysPrompt = [UIAlertController alertControllerWithTitle:@"" + message:[MatrixKitL10n attachmentE2eKeysFilePrompt] + preferredStyle:UIAlertControllerStyleAlert]; + + [keysPrompt addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n view] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + // View file content + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + + viewAttachment(); + } + + }]]; + + [keysPrompt addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n attachmentE2eKeysImport] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Show the keys import dialog + self->importView = [[MXKEncryptionKeysImportView alloc] initWithMatrixSession:self->roomDataSource.mxSession]; + self->currentAlert = self->importView.alertController; + [self->importView showInViewController:self toImportKeys:fileURL onComplete:^{ + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + self->importView = nil; + } + + }]; + } + + }]]; + + [self presentViewController:keysPrompt animated:YES completion:nil]; + self->currentAlert = keysPrompt; + } + else + { + viewAttachment(); + } + + } failure:^(NSError *error) { + + [self stopActivityIndicator]; + + // Notify MatrixKit user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + + }]; + + if ([cell isKindOfClass:MXKRoomBubbleTableViewCell.class]) + { + // Start animation in case of download + MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; + [roomBubbleTableViewCell startProgressUI]; + } + } + } + } +} + +- (BOOL)canPreviewFileAttachment:(MXKAttachment *)attachment withLocalFileURL:(NSURL *)localFileURL +{ + // Sanity check. + if (![NSFileManager.defaultManager isReadableFileAtPath:localFileURL.path]) + { + return NO; + } + + if (UIDevice.currentDevice.systemVersion.floatValue >= 13) + { + return YES; + } + + MXKUTI *attachmentUTI = attachment.uti; + MXKUTI *fileUTI = [[MXKUTI alloc] initWithLocalFileURL:localFileURL]; + if (!attachmentUTI || !fileUTI) + { + return NO; + } + + NSArray *unsupportedUTIs = @[MXKUTI.html, MXKUTI.xml, MXKUTI.svg]; + if ([attachmentUTI conformsToAnyOf:unsupportedUTIs] || [fileUTI conformsToAnyOf:unsupportedUTIs]) + { + return NO; + } + + return YES; +} + +#pragma mark - MXKAttachmentsViewControllerDelegate + +- (BOOL)attachmentsViewController:(MXKAttachmentsViewController*)attachmentsViewController paginateAttachmentBefore:(NSString*)eventId +{ + [self triggerAttachmentBackPagination:eventId]; + + return [self.roomDataSource.timeline canPaginate:MXTimelineDirectionBackwards]; +} + +- (void)displayedNewAttachmentWithEventId:(NSString *)eventId { + self.closedAttachmentEventId = eventId; +} + +#pragma mark - MXKRoomActivitiesViewDelegate + +- (void)didChangeHeight:(MXKRoomActivitiesView *)roomActivitiesView oldHeight:(CGFloat)oldHeight newHeight:(CGFloat)newHeight +{ + // We will scroll to bottom if the bottom of the table is currently visible + BOOL shouldScrollToBottom = [self isBubblesTableScrollViewAtTheBottom]; + + // Apply height change to constraints + _roomActivitiesContainerHeightConstraint.constant = newHeight; + _bubblesTableViewBottomConstraint.constant += newHeight - oldHeight; + + // Force to render the view + [self.view layoutIfNeeded]; + + if (shouldScrollToBottom) + { + [self scrollBubblesTableViewToBottomAnimated:YES]; + } +} + +#pragma mark - MXKPreviewViewControllerDelegate + +- (void)previewViewControllerDidEndPreview:(MXKPreviewViewController *)controller +{ + if (currentSharedAttachment) + { + [currentSharedAttachment onShareEnded]; + currentSharedAttachment = nil; + } +} + +#pragma mark - UIDocumentInteractionControllerDelegate + +- (UIViewController *)documentInteractionControllerViewControllerForPreview: (UIDocumentInteractionController *) controller +{ + return self; +} + +// Preview presented/dismissed on document. Use to set up any HI underneath. +- (void)documentInteractionControllerWillBeginPreview:(UIDocumentInteractionController *)controller +{ + documentInteractionController = controller; +} + +- (void)documentInteractionControllerDidEndPreview:(UIDocumentInteractionController *)controller +{ + documentInteractionController = nil; + if (currentSharedAttachment) + { + [currentSharedAttachment onShareEnded]; + currentSharedAttachment = nil; + } +} + +- (void)documentInteractionControllerDidDismissOptionsMenu:(UIDocumentInteractionController *)controller +{ + documentInteractionController = nil; + if (currentSharedAttachment) + { + [currentSharedAttachment onShareEnded]; + currentSharedAttachment = nil; + } +} + +- (void)documentInteractionControllerDidDismissOpenInMenu:(UIDocumentInteractionController *)controller +{ + documentInteractionController = nil; + if (currentSharedAttachment) + { + [currentSharedAttachment onShareEnded]; + currentSharedAttachment = nil; + } +} + +#pragma mark - resync management + +- (void)onSyncNotification +{ + latestServerSync = [NSDate date]; + [self removeReconnectingView]; +} + +- (BOOL)canReconnect +{ + // avoid restarting connection if some data has been received within 1 second (1000 : latestServerSync is null) + NSTimeInterval interval = latestServerSync ? [[NSDate date] timeIntervalSinceDate:latestServerSync] : 1000; + return (interval > 1) && [self.mainSession reconnect]; +} + +- (void)addReconnectingView +{ + if (!reconnectingView) + { + UIActivityIndicatorView* spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray]; + [spinner sizeToFit]; + spinner.hidesWhenStopped = NO; + spinner.backgroundColor = [UIColor clearColor]; + [spinner startAnimating]; + + // no need to manage constraints here + // IOS defines them. + // since IOS7 the spinner is centered so need to create a background and add it. + _bubblesTableView.tableFooterView = reconnectingView = spinner; + } +} + +- (void)removeReconnectingView +{ + if (reconnectingView && !restartConnection) + { + _bubblesTableView.tableFooterView = reconnectingView = nil; + } +} + +/** + Detect if the current connection must be restarted. + The spinner is displayed until the overscroll ends (and scrollViewDidEndDecelerating is called). + */ +- (void)detectPullToKick:(UIScrollView *)scrollView +{ + if (roomDataSource.isLive && !reconnectingView) + { + // detect if the user scrolls over the tableview bottom + restartConnection = ( + ((scrollView.contentSize.height < scrollView.frame.size.height) && (scrollView.contentOffset.y > 128)) + || + ((scrollView.contentSize.height > scrollView.frame.size.height) && (scrollView.contentOffset.y + scrollView.frame.size.height) > (scrollView.contentSize.height + 128))); + + if (restartConnection) + { + // wait that list decelerate to display / hide it + [self addReconnectingView]; + } + } +} + + +/** + Restarts the current connection if it is required. + The 0.3s delay is added to avoid flickering if the connection does not require to be restarted. + */ +- (void)managePullToKick:(UIScrollView *)scrollView +{ + // the current connection must be restarted + if (roomDataSource.isLive && restartConnection) + { + // display at least 0.3s the spinner to show to the user that something is pending + // else the UI is flickering + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.3 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ + self->restartConnection = NO; + + if (![self canReconnect]) + { + // if the event stream has not been restarted + // hide the spinner + [self removeReconnectingView]; + } + // else wait that onSyncNotification is called. + }); + } +} + +#pragma mark - MXKSourceAttachmentAnimatorDelegate + +- (UIImageView *)originalImageView { + if ([self.openedAttachmentEventId isEqualToString:self.closedAttachmentEventId]) { + return self.openedAttachmentImageView; + } + return nil; +} + + +- (CGRect)convertedFrameForOriginalImageView { + if ([self.openedAttachmentEventId isEqualToString:self.closedAttachmentEventId]) { + return [self.openedAttachmentImageView convertRect:self.openedAttachmentImageView.frame toView:nil]; + } + //default frame which will be used if the user scrolls to other attachments in MXKAttachmentsViewController + return CGRectMake(CGRectGetWidth(self.view.frame)/2, 0.0, 0.0, 0.0); +} + +#pragma mark - Encryption key sharing + +- (void)shareEncryptionKeys +{ + __block NSString *roomId = roomDataSource.roomId; + [roomDataSource.mxSession.crypto ensureEncryptionInRoom:roomId success:^{ + MXLogDebug(@"[MXKRoomViewController] Key shared for room: %@", roomId); + } failure:^(NSError *error) { + MXLogDebug(@"[MXKRoomViewController] Failed to share key for room %@: %@", roomId, error); + }]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.xib new file mode 100644 index 000000000..1ad5d0650 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.xib @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.h new file mode 100644 index 000000000..b07abb50d --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.h @@ -0,0 +1,75 @@ +/* +Copyright 2015 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +#import + +#import "MXKViewController.h" + +#import "MXKSearchDataSource.h" + +/** + This view controller handles search server side. Only one matrix session is handled by this view controller. + + According to its dataSource configuration the search can be done all user's rooms or a set of rooms. + */ +@interface MXKSearchViewController : MXKViewController + +@property (weak, nonatomic) IBOutlet UISearchBar *searchSearchBar; +@property (weak, nonatomic) IBOutlet UITableView *searchTableView; +@property (weak, nonatomic) IBOutlet UILabel *noResultsLabel; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *searchSearchBarTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *searchSearchBarHeightConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *searchTableViewBottomConstraint; + +/** + The current data source associated to the view controller. + */ +@property (nonatomic, readonly) MXKSearchDataSource *dataSource; + +/** + Enable the search option by adding a navigation item in the navigation bar (YES by default). + Set NO this property to disable this option and hide the related bar button. + */ +@property (nonatomic) BOOL enableBarButtonSearch; + +/** + If YES, the table view will scroll at the bottom on the next data source refresh. + It comes back to NO after each refresh. + */ +@property (nonatomic) BOOL shouldScrollToBottomOnRefresh; + + +#pragma mark - Class methods + +/** + Creates and returns a new `MXKSearchViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKSearchViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)searchViewController; + +/** + Display the search results described in the provided data source. + + Note: The provided data source replaces the current data source if any. The current + data source is released. + + @param searchDataSource the data source providing the search results. + */ +- (void)displaySearch:(MXKSearchDataSource*)searchDataSource; + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.m new file mode 100644 index 000000000..7ac637b33 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.m @@ -0,0 +1,423 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKSearchViewController.h" + +#import "MXKSearchTableViewCell.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@interface MXKSearchViewController () +{ + /** + Optional bar buttons + */ + UIBarButtonItem *searchBarButton; + + /** + Search handling + */ + BOOL ignoreSearchRequest; +} +@end + +@implementation MXKSearchViewController +@synthesize dataSource, shouldScrollToBottomOnRefresh; + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKSearchViewController class]) + bundle:[NSBundle bundleForClass:[MXKSearchViewController class]]]; +} + ++ (instancetype)searchViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKSearchViewController class]) + bundle:[NSBundle bundleForClass:[MXKSearchViewController class]]]; +} + +#pragma mark - + +- (void)finalizeInit +{ + [super finalizeInit]; + + _enableBarButtonSearch = YES; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!_searchTableView) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + // Adjust Top and Bottom constraints to take into account potential navBar and tabBar. + [NSLayoutConstraint deactivateConstraints:@[_searchSearchBarTopConstraint, _searchTableViewBottomConstraint]]; + + _searchSearchBarTopConstraint = [NSLayoutConstraint constraintWithItem:self.topLayoutGuide + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:self.searchSearchBar + attribute:NSLayoutAttributeTop + multiplier:1.0f + constant:0.0f]; + + _searchTableViewBottomConstraint = [NSLayoutConstraint constraintWithItem:self.bottomLayoutGuide + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self.searchTableView + attribute:NSLayoutAttributeBottom + multiplier:1.0f + constant:0.0f]; + + [NSLayoutConstraint activateConstraints:@[_searchSearchBarTopConstraint, _searchTableViewBottomConstraint]]; + + // Hide search bar by default + self.searchSearchBar.hidden = YES; + self.searchSearchBarHeightConstraint.constant = 0; + [self.view setNeedsUpdateConstraints]; + + self.noResultsLabel.text = [MatrixKitL10n searchNoResults]; + self.noResultsLabel.hidden = YES; + + searchBarButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemSearch target:self action:@selector(showSearchBar:)]; + + // Apply search option in navigation bar + self.enableBarButtonSearch = _enableBarButtonSearch; + + // Finalize table view configuration + _searchTableView.delegate = self; + _searchTableView.dataSource = dataSource; // Note: dataSource may be nil here + + // Set up classes to use for cells + [self.searchTableView registerNib:MXKSearchTableViewCell.nib forCellReuseIdentifier:MXKSearchTableViewCell.defaultReuseIdentifier]; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + // Restore search mechanism (if enabled) + ignoreSearchRequest = NO; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + // The user may still press search button whereas the view disappears + ignoreSearchRequest = YES; +} + + +#pragma mark - Override MXKViewController + +- (void)onKeyboardShowAnimationComplete +{ + // Report the keyboard view in order to track keyboard frame changes + self.keyboardView = _searchSearchBar.inputAccessoryView.superview; +} + +- (void)setKeyboardHeight:(CGFloat)keyboardHeight +{ + // Deduce the bottom constraint for the table view (Don't forget the potential tabBar) + CGFloat tableViewBottomConst = keyboardHeight - self.bottomLayoutGuide.length; + // Check whether the keyboard is over the tabBar + if (tableViewBottomConst < 0) + { + tableViewBottomConst = 0; + } + + // Update constraints + _searchTableViewBottomConstraint.constant = tableViewBottomConst; + + // Force layout immediately to take into account new constraint + [self.view layoutIfNeeded]; +} + +- (void)destroy +{ + _searchTableView.dataSource = nil; + _searchTableView.delegate = nil; + _searchTableView = nil; + + dataSource.delegate = nil; + [dataSource destroy]; + dataSource = nil; + + [super destroy]; +} + +#pragma mark - + +- (void)displaySearch:(MXKSearchDataSource*)searchDataSource +{ + // Cancel registration on existing dataSource if any + if (dataSource) + { + dataSource.delegate = nil; + + // Remove associated matrix sessions + [self removeMatrixSession:dataSource.mxSession]; + + [dataSource destroy]; + } + + dataSource = searchDataSource; + dataSource.delegate = self; + + // Report the related matrix sessions at view controller level to update UI according to sessions state + [self addMatrixSession:searchDataSource.mxSession]; + + if (_searchTableView) + { + // Set up table data source + _searchTableView.dataSource = dataSource; + } +} + + +#pragma mark - UIBarButton handling + +- (void)setEnableBarButtonSearch:(BOOL)enableBarButtonSearch +{ + _enableBarButtonSearch = enableBarButtonSearch; + [self refreshUIBarButtons]; +} + +- (void)refreshUIBarButtons +{ + if (_enableBarButtonSearch) + { + self.navigationItem.rightBarButtonItems = @[searchBarButton]; + } + else + { + self.navigationItem.rightBarButtonItems = nil; + } +} + +#pragma mark - MXKDataSourceDelegate + +- (Class)cellViewClassForCellData:(MXKCellData*)cellData +{ + return MXKSearchTableViewCell.class; +} + +- (NSString *)cellReuseIdentifierForCellData:(MXKCellData*)cellData +{ + return MXKSearchTableViewCell.defaultReuseIdentifier; +} + +- (void)dataSource:(MXKDataSource *)dataSource didCellChange:(id)changes +{ + __block CGPoint tableViewOffset; + + if (!shouldScrollToBottomOnRefresh) + { + // Store current tableview scrolling point to restore it after [UITableView reloadData] + // This avoids unexpected scrolling for the user + tableViewOffset = _searchTableView.contentOffset; + } + + [_searchTableView reloadData]; + + if (shouldScrollToBottomOnRefresh) + { + [self scrollToBottomAnimated:NO]; + shouldScrollToBottomOnRefresh = NO; + } + else + { + // Restore the user scrolling point by computing the offset introduced by new cells + // New cells are always introduced at the top of the table + NSIndexSet *insertedIndexes = (NSIndexSet*)changes; + + // Get each new cell height + [insertedIndexes enumerateIndexesUsingBlock:^(NSUInteger idx, BOOL * _Nonnull stop) { + + MXKCellData* cellData = [self.dataSource cellDataAtIndex:idx]; + Class class = [self cellViewClassForCellData:cellData]; + + tableViewOffset.y += [class heightForCellData:cellData withMaximumWidth:self->_searchTableView.frame.size.width]; + + }]; + + [_searchTableView setContentOffset:tableViewOffset animated:NO]; + } + + self.title = [NSString stringWithFormat:@"%@ (%tu)", self.dataSource.searchText, self.dataSource.serverCount]; +} + +- (void)dataSource:(MXKDataSource*)dataSource2 didStateChange:(MXKDataSourceState)state +{ + // MXKSearchDataSource comes back to the `MXKDataSourceStatePreparing` when searching + if (state == MXKDataSourceStatePreparing) + { + _noResultsLabel.hidden = YES; + [self startActivityIndicator]; + } + else + { + [self stopActivityIndicator]; + + // Display "No Results" if a search is active with an empty result + if (dataSource.searchText.length && ![dataSource tableView:_searchTableView numberOfRowsInSection:0]) + { + _noResultsLabel.hidden = NO; + _searchTableView.hidden = YES; + } + else + { + _noResultsLabel.hidden = YES; + _searchTableView.hidden = NO; + } + } +} + +#pragma mark - UITableView delegate + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + MXKCellData *cellData = [dataSource cellDataAtIndex:indexPath.row]; + + Class class = [self cellViewClassForCellData:cellData]; + return [class heightForCellData:cellData withMaximumWidth:tableView.frame.size.width]; +} + + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + // Must be implemented at app level +} + +- (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath*)indexPath +{ + // Release here resources, and restore reusable cells + if ([cell respondsToSelector:@selector(didEndDisplay)]) + { + [(id)cell didEndDisplay]; + } +} + +- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset +{ + // Detect vertical bounce at the top of the tableview to trigger pagination + if (scrollView == _searchTableView) + { + // paginate ? + if (scrollView.contentOffset.y < -64) + { + [self triggerBackPagination]; + } + } +} + +#pragma mark - UISearchBarDelegate + +- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar +{ + // "Done" key has been pressed + [searchBar resignFirstResponder]; + + // Apply filter + if (searchBar.text.length) + { + shouldScrollToBottomOnRefresh = YES; + [dataSource searchMessages:searchBar.text force:NO]; + } +} + +- (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar +{ + // Leave search + [searchBar resignFirstResponder]; + + self.searchSearchBar.hidden = YES; + self.searchSearchBarHeightConstraint.constant = 0; + [self.view setNeedsUpdateConstraints]; + + self.searchSearchBar.text = nil; +} + +#pragma mark - Actions + +- (void)showSearchBar:(id)sender +{ + // The user may have pressed search button whereas the view controller was disappearing + if (ignoreSearchRequest) + { + return; + } + + if (self.searchSearchBar.isHidden) + { + self.searchSearchBar.hidden = NO; + self.searchSearchBarHeightConstraint.constant = 44; + [self.view setNeedsUpdateConstraints]; + + [self.searchSearchBar becomeFirstResponder]; + } + else + { + [self searchBarCancelButtonClicked: self.searchSearchBar]; + } +} + +#pragma mark - Private methods + +- (void)triggerBackPagination +{ + // Paginate only if possible + if (NO == dataSource.canPaginate) + { + return; + } + + [dataSource paginateBack]; +} + +- (void)scrollToBottomAnimated:(BOOL)animated +{ + if (_searchTableView.contentSize.height) + { + CGFloat visibleHeight = _searchTableView.frame.size.height - _searchTableView.adjustedContentInset.top - _searchTableView.adjustedContentInset.bottom; + if (visibleHeight < _searchTableView.contentSize.height) + { + CGFloat wantedOffsetY = _searchTableView.contentSize.height - visibleHeight - _searchTableView.adjustedContentInset.top; + CGFloat currentOffsetY = _searchTableView.contentOffset.y; + if (wantedOffsetY != currentOffsetY) + { + [_searchTableView setContentOffset:CGPointMake(0, wantedOffsetY) animated:animated]; + } + } + else + { + _searchTableView.contentOffset = CGPointMake(0, - _searchTableView.adjustedContentInset.top); + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.xib new file mode 100644 index 000000000..77a7c29f6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.xib @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKTableViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKTableViewController.h new file mode 100644 index 000000000..76f196626 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKTableViewController.h @@ -0,0 +1,27 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKViewControllerHandling.h" + +/** + MXKViewController extends UITableViewController to handle requirements for + any matrixKit table view controllers (see MXKViewControllerHandling protocol). + */ + +@interface MXKTableViewController : UITableViewController + +@end + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKTableViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKTableViewController.m new file mode 100644 index 000000000..383672543 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKTableViewController.m @@ -0,0 +1,574 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewController.h" + +#import "UIViewController+MatrixKit.h" +#import "MXSession+MatrixKit.h" + +@interface MXKTableViewController () +{ + /** + Array of `MXSession` instances. + */ + NSMutableArray *mxSessionArray; + + /** + Keep reference on the pushed view controllers to release them correctly + */ + NSMutableArray *childViewControllers; +} +@end + +@implementation MXKTableViewController +@synthesize defaultBarTintColor, enableBarTintColorStatusChange; +@synthesize barTitleColor; +@synthesize mainSession; +@synthesize activityIndicator, rageShakeManager; +@synthesize childViewControllers; + +#pragma mark - + +- (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil +{ + self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]; + if (self) + { + [self finalizeInit]; + } + + return self; +} + +- (nullable instancetype)initWithCoder:(NSCoder *)aDecoder +{ + self = [super initWithCoder:aDecoder]; + if (self) + { + [self finalizeInit]; + } + + return self; +} + +- (void)finalizeInit +{ + // Set default properties values + defaultBarTintColor = nil; + barTitleColor = nil; + enableBarTintColorStatusChange = YES; + rageShakeManager = nil; + + mxSessionArray = [NSMutableArray array]; + childViewControllers = [NSMutableArray array]; +} + +#pragma mark - + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Add default activity indicator + activityIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhite]; + activityIndicator.backgroundColor = [UIColor colorWithRed:0.8 green:0.8 blue:0.8 alpha:1.0]; + activityIndicator.autoresizingMask = UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin | UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin; + + CGRect frame = activityIndicator.frame; + frame.size.width += 30; + frame.size.height += 30; + activityIndicator.bounds = frame; + [activityIndicator.layer setCornerRadius:5]; + + activityIndicator.center = self.view.center; + [self.view addSubview:activityIndicator]; +} + +- (void)dealloc +{ + if (activityIndicator) + { + [activityIndicator removeFromSuperview]; + activityIndicator = nil; + } +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + if (self.rageShakeManager) + { + [self.rageShakeManager cancel:self]; + } + + // Update UI according to mxSession state, and add observer (if need) + if (mxSessionArray.count) + { + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMatrixSessionStateDidChange:) name:kMXSessionStateDidChangeNotification object:nil]; + } + [self onMatrixSessionChange]; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionStateDidChangeNotification object:nil]; + + [activityIndicator stopAnimating]; + + if (self.rageShakeManager) + { + [self.rageShakeManager cancel:self]; + } +} + +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + + MXLogDebug(@"[MXKTableViewController] %@ viewDidAppear", self.class); + + // Release properly pushed and/or presented view controller + if (childViewControllers.count) + { + for (id viewController in childViewControllers) + { + if ([viewController isKindOfClass:[UINavigationController class]]) + { + UINavigationController *navigationController = (UINavigationController*)viewController; + for (id subViewController in navigationController.viewControllers) + { + if ([subViewController respondsToSelector:@selector(destroy)]) + { + [subViewController destroy]; + } + } + } + else if ([viewController respondsToSelector:@selector(destroy)]) + { + [viewController destroy]; + } + } + + [childViewControllers removeAllObjects]; + } +} + +- (void)viewDidDisappear:(BOOL)animated +{ + [super viewDidDisappear:animated]; + + MXLogDebug(@"[MXKTableViewController] %@ viewDidDisappear", self.class); +} + +- (void)setEnableBarTintColorStatusChange:(BOOL)enable +{ + if (enableBarTintColorStatusChange != enable) + { + enableBarTintColorStatusChange = enable; + + [self onMatrixSessionChange]; + } +} + +- (void)setDefaultBarTintColor:(UIColor *)barTintColor +{ + defaultBarTintColor = barTintColor; + + if (enableBarTintColorStatusChange) + { + // Force update by taking into account the matrix session state. + [self onMatrixSessionChange]; + } + else + { + // Set default tintColor + self.navigationController.navigationBar.barTintColor = defaultBarTintColor; + self.mxk_mainNavigationController.navigationBar.barTintColor = defaultBarTintColor; + } +} + +- (void)setBarTitleColor:(UIColor *)titleColor +{ + barTitleColor = titleColor; + + // Retrieve the main navigation controller if the current view controller is embedded inside a split view controller. + UINavigationController *mainNavigationController = self.mxk_mainNavigationController; + + // Set navigation bar title color + NSDictionary *titleTextAttributes = self.navigationController.navigationBar.titleTextAttributes; + if (titleTextAttributes) + { + NSMutableDictionary *textAttributes = [NSMutableDictionary dictionaryWithDictionary:titleTextAttributes]; + textAttributes[NSForegroundColorAttributeName] = barTitleColor; + self.navigationController.navigationBar.titleTextAttributes = textAttributes; + } + else if (barTitleColor) + { + self.navigationController.navigationBar.titleTextAttributes = @{NSForegroundColorAttributeName: barTitleColor}; + } + + if (mainNavigationController) + { + titleTextAttributes = mainNavigationController.navigationBar.titleTextAttributes; + if (titleTextAttributes) + { + NSMutableDictionary *textAttributes = [NSMutableDictionary dictionaryWithDictionary:titleTextAttributes]; + textAttributes[NSForegroundColorAttributeName] = barTitleColor; + mainNavigationController.navigationBar.titleTextAttributes = textAttributes; + } + else if (barTitleColor) + { + mainNavigationController.navigationBar.titleTextAttributes = @{NSForegroundColorAttributeName: barTitleColor}; + } + } +} + +- (void)setView:(UIView *)view +{ + [super setView:view]; + + // Keep the activity indicator (if any) + if (view && activityIndicator) + { + [self.view addSubview:activityIndicator]; + } +} + +- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender +{ + // Keep ref on destinationViewController + [childViewControllers addObject:segue.destinationViewController]; +} + +- (void)presentViewController:(UIViewController *)viewControllerToPresent animated:(BOOL)flag completion:(void (^)(void))completion +{ + // Keep ref on presented view controller + [childViewControllers addObject:viewControllerToPresent]; + + [super presentViewController:viewControllerToPresent animated:flag completion:completion]; +} + +#pragma mark - + +- (void)addMatrixSession:(MXSession*)mxSession +{ + if (!mxSession || mxSession.state == MXSessionStateClosed) + { + return; + } + + if (!mxSessionArray.count) + { + [mxSessionArray addObject:mxSession]; + + // Add matrix sessions observer on first added session + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMatrixSessionStateDidChange:) name:kMXSessionStateDidChangeNotification object:nil]; + } + else if ([mxSessionArray indexOfObject:mxSession] == NSNotFound) + { + [mxSessionArray addObject:mxSession]; + } + + // Force update + [self onMatrixSessionChange]; +} + +- (void)removeMatrixSession:(MXSession*)mxSession +{ + if (!mxSession) + { + return; + } + + NSUInteger index = [mxSessionArray indexOfObject:mxSession]; + if (index != NSNotFound) + { + [mxSessionArray removeObjectAtIndex:index]; + + if (!mxSessionArray.count) + { + // Remove matrix sessions observer + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionStateDidChangeNotification object:nil]; + } + } + + // Force update + [self onMatrixSessionChange]; +} + +- (NSArray*)mxSessions +{ + return [NSArray arrayWithArray:mxSessionArray]; +} + +- (MXSession*)mainSession +{ + // We consider the first added session as the main one. + if (mxSessionArray.count) + { + return [mxSessionArray firstObject]; + } + return nil; +} + +#pragma mark - + +- (void)withdrawViewControllerAnimated:(BOOL)animated completion:(void (^)(void))completion +{ + // Check whether the view controller is embedded inside a navigation controller. + if (self.navigationController) + { + [self popViewController:self navigationController:self.navigationController animated:animated completion:completion]; + } + else + { + // Suppose here the view controller has been presented modally. We dismiss it + [self dismissViewControllerAnimated:animated completion:completion]; + } +} + +- (void)popViewController:(UIViewController*)viewController navigationController:(UINavigationController*)navigationController animated:(BOOL)animated completion:(void (^)(void))completion +{ + // We pop the view controller (except if it is the root view controller). + NSUInteger index = [navigationController.viewControllers indexOfObject:viewController]; + if (index != NSNotFound) + { + if (index > 0) + { + UIViewController *previousViewController = [navigationController.viewControllers objectAtIndex:(index - 1)]; + [navigationController popToViewController:previousViewController animated:animated]; + + if (completion) + { + completion(); + } + } + else + { + // Check whether the navigation controller is embedded inside a navigation controller, to pop it. + if (navigationController.navigationController) + { + [self popViewController:navigationController navigationController:navigationController.navigationController animated:animated completion:completion]; + } + else + { + // Remove the root view controller + navigationController.viewControllers = @[]; + // Suppose here the navigation controller has been presented modally. We dismiss it + [navigationController dismissViewControllerAnimated:animated completion:completion]; + } + } + } +} + +- (void)destroy +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + mxSessionArray = nil; + childViewControllers = nil; +} + +#pragma mark - Sessions handling + +- (void)onMatrixSessionStateDidChange:(NSNotification *)notif +{ + MXSession *mxSession = notif.object; + + NSUInteger index = [mxSessionArray indexOfObject:mxSession]; + if (index != NSNotFound) + { + if (mxSession.state == MXSessionStateClosed) + { + // Call here the dedicated method which may be overridden + [self removeMatrixSession:mxSession]; + } + else + { + [self onMatrixSessionChange]; + } + } +} + +- (void)onMatrixSessionChange +{ + // This method is called to refresh view controller appearance on session state change, + // It is called when the view will appear to update session array by removing closed sessions. + // Indeed 'kMXSessionStateDidChangeNotification' are observed only when the view controller is visible. + + // Retrieve the main navigation controller if the current view controller is embedded inside a split view controller. + UINavigationController *mainNavigationController = self.mxk_mainNavigationController; + + if (mxSessionArray.count) + { + // Check each session state + UIColor *barTintColor = defaultBarTintColor; + BOOL allHomeserverNotReachable = YES; + BOOL isActivityInProgress = NO; + for (NSUInteger index = 0; index < mxSessionArray.count;) + { + MXSession *mxSession = mxSessionArray[index]; + + // Remove here closed sessions + if (mxSession.state == MXSessionStateClosed) + { + // Call here the dedicated method which may be overridden. + // This method will call again [onMatrixSessionChange] when session is removed. + [self removeMatrixSession:mxSession]; + return; + } + else + { + if (mxSession.state == MXSessionStateHomeserverNotReachable) + { + barTintColor = [UIColor orangeColor]; + } + else + { + allHomeserverNotReachable = NO; + isActivityInProgress = mxSession.shouldShowActivityIndicator; + } + + index ++; + } + } + + // Check whether the navigation bar color depends on homeserver reachability. + if (enableBarTintColorStatusChange) + { + // The navigation bar tintColor reflects the matrix homeserver reachability status. + if (allHomeserverNotReachable) + { + self.navigationController.navigationBar.barTintColor = [UIColor redColor]; + if (mainNavigationController) + { + mainNavigationController.navigationBar.barTintColor = [UIColor redColor]; + } + } + else + { + self.navigationController.navigationBar.barTintColor = barTintColor; + if (mainNavigationController) + { + mainNavigationController.navigationBar.barTintColor = barTintColor; + } + } + } + + // Run activity indicator if need + if (isActivityInProgress) + { + [self startActivityIndicator]; + } + else + { + [self stopActivityIndicator]; + } + } + else + { + // Hide potential activity indicator + [self stopActivityIndicator]; + + // Check whether the navigation bar color depends on homeserver reachability. + if (enableBarTintColorStatusChange) + { + // Restore default tintColor + self.navigationController.navigationBar.barTintColor = defaultBarTintColor; + if (mainNavigationController) + { + mainNavigationController.navigationBar.barTintColor = defaultBarTintColor; + } + } + } +} + +#pragma mark - Activity indicator + +- (void)startActivityIndicator +{ + if (activityIndicator) + { + // Keep centering the loading wheel + CGPoint center = self.view.center; + center.y += self.tableView.contentOffset.y - self.tableView.adjustedContentInset.top; + activityIndicator.center = center; + [self.view bringSubviewToFront:activityIndicator]; + + [activityIndicator startAnimating]; + + // Show the loading wheel after a delay so that if the caller calls stopActivityIndicator + // in a short future, the loading wheel will not be displayed to the end user. + activityIndicator.alpha = 0; + [UIView animateWithDuration:0.3 delay:0.3 options:UIViewAnimationOptionBeginFromCurrentState animations:^{ + self->activityIndicator.alpha = 1; + } completion:^(BOOL finished) + { + }]; + } +} + +- (void)stopActivityIndicator +{ + // Check whether all conditions are satisfied before stopping loading wheel + BOOL isActivityInProgress = NO; + for (MXSession *mxSession in mxSessionArray) + { + if (mxSession.shouldShowActivityIndicator) + { + isActivityInProgress = YES; + } + } + if (!isActivityInProgress) + { + [activityIndicator stopAnimating]; + } +} + +#pragma mark - Shake handling + +- (void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent *)event +{ + if (motion == UIEventSubtypeMotionShake && self.rageShakeManager) + { + [self.rageShakeManager startShaking:self]; + } +} + +- (void)motionCancelled:(UIEventSubtype)motion withEvent:(UIEvent *)event +{ + [self motionEnded:motion withEvent:event]; +} + +- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event +{ + if (self.rageShakeManager) + { + [self.rageShakeManager stopShaking:self]; + } +} + +- (BOOL)canBecomeFirstResponder +{ + return (self.rageShakeManager != nil); +} + + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKViewController.h new file mode 100644 index 000000000..e75d42e11 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKViewController.h @@ -0,0 +1,52 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKViewControllerHandling.h" +#import "MXKActivityHandlingViewController.h" + +/** + MXKViewController extends UIViewController to handle requirements for + any matrixKit view controllers (see MXKViewControllerHandling protocol). + + This class provides some methods to ease keyboard handling. + */ + +@interface MXKViewController : MXKActivityHandlingViewController + + +#pragma mark - Keyboard handling + +/** + Call when keyboard display animation is complete. + + Override this method to set the actual keyboard view in 'keyboardView' property. + The 'MXKViewController' instance will then observe the keyboard frame changes, and update 'keyboardHeight' property. + */ +- (void)onKeyboardShowAnimationComplete; + +/** + The current keyboard view (This field is nil when keyboard is dismissed). + This property should be set when keyboard display animation is complete to track keyboard frame changes. + */ +@property (nonatomic) UIView *keyboardView; + +/** + The current keyboard height (This field is 0 when keyboard is dismissed). + */ +@property CGFloat keyboardHeight; + +@end + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKViewController.m new file mode 100644 index 000000000..a4e559b4c --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKViewController.m @@ -0,0 +1,657 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKViewController.h" + +#import "UIViewController+MatrixKit.h" +#import "MXSession+MatrixKit.h" + +const CGFloat MXKViewControllerMaxExternalKeyboardHeight = 80; + +@interface MXKViewController () +{ + /** + Array of `MXSession` instances. + */ + NSMutableArray *mxSessionArray; + + /** + Keep reference on the pushed view controllers to release them correctly + */ + NSMutableArray *childViewControllers; +} +@end + +@implementation MXKViewController +@synthesize defaultBarTintColor, enableBarTintColorStatusChange; +@synthesize barTitleColor; +@synthesize mainSession; +@synthesize rageShakeManager; +@synthesize childViewControllers; + +#pragma mark - + +- (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil +{ + self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]; + if (self) + { + [self finalizeInit]; + } + + return self; +} + +- (nullable instancetype)initWithCoder:(NSCoder *)aDecoder +{ + self = [super initWithCoder:aDecoder]; + if (self) + { + [self finalizeInit]; + } + + return self; +} + +- (void)finalizeInit +{ + // Set default properties values + defaultBarTintColor = nil; + barTitleColor = nil; + enableBarTintColorStatusChange = YES; + rageShakeManager = nil; + + mxSessionArray = [NSMutableArray array]; + childViewControllers = [NSMutableArray array]; +} + +#pragma mark - + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + if (self.rageShakeManager) + { + [self.rageShakeManager cancel:self]; + } + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onKeyboardWillShow:) name:UIKeyboardWillShowNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onKeyboardWillHide:) name:UIKeyboardWillHideNotification object:nil]; + + // Update UI according to mxSession state, and add observer (if need) + if (mxSessionArray.count) + { + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMatrixSessionStateDidChange:) name:kMXSessionStateDidChangeNotification object:nil]; + } + [self onMatrixSessionChange]; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:UIKeyboardWillHideNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:UIKeyboardWillShowNotification object:nil]; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionStateDidChangeNotification object:nil]; + + [self.activityIndicator stopAnimating]; + + if (self.rageShakeManager) + { + [self.rageShakeManager cancel:self]; + } + + // Remove keyboard view (if any) + self.keyboardView = nil; + self.keyboardHeight = 0; +} + +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + + MXLogDebug(@"[MXKViewController] %@ viewDidAppear", self.class); + + // Release properly pushed and/or presented view controller + if (childViewControllers.count) + { + for (id viewController in childViewControllers) + { + if ([viewController isKindOfClass:[UINavigationController class]]) + { + UINavigationController *navigationController = (UINavigationController*)viewController; + for (id subViewController in navigationController.viewControllers) + { + if ([subViewController respondsToSelector:@selector(destroy)]) + { + [subViewController destroy]; + } + } + } + else if ([viewController respondsToSelector:@selector(destroy)]) + { + [viewController destroy]; + } + } + + [childViewControllers removeAllObjects]; + } +} + +- (void)viewDidDisappear:(BOOL)animated +{ + [super viewDidDisappear:animated]; + + MXLogDebug(@"[MXKViewController] %@ viewDidDisappear", self.class); +} + +- (void)setEnableBarTintColorStatusChange:(BOOL)enable +{ + if (enableBarTintColorStatusChange != enable) + { + enableBarTintColorStatusChange = enable; + + [self onMatrixSessionChange]; + } +} + +- (void)setDefaultBarTintColor:(UIColor *)barTintColor +{ + defaultBarTintColor = barTintColor; + + if (enableBarTintColorStatusChange) + { + // Force update by taking into account the matrix session state. + [self onMatrixSessionChange]; + } + else + { + // Set default tintColor + self.navigationController.navigationBar.barTintColor = defaultBarTintColor; + self.mxk_mainNavigationController.navigationBar.barTintColor = defaultBarTintColor; + } +} + +- (void)setBarTitleColor:(UIColor *)titleColor +{ + barTitleColor = titleColor; + + // Retrieve the main navigation controller if the current view controller is embedded inside a split view controller. + UINavigationController *mainNavigationController = self.mxk_mainNavigationController; + + // Set navigation bar title color + NSDictionary *titleTextAttributes = self.navigationController.navigationBar.titleTextAttributes; + if (titleTextAttributes) + { + NSMutableDictionary *textAttributes = [NSMutableDictionary dictionaryWithDictionary:titleTextAttributes]; + textAttributes[NSForegroundColorAttributeName] = barTitleColor; + self.navigationController.navigationBar.titleTextAttributes = textAttributes; + } + else if (barTitleColor) + { + self.navigationController.navigationBar.titleTextAttributes = @{NSForegroundColorAttributeName: barTitleColor}; + } + + if (mainNavigationController) + { + titleTextAttributes = mainNavigationController.navigationBar.titleTextAttributes; + if (titleTextAttributes) + { + NSMutableDictionary *textAttributes = [NSMutableDictionary dictionaryWithDictionary:titleTextAttributes]; + textAttributes[NSForegroundColorAttributeName] = barTitleColor; + mainNavigationController.navigationBar.titleTextAttributes = textAttributes; + } + else if (barTitleColor) + { + mainNavigationController.navigationBar.titleTextAttributes = @{NSForegroundColorAttributeName: barTitleColor}; + } + } +} + +- (void)setView:(UIView *)view +{ + [super setView:view]; + + // Keep the activity indicator (if any) + if (self.activityIndicator) + { + self.activityIndicator.center = self.view.center; + [self.view addSubview:self.activityIndicator]; + } +} + +- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender +{ + // Keep ref on destinationViewController + [childViewControllers addObject:segue.destinationViewController]; +} + +- (void)presentViewController:(UIViewController *)viewControllerToPresent animated:(BOOL)flag completion:(void (^)(void))completion +{ + // Keep ref on presented view controller + [childViewControllers addObject:viewControllerToPresent]; + + [super presentViewController:viewControllerToPresent animated:flag completion:completion]; +} + +#pragma mark - + +- (void)addMatrixSession:(MXSession*)mxSession +{ + if (!mxSession || mxSession.state == MXSessionStateClosed) + { + return; + } + + if (!mxSessionArray.count) + { + [mxSessionArray addObject:mxSession]; + + // Add matrix sessions observer on first added session + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMatrixSessionStateDidChange:) name:kMXSessionStateDidChangeNotification object:nil]; + } + else if ([mxSessionArray indexOfObject:mxSession] == NSNotFound) + { + [mxSessionArray addObject:mxSession]; + } + + // Force update + [self onMatrixSessionChange]; +} + +- (void)removeMatrixSession:(MXSession*)mxSession +{ + if (!mxSession) + { + return; + } + + NSUInteger index = [mxSessionArray indexOfObject:mxSession]; + if (index != NSNotFound) + { + [mxSessionArray removeObjectAtIndex:index]; + + if (!mxSessionArray.count) + { + // Remove matrix sessions observer + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionStateDidChangeNotification object:nil]; + } + } + + // Force update + [self onMatrixSessionChange]; +} + +- (NSArray*)mxSessions +{ + return [NSArray arrayWithArray:mxSessionArray]; +} + +- (MXSession*)mainSession +{ + // We consider the first added session as the main one. + if (mxSessionArray.count) + { + return [mxSessionArray firstObject]; + } + return nil; +} + +#pragma mark - + +- (void)withdrawViewControllerAnimated:(BOOL)animated completion:(void (^)(void))completion +{ + // Check whether the view controller is embedded inside a navigation controller. + if (self.navigationController) + { + [self popViewController:self navigationController:self.navigationController animated:animated completion:completion]; + } + else + { + // Suppose here the view controller has been presented modally. We dismiss it + [self dismissViewControllerAnimated:animated completion:completion]; + } +} + +- (void)popViewController:(UIViewController*)viewController navigationController:(UINavigationController*)navigationController animated:(BOOL)animated completion:(void (^)(void))completion +{ + // We pop the view controller (except if it is the root view controller). + NSUInteger index = [navigationController.viewControllers indexOfObject:viewController]; + if (index != NSNotFound) + { + if (index > 0) + { + UIViewController *previousViewController = [navigationController.viewControllers objectAtIndex:(index - 1)]; + [navigationController popToViewController:previousViewController animated:animated]; + + if (completion) + { + completion(); + } + } + else + { + // Check whether the navigation controller is embedded inside a navigation controller, to pop it. + if (navigationController.navigationController) + { + [self popViewController:navigationController navigationController:navigationController.navigationController animated:animated completion:completion]; + } + else + { + // Remove the root view controller + navigationController.viewControllers = @[]; + // Suppose here the navigation controller has been presented modally. We dismiss it + [navigationController dismissViewControllerAnimated:animated completion:completion]; + } + } + } +} + +- (void)destroy +{ + // Remove properly keyboard view (remove related key observers) + self.keyboardView = nil; + + // Remove observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + mxSessionArray = nil; + childViewControllers = nil; +} + +#pragma mark - Sessions handling + +- (void)onMatrixSessionStateDidChange:(NSNotification *)notif +{ + MXSession *mxSession = notif.object; + + NSUInteger index = [mxSessionArray indexOfObject:mxSession]; + if (index != NSNotFound) + { + if (mxSession.state == MXSessionStateClosed) + { + // Call here the dedicated method which may be overridden + [self removeMatrixSession:mxSession]; + } + else + { + [self onMatrixSessionChange]; + } + } +} + +- (void)onMatrixSessionChange +{ + // This method is called to refresh view controller appearance on session state change, + // It is called when the view will appear to update session array by removing closed sessions. + // Indeed 'kMXSessionStateDidChangeNotification' are observed only when the view controller is visible. + + // Retrieve the main navigation controller if the current view controller is embedded inside a split view controller. + UINavigationController *mainNavigationController = self.mxk_mainNavigationController; + + if (mxSessionArray.count) + { + // Check each session state. + UIColor *barTintColor = defaultBarTintColor; + BOOL allHomeserverNotReachable = YES; + BOOL isActivityInProgress = NO; + for (NSUInteger index = 0; index < mxSessionArray.count;) + { + MXSession *mxSession = mxSessionArray[index]; + + // Remove here closed sessions + if (mxSession.state == MXSessionStateClosed) + { + // Call here the dedicated method which may be overridden. + // This method will call again [onMatrixSessionChange] when session is removed. + [self removeMatrixSession:mxSession]; + return; + } + else + { + if (mxSession.state == MXSessionStateHomeserverNotReachable) + { + barTintColor = [UIColor orangeColor]; + } + else + { + allHomeserverNotReachable = NO; + isActivityInProgress = mxSession.shouldShowActivityIndicator; + } + + index++; + } + } + + // Check whether the navigation bar color depends on homeserver reachability. + if (enableBarTintColorStatusChange) + { + // The navigation bar tintColor reflects the matrix homeserver reachability status. + if (allHomeserverNotReachable) + { + self.navigationController.navigationBar.barTintColor = [UIColor redColor]; + if (mainNavigationController) + { + mainNavigationController.navigationBar.barTintColor = [UIColor redColor]; + } + } + else + { + self.navigationController.navigationBar.barTintColor = barTintColor; + if (mainNavigationController) + { + mainNavigationController.navigationBar.barTintColor = barTintColor; + } + } + } + + // Run activity indicator if need + if (isActivityInProgress) + { + [self startActivityIndicator]; + } + else + { + [self stopActivityIndicator]; + } + } + else + { + // Hide potential activity indicator + [self stopActivityIndicator]; + + // Check whether the navigation bar color depends on homeserver reachability. + if (enableBarTintColorStatusChange) + { + // Restore default tintColor + self.navigationController.navigationBar.barTintColor = defaultBarTintColor; + if (mainNavigationController) + { + mainNavigationController.navigationBar.barTintColor = defaultBarTintColor; + } + + } + } +} + +#pragma mark - Activity indicator + +- (void)stopActivityIndicator +{ + // Check whether all conditions are satisfied before stopping loading wheel + BOOL isActivityInProgress = NO; + for (MXSession *mxSession in mxSessionArray) + { + if (mxSession.shouldShowActivityIndicator) + { + isActivityInProgress = YES; + } + } + if (!isActivityInProgress) + { + [super stopActivityIndicator]; + } +} + +#pragma mark - Shake handling + +- (void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent *)event +{ + if (motion == UIEventSubtypeMotionShake && self.rageShakeManager) + { + [self.rageShakeManager startShaking:self]; + } +} + +- (void)motionCancelled:(UIEventSubtype)motion withEvent:(UIEvent *)event +{ + [self motionEnded:motion withEvent:event]; +} + +- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event +{ + if (self.rageShakeManager) + { + [self.rageShakeManager stopShaking:self]; + } +} + +- (BOOL)canBecomeFirstResponder +{ + return (self.rageShakeManager != nil); +} + +#pragma mark - Keyboard handling + +- (void)onKeyboardShowAnimationComplete +{ + // Do nothing here - `MXKViewController-inherited` instance must override this method. +} + +- (void)setKeyboardView:(UIView *)keyboardView +{ + // Remove previous keyboardView if any + if (_keyboardView) + { + // Restore UIKeyboardWillShowNotification observer + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onKeyboardWillShow:) name:UIKeyboardWillShowNotification object:nil]; + + // Remove keyboard view observers + [_keyboardView removeObserver:self forKeyPath:NSStringFromSelector(@selector(frame))]; + [_keyboardView removeObserver:self forKeyPath:NSStringFromSelector(@selector(center))]; + + _keyboardView = nil; + } + + if (keyboardView) + { + // Add observers to detect keyboard drag down + [keyboardView addObserver:self forKeyPath:NSStringFromSelector(@selector(frame)) options:0 context:nil]; + [keyboardView addObserver:self forKeyPath:NSStringFromSelector(@selector(center)) options:0 context:nil]; + + // Remove UIKeyboardWillShowNotification observer to ignore this notification until keyboard is dismissed. + // Note: UIKeyboardWillShowNotification may be triggered several times before keyboard is dismissed, + // because the keyboard height is updated (switch to a Chinese keyboard for example). + [[NSNotificationCenter defaultCenter] removeObserver:self name:UIKeyboardWillShowNotification object:nil]; + + _keyboardView = keyboardView; + } +} + +- (void)onKeyboardWillShow:(NSNotification *)notif +{ + MXLogDebug(@"[MXKViewController] %@ onKeyboardWillShow", self.class); + + // Get the keyboard size + NSValue *rectVal = notif.userInfo[UIKeyboardFrameEndUserInfoKey]; + CGRect endRect = rectVal.CGRectValue; + + // IOS 8 triggers some unexpected keyboard events + if ((endRect.size.height == 0) || (endRect.size.width == 0)) + { + return; + } + + // Detect if an external keyboard is used + CGRect keyboard = [self.view convertRect:endRect fromView:self.view.window]; + CGFloat height = self.view.frame.size.height; + BOOL hasExternalKeyboard = keyboard.size.height <= MXKViewControllerMaxExternalKeyboardHeight; + + // Get the animation info + NSNumber *curveValue = [[notif userInfo] objectForKey:UIKeyboardAnimationCurveUserInfoKey]; + UIViewAnimationCurve animationCurve = curveValue.intValue; + // The duration is ignored but it is better to define it + double animationDuration = [[[notif userInfo] objectForKey:UIKeyboardAnimationDurationUserInfoKey] doubleValue]; + + // Apply keyboard animation + [UIView animateWithDuration:animationDuration delay:0 options:UIViewAnimationOptionBeginFromCurrentState | (animationCurve << 16) animations:^{ + if (!hasExternalKeyboard) + { + // Set the new virtual keyboard height by checking screen orientation + self.keyboardHeight = (endRect.origin.y == 0) ? endRect.size.width : endRect.size.height; + } + else + { + // The virtual keyboard is not shown on the screen but its toolbar is still displayed. + // Manage the height of this one + self.keyboardHeight = height - keyboard.origin.y; + } + } completion:^(BOOL finished) + { + [self onKeyboardShowAnimationComplete]; + }]; +} + +- (void)onKeyboardWillHide:(NSNotification *)notif +{ + MXLogDebug(@"[MXKViewController] %@ onKeyboardWillHide", self.class); + + // Remove keyboard view + self.keyboardView = nil; + + // Get the animation info + NSNumber *curveValue = [[notif userInfo] objectForKey:UIKeyboardAnimationCurveUserInfoKey]; + UIViewAnimationCurve animationCurve = curveValue.intValue; + // the duration is ignored but it is better to define it + double animationDuration = [[[notif userInfo] objectForKey:UIKeyboardAnimationDurationUserInfoKey] doubleValue]; + + // Apply keyboard animation + [UIView animateWithDuration:animationDuration delay:0 options:UIViewAnimationOptionBeginFromCurrentState | (animationCurve << 16) animations:^{ + self.keyboardHeight = 0; + } completion:^(BOOL finished) + { + }]; +} + +#pragma mark - KVO + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context +{ + if ((object == _keyboardView) && ([keyPath isEqualToString:NSStringFromSelector(@selector(frame))] || [keyPath isEqualToString:NSStringFromSelector(@selector(center))])) + { + + // The keyboard view has been modified (Maybe the user drag it down), we update the input toolbar bottom constraint to adjust layout. + + // Compute keyboard height (on IOS 8 and later, the screen size is oriented) + CGSize screenSize = [[UIScreen mainScreen] bounds].size; + self.keyboardHeight = screenSize.height - _keyboardView.frame.origin.y; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKViewControllerActivityHandling.h b/Riot/Modules/MatrixKit/Controllers/MXKViewControllerActivityHandling.h new file mode 100644 index 000000000..299071230 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKViewControllerActivityHandling.h @@ -0,0 +1,50 @@ +// +// Copyright 2021 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef MXKViewControllerActivityHandling_h +#define MXKViewControllerActivityHandling_h + +/** + `MXKViewControllerActivityHandling` defines a protocol to handle requirements for + all matrixKit view controllers and table view controllers. + + It manages the following points: + - stop/start activity indicator according to the state of the associated matrix sessions. + */ +@protocol MXKViewControllerActivityHandling + +/** + Activity indicator view. + By default this activity indicator is centered inside the view controller view. It automatically + starts if `shouldShowActivityIndicator `returns true for the session. + It is stopped on other states. + Set nil to disable activity indicator animation. + */ +@property (nonatomic) UIActivityIndicatorView *activityIndicator; + +/** + Bring the activity indicator to the front and start it. + */ +- (void)startActivityIndicator; + +/** + Stop the activity indicator if all conditions are satisfied. + */ +- (void)stopActivityIndicator; + +@end + +#endif /* MXKViewControllerActivityHandling_h */ diff --git a/Riot/Modules/MatrixKit/Controllers/MXKViewControllerHandling.h b/Riot/Modules/MatrixKit/Controllers/MXKViewControllerHandling.h new file mode 100644 index 000000000..ee9143508 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKViewControllerHandling.h @@ -0,0 +1,148 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import + +#import "MXKResponderRageShaking.h" +#import "MXKViewControllerActivityHandling.h" + +/** + `MXKViewControllerHandling` defines a protocol to handle requirements for + all matrixKit view controllers and table view controllers. + + It manages the following points: + - matrix sessions handling, one or more sessions are supported. + - stop/start activity indicator according to the state of the associated matrix sessions. + - update view appearance on matrix session state change. + - support rage shake mechanism (depend on `rageShakeManager` property). + */ +@protocol MXKViewControllerHandling + +/** + The default navigation bar tint color (nil by default). + */ +@property (nonatomic) UIColor *defaultBarTintColor; + +/** + The color of the title in the navigation bar (nil by default). + */ +@property (nonatomic) UIColor *barTitleColor; + +/** + Enable the change of the navigation bar tint color according to the matrix homeserver reachability status (YES by default). + Set NO this property to disable navigation tint color change. + */ +@property (nonatomic) BOOL enableBarTintColorStatusChange; + +/** + List of associated matrix sessions (empty by default). + This property is used to update view appearance according to the session(s) state. + */ +@property (nonatomic, readonly) NSArray* mxSessions; + +/** + The first associated matrix session is considered as the main session (nil by default). + */ +@property (nonatomic, readonly) MXSession *mainSession; + +/** + Keep reference on the pushed and/or presented view controllers. + */ +@property (nonatomic, readonly) NSArray *childViewControllers; + +/** + An object implementing the `MXKResponderRageShaking` protocol. + The view controller uses this object (if any) to report beginning and end of potential + rage shake when it is the first responder. + + This property is nil by default. + */ +@property (nonatomic) id rageShakeManager; + +/** + Called during UIViewController initialization to set the default + properties values (see [initWithNibName:bundle:] and [initWithCoder:]). + + You should not call this method directly. + + Subclasses can override this method as needed to customize the initialization. + */ +- (void)finalizeInit; + +/** + Add a matrix session in the list of associated sessions (see 'mxSessions' property). + + The session is ignored if its state is 'MXSessionStateClosed'. + In other case, the session is stored, and an observer on 'kMXSessionStateDidChangeNotification' is added if it's not already done. + A session is automatically removed when its state returns to 'MXSessionStateClosed'. + + @param mxSession a Matrix session. + */ +- (void)addMatrixSession:(MXSession*)mxSession; + +/** + Remove a matrix session from the list of associated sessions (see 'mxSessions' property). + + Remove the session. The 'kMXSessionStateDidChangeNotification' observer is removed if there is no more matrix session. + + @param mxSession a Matrix session. + */ +- (void)removeMatrixSession:(MXSession*)mxSession; + +/** + The method specified as notification selector during 'kMXSessionStateDidChangeNotification' observer creation. + + By default this method consider ONLY notifications related to associated sessions (see 'mxSessions' property). + A session is automatically removed from the list when its state is 'MXSessionStateClosed'. Else [self onMatrixSessionChange] is called. + + Override it to handle state change on associated sessions AND others. + */ +- (void)onMatrixSessionStateDidChange:(NSNotification *)notif; + +/** + This method is called on the following matrix session changes: + - a new session is added. + - a session is removed. + - the state of an associated session changed (according to `MXSessionStateDidChangeNotification`). + + This method is called to refresh the display when the view controller will appear too. + + By default view controller appearance is updated according to the state of associated sessions: + - starts activity indicator as soon as when `shouldShowActivityIndicator `returns true for the session. + - switches in red the navigation bar tintColor when all sessions are in `MXSessionStateHomeserverNotReachable` state. + - switches in orange the navigation bar tintColor when at least one session is in `MXSessionStateHomeserverNotReachable` state. + + Override it to customize view appearance according to associated session(s). + */ +- (void)onMatrixSessionChange; + +/** + Pop or dismiss the view controller. It depends if the view controller is embedded inside a navigation controller or not. + + @param animated YES to animate the transition. + @param completion the block to execute after the view controller is popped or dismissed. This block has no return value and takes no parameters. You may specify nil for this parameter. + */ +- (void)withdrawViewControllerAnimated:(BOOL)animated completion:(void (^)(void))completion; + +/** + Dispose of any resources, and remove event observers. + */ +- (void)destroy; + +@end + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKWebViewViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKWebViewViewController.h new file mode 100644 index 000000000..30306e024 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKWebViewViewController.h @@ -0,0 +1,69 @@ +/* + Copyright 2016 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKViewController.h" +#import + +/** + 'MXKWebViewViewController' instance is used to display a webview. + */ +@interface MXKWebViewViewController : MXKViewController +{ +@protected + /** + The back button displayed as the right bar button item. + */ + UIBarButtonItem *backButton; + +@public + /** + The content of this screen is fully displayed by this webview + */ + WKWebView *webView; +} + +/** + Init 'MXKWebViewViewController' instance with a web content url. + + @param URL the url to open + */ +- (id)initWithURL:(NSString*)URL; + +/** + Init 'MXKWebViewViewController' instance with a local HTML file path. + + @param localHTMLFile The path of the local HTML file. + */ +- (id)initWithLocalHTMLFile:(NSString*)localHTMLFile; + +/** + Route javascript logs to NSLog. + */ +- (void)enableDebug; + +/** + Define the web content url to open + Don’t use this property to load local HTML files, instead use 'localHTMLFile'. + */ +@property (nonatomic) NSString *URL; + +/** + Define the local HTML file path to load + */ +@property (nonatomic) NSString *localHTMLFile; + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKWebViewViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKWebViewViewController.m new file mode 100644 index 000000000..07c23b452 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKWebViewViewController.m @@ -0,0 +1,349 @@ +/* + Copyright 2016 OpenMarket Ltd + Copyright 2018 New Vector Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKWebViewViewController.h" + +#import "NSBundle+MatrixKit.h" + +#import + +#import "MXKSwiftHeader.h" + +NSString *const kMXKWebViewViewControllerPostMessageJSLog = @"jsLog"; + +// Override console.* logs methods to send WebKit postMessage events to native code. +// Note: this code has a minimal support of multiple parameters in console.log() +NSString *const kMXKWebViewViewControllerJavaScriptEnableLog = +@"console.debug = console.log; console.info = console.log; console.warn = console.log; console.error = console.log;" \ +@"console.log = function() {" \ +@" var msg = arguments[0];" \ +@" for (var i = 1; i < arguments.length; i++) {" \ +@" msg += ' ' + arguments[i];" \ +@" }" \ +@" window.webkit.messageHandlers.%@.postMessage(msg);" \ +@"};"; + +@interface MXKWebViewViewController () +{ + BOOL enableDebug; + + // Right buttons bar state before loading the webview + NSArray *originalRightBarButtonItems; +} + +@end + +@implementation MXKWebViewViewController + +- (instancetype)init +{ + self = [super init]; + if (self) + { + enableDebug = NO; + } + return self; +} + +- (id)initWithURL:(NSString*)URL +{ + self = [self init]; + if (self) + { + _URL = URL; + } + return self; +} + +- (id)initWithLocalHTMLFile:(NSString*)localHTMLFile +{ + self = [self init]; + if (self) + { + _localHTMLFile = localHTMLFile; + } + return self; +} + +- (void)enableDebug +{ + // We can only call addScriptMessageHandler on a given message only once + if (enableDebug) + { + return; + } + enableDebug = YES; + + // Redirect all console.* logging methods into a WebKit postMessage event with name "jsLog" + [webView.configuration.userContentController addScriptMessageHandler:self name:kMXKWebViewViewControllerPostMessageJSLog]; + + NSString *javaScriptString = [NSString stringWithFormat:kMXKWebViewViewControllerJavaScriptEnableLog, kMXKWebViewViewControllerPostMessageJSLog]; + + [webView evaluateJavaScript:javaScriptString completionHandler:nil]; +} + +- (void)finalizeInit +{ + [super finalizeInit]; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + originalRightBarButtonItems = self.navigationItem.rightBarButtonItems; + + // Init the webview + webView = [[WKWebView alloc] initWithFrame:self.view.frame]; + webView.backgroundColor= [UIColor whiteColor]; + webView.navigationDelegate = self; + webView.UIDelegate = self; + + [webView setTranslatesAutoresizingMaskIntoConstraints:NO]; + [self.view addSubview:webView]; + + // Force webview in full width (to handle auto-layout in case of screen rotation) + NSLayoutConstraint *leftConstraint = [NSLayoutConstraint constraintWithItem:webView + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:self.view + attribute:NSLayoutAttributeLeading + multiplier:1.0 + constant:0]; + NSLayoutConstraint *rightConstraint = [NSLayoutConstraint constraintWithItem:webView + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:self.view + attribute:NSLayoutAttributeTrailing + multiplier:1.0 + constant:0]; + // Force webview in full height + NSLayoutConstraint *topConstraint = [NSLayoutConstraint constraintWithItem:webView + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self.topLayoutGuide + attribute:NSLayoutAttributeBottom + multiplier:1.0 + constant:0]; + NSLayoutConstraint *bottomConstraint = [NSLayoutConstraint constraintWithItem:webView + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:self.bottomLayoutGuide + attribute:NSLayoutAttributeTop + multiplier:1.0 + constant:0]; + + [NSLayoutConstraint activateConstraints:@[leftConstraint, rightConstraint, topConstraint, bottomConstraint]]; + + backButton = [[UIBarButtonItem alloc] initWithTitle:[MatrixKitL10n back] style:UIBarButtonItemStylePlain target:self action:@selector(goBack)]; + + if (_URL.length) + { + self.URL = _URL; + } + else if (_localHTMLFile.length) + { + self.localHTMLFile = _localHTMLFile; + } +} + +- (void)destroy +{ + if (webView) + { + webView.navigationDelegate = nil; + [webView stopLoading]; + [webView removeFromSuperview]; + webView = nil; + } + + backButton = nil; + + _URL = nil; + _localHTMLFile = nil; + + [super destroy]; +} + +- (void)dealloc +{ + [self destroy]; +} + +- (void)setURL:(NSString *)URL +{ + [webView stopLoading]; + + _URL = URL; + _localHTMLFile = nil; + + if (URL.length) + { + NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:URL]]; + [webView loadRequest:request]; + } +} + +- (void)setLocalHTMLFile:(NSString *)localHTMLFile +{ + [webView stopLoading]; + + _localHTMLFile = localHTMLFile; + _URL = nil; + + if (localHTMLFile.length) + { + NSString* htmlString = [NSString stringWithContentsOfFile:localHTMLFile encoding:NSUTF8StringEncoding error:nil]; + [webView loadHTMLString:htmlString baseURL:nil]; + } +} + +- (void)goBack +{ + if (webView.canGoBack) + { + [webView goBack]; + } + else if (_localHTMLFile.length) + { + // Reload local html file + self.localHTMLFile = _localHTMLFile; + } +} + +#pragma mark - WKNavigationDelegate + +- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation +{ + // Handle back button visibility here + BOOL canGoBack = webView.canGoBack; + + if (_localHTMLFile.length && !canGoBack) + { + // Check whether the current content is not the local html file + canGoBack = (![webView.URL.absoluteString isEqualToString:@"about:blank"]); + } + + if (canGoBack) + { + self.navigationItem.rightBarButtonItem = backButton; + } + else + { + // Reset the original state + self.navigationItem.rightBarButtonItems = originalRightBarButtonItems; + } +} + +- (void)webView:(WKWebView *)webView didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential * _Nullable credential))completionHandler +{ + NSURLProtectionSpace *protectionSpace = [challenge protectionSpace]; + + // We handle here only the server trust authentication. + // We fallback to the default logic for other cases. + if (protectionSpace.authenticationMethod != NSURLAuthenticationMethodServerTrust || !protectionSpace.serverTrust) + { + completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil); + return; + } + + SecTrustRef serverTrust = [protectionSpace serverTrust]; + + // Check first whether there are some pinned certificates (certificate included in the bundle). + NSArray *paths = [[NSBundle mainBundle] pathsForResourcesOfType:@"cer" inDirectory:@"."]; + if (paths.count) + { + NSMutableArray *pinnedCertificates = [NSMutableArray array]; + for (NSString *path in paths) + { + NSData *certificateData = [NSData dataWithContentsOfFile:path]; + [pinnedCertificates addObject:(__bridge_transfer id)SecCertificateCreateWithData(NULL, (__bridge CFDataRef)certificateData)]; + } + // Only use these certificates to pin against, and do not trust the built-in anchor certificates. + SecTrustSetAnchorCertificates(serverTrust, (__bridge CFArrayRef)pinnedCertificates); + } + else + { + // Check whether some certificates have been trusted by the user (self-signed certificates support). + NSSet *certificates = [MXAllowedCertificates sharedInstance].certificates; + if (certificates.count) + { + NSMutableArray *allowedCertificates = [NSMutableArray array]; + for (NSData *certificateData in certificates) + { + [allowedCertificates addObject:(__bridge_transfer id)SecCertificateCreateWithData(NULL, (__bridge CFDataRef)certificateData)]; + } + // Add all the allowed certificates to the chain of trust + SecTrustSetAnchorCertificates(serverTrust, (__bridge CFArrayRef)allowedCertificates); + // Reenable trusting the built-in anchor certificates in addition to those passed in via the SecTrustSetAnchorCertificates API. + SecTrustSetAnchorCertificatesOnly(serverTrust, false); + } + } + + // Re-evaluate the trust policy + SecTrustResultType secresult = kSecTrustResultInvalid; + if (SecTrustEvaluate(serverTrust, &secresult) != errSecSuccess) + { + // Reject the server auth if an error occurs + completionHandler(NSURLSessionAuthChallengeRejectProtectionSpace, nil); + } + else + { + switch (secresult) + { + case kSecTrustResultUnspecified: // The OS trusts this certificate implicitly. + case kSecTrustResultProceed: // The user explicitly told the OS to trust it. + { + NSURLCredential *credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust]; + completionHandler(NSURLSessionAuthChallengeUseCredential, credential); + break; + } + + default: + { + completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil); + break; + } + } + } +} + +#pragma mark - WKUIDelegate + +- (WKWebView *)webView:(WKWebView *)webView createWebViewWithConfiguration:(nonnull WKWebViewConfiguration *)configuration forNavigationAction:(nonnull WKNavigationAction *)navigationAction windowFeatures:(nonnull WKWindowFeatures *)windowFeatures +{ + // Make sure we open links with `target="_blank"` within this webview + if (!navigationAction.targetFrame.isMainFrame) + { + [webView loadRequest:navigationAction.request]; + } + + return nil; +} + +#pragma mark - WKScriptMessageHandler + +- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message +{ + if ([message.name isEqualToString:kMXKWebViewViewControllerPostMessageJSLog]) + { + MXLogDebug(@"-- JavaScript: %@", message.body); + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Libs/SwiftUTI/LICENSE b/Riot/Modules/MatrixKit/Libs/SwiftUTI/LICENSE new file mode 100644 index 000000000..a0bf476a7 --- /dev/null +++ b/Riot/Modules/MatrixKit/Libs/SwiftUTI/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Libs/SwiftUTI/SWIFT_UTI_README.md b/Riot/Modules/MatrixKit/Libs/SwiftUTI/SWIFT_UTI_README.md new file mode 100644 index 000000000..d23bf7763 --- /dev/null +++ b/Riot/Modules/MatrixKit/Libs/SwiftUTI/SWIFT_UTI_README.md @@ -0,0 +1,4 @@ +Original source: +https://github.com/mkeiser/SwiftUTI + +The pod of this library is no more maintained. At the time of writing this README, the official pod version is 1.0.6 whereas the last release of the library is 2.0.3. This last release has a podspec that points to 2.0.2. diff --git a/Riot/Modules/MatrixKit/Libs/SwiftUTI/UTI.swift b/Riot/Modules/MatrixKit/Libs/SwiftUTI/UTI.swift new file mode 100644 index 000000000..c3d66c481 --- /dev/null +++ b/Riot/Modules/MatrixKit/Libs/SwiftUTI/UTI.swift @@ -0,0 +1,517 @@ +// +// UTI.swift +// fseventstool +// +// Created by Matthias Keiser on 09.01.17. +// Copyright © 2017 Tristan Inc. All rights reserved. +// + +import Foundation + +#if os(iOS) || os(watchOS) + import MobileCoreServices +#elseif os(macOS) + import CoreServices +#endif + +/// Instances of the UTI class represent a specific Universal Type Identifier, e.g. kUTTypeMPEG4. + +public class UTI: RawRepresentable, Equatable { + + /** + The TagClass enum represents the supported tag classes. + + - fileExtension: kUTTagClassFilenameExtension + - mimeType: kUTTagClassMIMEType + - pbType: kUTTagClassNSPboardType + - osType: kUTTagClassOSType + */ + public enum TagClass: String { + + /// Equivalent to kUTTagClassFilenameExtension + case fileExtension = "public.filename-extension" + + /// Equivalent to kUTTagClassMIMEType + case mimeType = "public.mime-type" + + #if os (macOS) + + /// Equivalent to kUTTagClassNSPboardType + case pbType = "com.apple.nspboard-type" + + /// Equivalent to kUTTagClassOSType + case osType = "com.apple.ostype" + #endif + + /// Convenience variable for internal use. + + fileprivate var rawCFValue: CFString { + return self.rawValue as CFString + } + } + + public typealias RawValue = String + public let rawValue: String + + + /// Convenience variable for internal use. + + private var rawCFValue: CFString { + + return self.rawValue as CFString + } + + // MARK: Initialization + + + /** + + This is the designated initializer of the UTI class. + + - Parameters: + - rawValue: A string that is a Universal Type Identifier, i.e. "com.foobar.baz" or a constant like kUTTypeMP3. + - Returns: + An UTI instance representing the specified rawValue. + - Note: + You should rarely use this method. The preferred way to initialize a known UTI is to use its static variable (i.e. UTI.pdf). You should make an extension to make your own types available as static variables. + + */ + + public required init(rawValue: UTI.RawValue) { + + self.rawValue = rawValue + } + + /** + + Initialize an UTI with a tag of a specified class. + + - Parameters: + - tagClass: The class of the tag. + - value: The value of the tag. + - conformingTo: If specified, the returned UTI must conform to this UTI. If nil is specified, this parameter is ignored. The default is nil. + - Returns: + An UTI instance representing the specified rawValue. If no known UTI with the specified tags is found, a dynamic UTI is created. + - Note: + You should rarely need this method. It's usually simpler to use one of the specialized initialzers like + ```convenience init?(withExtension fileExtension: String, conformingTo conforming: UTI? = nil)``` + */ + + public convenience init(withTagClass tagClass: TagClass, value: String, conformingTo conforming: UTI? = nil) { + + let unmanagedIdentifier = UTTypeCreatePreferredIdentifierForTag(tagClass.rawCFValue, value as CFString, conforming?.rawCFValue) + + // UTTypeCreatePreferredIdentifierForTag only returns nil if the tag class is unknwown, which can't happen to us since we use an + // enum of known values. Hence we can force-cast the result. + + let identifier = (unmanagedIdentifier?.takeRetainedValue() as String?)! + + self.init(rawValue: identifier) + } + + /** + + Initialize an UTI with a file extension. + + - Parameters: + - withExtension: The file extension (e.g. "txt"). + - conformingTo: If specified, the returned UTI must conform to this UTI. If nil is specified, this parameter is ignored. The default is nil. + - Returns: + An UTI corresponding to the specified values. + **/ + + public convenience init(withExtension fileExtension: String, conformingTo conforming: UTI? = nil) { + + self.init(withTagClass:.fileExtension, value: fileExtension, conformingTo: conforming) + } + + /** + + Initialize an UTI with a MIME type. + + - Parameters: + - mimeType: The MIME type (e.g. "text/plain"). + - conformingTo: If specified, the returned UTI must conform to this UTI. If nil is specified, this parameter is ignored. The default is nil. + - Returns: + An UTI corresponding to the specified values. + */ + + public convenience init(withMimeType mimeType: String, conformingTo conforming: UTI? = nil) { + + self.init(withTagClass:.mimeType, value: mimeType, conformingTo: conforming) + } + + #if os(macOS) + + /** + + Initialize an UTI with a pasteboard type. + - Important: **This function is de-facto deprecated!** The old cocoa pasteboard types ( `NSStringPboardType`, `NSPDFPboardType`, etc) have been deprecated in favour of actual UTIs, and the constants are not available anymore in Swift. This function only works correctly with the values of these old constants, but _not_ with the replacement values (like `NSPasteboardTypeString` etc), since these already are UTIs. + - Parameters: + - pbType: The pasteboard type (e.g. NSPDFPboardType). + - conformingTo: If specified, the returned UTI must conform to this UTI. If nil is specified, this parameter is ignored. The default is nil. + - Returns: + An UTI corresponding to the specified values. + */ + public convenience init(withPBType pbType: String, conformingTo conforming: UTI? = nil) { + + self.init(withTagClass:.pbType, value: pbType, conformingTo: conforming) + } + + /** + Initialize an UTI with a OSType. + + - Parameters: + - osType: The OSType type as a string (e.g. "PDF "). + - conformingTo: If specified, the returned UTI must conform to this UTI. If nil is specified, this parameter is ignored. The default is nil. + - Returns: + An UTI corresponding to the specified values. + - Note: + You can use the variable ```OSType.string``` to get a string from an actual OSType. + */ + + public convenience init(withOSType osType: String, conformingTo conforming: UTI? = nil) { + + self.init(withTagClass:.osType, value: osType, conformingTo: conforming) + } + + #endif + + // MARK: Accessing Tags + + /** + + Returns the tag with the specified class. + + - Parameters: + - tagClass: The tag class to return. + - Returns: + The requested tag, or nil if there is no tag of the specified class. + */ + + public func tag(with tagClass: TagClass) -> String? { + + let unmanagedTag = UTTypeCopyPreferredTagWithClass(self.rawCFValue, tagClass.rawCFValue) + + guard let tag = unmanagedTag?.takeRetainedValue() as String? else { + return nil + } + + return tag + } + + /// Return the file extension that corresponds the the UTI. Returns nil if not available. + + public var fileExtension: String? { + + return self.tag(with: .fileExtension) + } + + /// Return the MIME type that corresponds the the UTI. Returns nil if not available. + + public var mimeType: String? { + + return self.tag(with: .mimeType) + } + + #if os(macOS) + + /// Return the pasteboard type that corresponds the the UTI. Returns nil if not available. + + public var pbType: String? { + + return self.tag(with: .pbType) + } + + /// Return the OSType as a string that corresponds the the UTI. Returns nil if not available. + /// - Note: you can use the ```init(with string: String)``` initializer to construct an actual OSType from the returnes string. + + public var osType: String? { + + return self.tag(with: .osType) + } + + #endif + + /** + + Returns all tags of the specified tag class. + + - Parameters: + - tagClass: The class of the requested tags. + - Returns: + An array of all tags of the receiver of the specified class. + */ + + public func tags(with tagClass: TagClass) -> Array { + + let unmanagedTags = UTTypeCopyAllTagsWithClass(self.rawCFValue, tagClass.rawCFValue) + + guard let tags = unmanagedTags?.takeRetainedValue() as? Array else { + return [] + } + + return tags as Array + } + + // MARK: List all UTIs associated with a tag + + + /** + Returns all UTIs that are associated with a specified tag. + + - Parameters: + - tag: The class of the specified tag. + - value: The value of the tag. + - conforming: If specified, the returned UTIs must conform to this UTI. If nil is specified, this parameter is ignored. The default is nil. + - Returns: + An array of all UTIs that satisfy the specified parameters. + */ + + public static func utis(for tag: TagClass, value: String, conformingTo conforming: UTI? = nil) -> Array { + + let unmanagedIdentifiers = UTTypeCreateAllIdentifiersForTag(tag.rawCFValue, value as CFString, conforming?.rawCFValue) + + + guard let identifiers = unmanagedIdentifiers?.takeRetainedValue() as? Array else { + return [] + } + + return identifiers.compactMap { UTI(rawValue: $0 as String) } + } + + // MARK: Equality and Conformance to other UTIs + + /** + + Checks if the receiver conforms to a specified UTI. + + - Parameters: + - otherUTI: The UTI to which the receiver is compared. + - Returns: + ```true``` if the receiver conforms to the specified UTI, ```false```otherwise. + */ + + public func conforms(to otherUTI: UTI) -> Bool { + + return UTTypeConformsTo(self.rawCFValue, otherUTI.rawCFValue) as Bool + } + + public static func ==(lhs: UTI, rhs: UTI) -> Bool { + + return UTTypeEqual(lhs.rawCFValue, rhs.rawCFValue) as Bool + } + + // MARK: Accessing Information about an UTI + + /// Returns the localized, user-readable type description string associated with a uniform type identifier. + + public var description: String? { + + let unmanagedDescription = UTTypeCopyDescription(self.rawCFValue) + + guard let description = unmanagedDescription?.takeRetainedValue() as String? else { + return nil + } + + return description + } + + /// Returns a uniform type’s declaration as a Dictionary, or nil if if no declaration for that type can be found. + + public var declaration: [AnyHashable:Any]? { + + let unmanagedDeclaration = UTTypeCopyDeclaration(self.rawCFValue) + + guard let declaration = unmanagedDeclaration?.takeRetainedValue() as? [AnyHashable:Any] else { + return nil + } + + return declaration + } + + /// Returns the location of a bundle containing the declaration for a type, or nil if the bundle could not be located. + + public var declaringBundleURL: URL? { + + let unmanagedURL = UTTypeCopyDeclaringBundleURL(self.rawCFValue) + + guard let url = unmanagedURL?.takeRetainedValue() as URL? else { + return nil + } + + return url + } + + /// Returns ```true``` if the receiver is a dynamic UTI. + + public var isDynamic: Bool { + + return UTTypeIsDynamic(self.rawCFValue) + } +} + + +// MARK: System defined UTIs + +public extension UTI { + + static let item = UTI(rawValue: kUTTypeItem as String) + static let content = UTI(rawValue: kUTTypeContent as String) + static let compositeContent = UTI(rawValue: kUTTypeCompositeContent as String) + static let message = UTI(rawValue: kUTTypeMessage as String) + static let contact = UTI(rawValue: kUTTypeContact as String) + static let archive = UTI(rawValue: kUTTypeArchive as String) + static let diskImage = UTI(rawValue: kUTTypeDiskImage as String) + static let data = UTI(rawValue: kUTTypeData as String) + static let directory = UTI(rawValue: kUTTypeDirectory as String) + static let resolvable = UTI(rawValue: kUTTypeResolvable as String) + static let symLink = UTI(rawValue: kUTTypeSymLink as String) + static let executable = UTI(rawValue: kUTTypeExecutable as String) + static let mountPoint = UTI(rawValue: kUTTypeMountPoint as String) + static let aliasFile = UTI(rawValue: kUTTypeAliasFile as String) + static let aliasRecord = UTI(rawValue: kUTTypeAliasRecord as String) + static let urlBookmarkData = UTI(rawValue: kUTTypeURLBookmarkData as String) + static let url = UTI(rawValue: kUTTypeURL as String) + static let fileURL = UTI(rawValue: kUTTypeFileURL as String) + static let text = UTI(rawValue: kUTTypeText as String) + static let plainText = UTI(rawValue: kUTTypePlainText as String) + static let utf8PlainText = UTI(rawValue: kUTTypeUTF8PlainText as String) + static let utf16ExternalPlainText = UTI(rawValue: kUTTypeUTF16ExternalPlainText as String) + static let utf16PlainText = UTI(rawValue: kUTTypeUTF16PlainText as String) + static let delimitedText = UTI(rawValue: kUTTypeDelimitedText as String) + static let commaSeparatedText = UTI(rawValue: kUTTypeCommaSeparatedText as String) + static let tabSeparatedText = UTI(rawValue: kUTTypeTabSeparatedText as String) + static let utf8TabSeparatedText = UTI(rawValue: kUTTypeUTF8TabSeparatedText as String) + static let rtf = UTI(rawValue: kUTTypeRTF as String) + static let html = UTI(rawValue: kUTTypeHTML as String) + static let xml = UTI(rawValue: kUTTypeXML as String) + static let sourceCode = UTI(rawValue: kUTTypeSourceCode as String) + static let assemblyLanguageSource = UTI(rawValue: kUTTypeAssemblyLanguageSource as String) + static let cSource = UTI(rawValue: kUTTypeCSource as String) + static let objectiveCSource = UTI(rawValue: kUTTypeObjectiveCSource as String) + @available( OSX 10.11, iOS 9.0, * ) + static let swiftSource = UTI(rawValue: kUTTypeSwiftSource as String) + static let cPlusPlusSource = UTI(rawValue: kUTTypeCPlusPlusSource as String) + static let objectiveCPlusPlusSource = UTI(rawValue: kUTTypeObjectiveCPlusPlusSource as String) + static let cHeader = UTI(rawValue: kUTTypeCHeader as String) + static let cPlusPlusHeader = UTI(rawValue: kUTTypeCPlusPlusHeader as String) + static let javaSource = UTI(rawValue: kUTTypeJavaSource as String) + static let script = UTI(rawValue: kUTTypeScript as String) + static let appleScript = UTI(rawValue: kUTTypeAppleScript as String) + static let osaScript = UTI(rawValue: kUTTypeOSAScript as String) + static let osaScriptBundle = UTI(rawValue: kUTTypeOSAScriptBundle as String) + static let javaScript = UTI(rawValue: kUTTypeJavaScript as String) + static let shellScript = UTI(rawValue: kUTTypeShellScript as String) + static let perlScript = UTI(rawValue: kUTTypePerlScript as String) + static let pythonScript = UTI(rawValue: kUTTypePythonScript as String) + static let rubyScript = UTI(rawValue: kUTTypeRubyScript as String) + static let phpScript = UTI(rawValue: kUTTypePHPScript as String) + static let json = UTI(rawValue: kUTTypeJSON as String) + static let propertyList = UTI(rawValue: kUTTypePropertyList as String) + static let xmlPropertyList = UTI(rawValue: kUTTypeXMLPropertyList as String) + static let binaryPropertyList = UTI(rawValue: kUTTypeBinaryPropertyList as String) + static let pdf = UTI(rawValue: kUTTypePDF as String) + static let rtfd = UTI(rawValue: kUTTypeRTFD as String) + static let flatRTFD = UTI(rawValue: kUTTypeFlatRTFD as String) + static let txnTextAndMultimediaData = UTI(rawValue: kUTTypeTXNTextAndMultimediaData as String) + static let webArchive = UTI(rawValue: kUTTypeWebArchive as String) + static let image = UTI(rawValue: kUTTypeImage as String) + static let jpeg = UTI(rawValue: kUTTypeJPEG as String) + static let jpeg2000 = UTI(rawValue: kUTTypeJPEG2000 as String) + static let tiff = UTI(rawValue: kUTTypeTIFF as String) + static let pict = UTI(rawValue: kUTTypePICT as String) + static let gif = UTI(rawValue: kUTTypeGIF as String) + static let png = UTI(rawValue: kUTTypePNG as String) + static let quickTimeImage = UTI(rawValue: kUTTypeQuickTimeImage as String) + static let appleICNS = UTI(rawValue: kUTTypeAppleICNS as String) + static let bmp = UTI(rawValue: kUTTypeBMP as String) + static let ico = UTI(rawValue: kUTTypeICO as String) + static let rawImage = UTI(rawValue: kUTTypeRawImage as String) + static let scalableVectorGraphics = UTI(rawValue: kUTTypeScalableVectorGraphics as String) + @available(OSX 10.12, iOS 9.1, watchOS 2.1, *) + static let livePhoto = UTI(rawValue: kUTTypeLivePhoto as String) + @available(OSX 10.12, iOS 9.1, *) + static let audiovisualContent = UTI(rawValue: kUTTypeAudiovisualContent as String) + static let movie = UTI(rawValue: kUTTypeMovie as String) + static let video = UTI(rawValue: kUTTypeVideo as String) + static let audio = UTI(rawValue: kUTTypeAudio as String) + static let quickTimeMovie = UTI(rawValue: kUTTypeQuickTimeMovie as String) + static let mpeg = UTI(rawValue: kUTTypeMPEG as String) + static let mpeg2Video = UTI(rawValue: kUTTypeMPEG2Video as String) + static let mpeg2TransportStream = UTI(rawValue: kUTTypeMPEG2TransportStream as String) + static let mp3 = UTI(rawValue: kUTTypeMP3 as String) + static let mpeg4 = UTI(rawValue: kUTTypeMPEG4 as String) + static let mpeg4Audio = UTI(rawValue: kUTTypeMPEG4Audio as String) + static let appleProtectedMPEG4Audio = UTI(rawValue: kUTTypeAppleProtectedMPEG4Audio as String) + static let appleProtectedMPEG4Video = UTI(rawValue: kUTTypeAppleProtectedMPEG4Video as String) + static let aviMovie = UTI(rawValue: kUTTypeAVIMovie as String) + static let audioInterchangeFileFormat = UTI(rawValue: kUTTypeAudioInterchangeFileFormat as String) + static let waveformAudio = UTI(rawValue: kUTTypeWaveformAudio as String) + static let midiAudio = UTI(rawValue: kUTTypeMIDIAudio as String) + static let playlist = UTI(rawValue: kUTTypePlaylist as String) + static let m3UPlaylist = UTI(rawValue: kUTTypeM3UPlaylist as String) + static let folder = UTI(rawValue: kUTTypeFolder as String) + static let volume = UTI(rawValue: kUTTypeVolume as String) + static let package = UTI(rawValue: kUTTypePackage as String) + static let bundle = UTI(rawValue: kUTTypeBundle as String) + static let pluginBundle = UTI(rawValue: kUTTypePluginBundle as String) + static let spotlightImporter = UTI(rawValue: kUTTypeSpotlightImporter as String) + static let quickLookGenerator = UTI(rawValue: kUTTypeQuickLookGenerator as String) + static let xpcService = UTI(rawValue: kUTTypeXPCService as String) + static let framework = UTI(rawValue: kUTTypeFramework as String) + static let application = UTI(rawValue: kUTTypeApplication as String) + static let applicationBundle = UTI(rawValue: kUTTypeApplicationBundle as String) + static let applicationFile = UTI(rawValue: kUTTypeApplicationFile as String) + static let unixExecutable = UTI(rawValue: kUTTypeUnixExecutable as String) + static let windowsExecutable = UTI(rawValue: kUTTypeWindowsExecutable as String) + static let javaClass = UTI(rawValue: kUTTypeJavaClass as String) + static let javaArchive = UTI(rawValue: kUTTypeJavaArchive as String) + static let systemPreferencesPane = UTI(rawValue: kUTTypeSystemPreferencesPane as String) + static let gnuZipArchive = UTI(rawValue: kUTTypeGNUZipArchive as String) + static let bzip2Archive = UTI(rawValue: kUTTypeBzip2Archive as String) + static let zipArchive = UTI(rawValue: kUTTypeZipArchive as String) + static let spreadsheet = UTI(rawValue: kUTTypeSpreadsheet as String) + static let presentation = UTI(rawValue: kUTTypePresentation as String) + static let database = UTI(rawValue: kUTTypeDatabase as String) + static let vCard = UTI(rawValue: kUTTypeVCard as String) + static let toDoItem = UTI(rawValue: kUTTypeToDoItem as String) + static let calendarEvent = UTI(rawValue: kUTTypeCalendarEvent as String) + static let emailMessage = UTI(rawValue: kUTTypeEmailMessage as String) + static let internetLocation = UTI(rawValue: kUTTypeInternetLocation as String) + static let inkText = UTI(rawValue: kUTTypeInkText as String) + static let font = UTI(rawValue: kUTTypeFont as String) + static let bookmark = UTI(rawValue: kUTTypeBookmark as String) + static let _3DContent = UTI(rawValue: kUTType3DContent as String) + static let pkcs12 = UTI(rawValue: kUTTypePKCS12 as String) + static let x509Certificate = UTI(rawValue: kUTTypeX509Certificate as String) + static let electronicPublication = UTI(rawValue: kUTTypeElectronicPublication as String) + static let log = UTI(rawValue: kUTTypeLog as String) +} + +#if os(OSX) + + extension OSType { + + + /// Returns the OSType encoded as a String. + + var string: String { + + let unmanagedString = UTCreateStringForOSType(self) + + return unmanagedString.takeRetainedValue() as String + } + + + /// Initializes a OSType from a String. + /// + /// - Parameter string: A String representing an OSType. + + init(with string: String) { + + self = UTGetOSTypeFromString(string as CFString) + } + } + +#endif diff --git a/Riot/Modules/MatrixKit/MatrixKit.h b/Riot/Modules/MatrixKit/MatrixKit.h new file mode 100644 index 000000000..b6e5806f4 --- /dev/null +++ b/Riot/Modules/MatrixKit/MatrixKit.h @@ -0,0 +1,155 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKConstants.h" + +#import "MXKAppSettings.h" + +#import "MXAggregatedReactions+MatrixKit.h" +#import "MXEvent+MatrixKit.h" +#import "MXRoom+Sync.h" +#import "NSBundle+MatrixKit.h" +#import "NSBundle+MXKLanguage.h" +#import "UIAlertController+MatrixKit.h" +#import "UIViewController+MatrixKit.h" + +#import "MXKEventFormatter.h" + +#import "MXKTools.h" + +#import "MXKErrorPresentation.h" +#import "MXKErrorPresentable.h" +#import "MXKErrorViewModel.h" +#import "MXKErrorPresentableBuilder.h" +#import "MXKErrorAlertPresentation.h" + +#import "MXKViewController.h" +#import "MXKRoomViewController.h" +#import "MXKRecentListViewController.h" +#import "MXKRoomMemberListViewController.h" +#import "MXKSearchViewController.h" +#import "MXKCallViewController.h" +#import "MXKContactListViewController.h" +#import "MXKAccountDetailsViewController.h" +#import "MXKContactDetailsViewController.h" +#import "MXKRoomMemberDetailsViewController.h" +#import "MXKNotificationSettingsViewController.h" +#import "MXKAttachmentsViewController.h" +#import "MXKRoomSettingsViewController.h" +#import "MXKWebViewViewController.h" + +#import "MXKAuthenticationViewController.h" +#import "MXKAuthInputsPasswordBasedView.h" +#import "MXKAuthInputsEmailCodeBasedView.h" +#import "MXKAuthenticationFallbackWebView.h" +#import "MXKAuthenticationRecaptchaWebView.h" + +#import "MXKView.h" + +#import "MXKRoomCreationInputs.h" + +#import "MXKInterleavedRecentsDataSource.h" + +#import "MXKRoomCreationView.h" + +#import "MXKRoomInputToolbarView.h" +#import "MXKRoomInputToolbarViewWithHPGrowingText.h" + +#import "MXKRoomDataSourceManager.h" + +#import "MXKRoomBubbleCellData.h" +#import "MXKRoomBubbleCellDataWithAppendingMode.h" + +#import "MXKAttachment.h" + +#import "MXKRecentTableViewCell.h" +#import "MXKInterleavedRecentTableViewCell.h" + +#import "MXKPublicRoomTableViewCell.h" + +#import "MXKDirectoryServersDataSource.h" +#import "MXKDirectoryServerCellDataStoring.h" +#import "MXKDirectoryServerCellData.h" + +#import "MXKRoomMemberTableViewCell.h" +#import "MXKAccountTableViewCell.h" +#import "MXKReadReceiptTableViewCell.h" +#import "MXKPushRuleTableViewCell.h" +#import "MXKPushRuleCreationTableViewCell.h" + +#import "MXKTableViewCellWithButton.h" +#import "MXKTableViewCellWithButtons.h" +#import "MXKTableViewCellWithLabelAndButton.h" +#import "MXKTableViewCellWithLabelAndImageView.h" +#import "MXKTableViewCellWithLabelAndMXKImageView.h" +#import "MXKTableViewCellWithLabelAndSlider.h" +#import "MXKTableViewCellWithLabelAndSubLabel.h" +#import "MXKTableViewCellWithLabelAndSwitch.h" +#import "MXKTableViewCellWithLabelAndTextField.h" +#import "MXKTableViewCellWithLabelTextFieldAndButton.h" +#import "MXKTableViewCellWithPicker.h" +#import "MXKTableViewCellWithSearchBar.h" +#import "MXKTableViewCellWithTextFieldAndButton.h" +#import "MXKTableViewCellWithTextView.h" + +#import "MXKTableViewHeaderFooterWithLabel.h" + +#import "MXKMediaCollectionViewCell.h" +#import "MXKPieChartView.h" +#import "MXKPieChartHUD.h" + +#import "MXKRoomTitleView.h" +#import "MXKRoomTitleViewWithTopic.h" + +#import "MXKRoomEmptyBubbleTableViewCell.h" + +#import "MXKRoomIncomingBubbleTableViewCell.h" +#import "MXKRoomIncomingTextMsgBubbleCell.h" +#import "MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.h" +#import "MXKRoomIncomingAttachmentBubbleCell.h" +#import "MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.h" + +#import "MXKRoomOutgoingBubbleTableViewCell.h" +#import "MXKRoomOutgoingTextMsgBubbleCell.h" +#import "MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.h" +#import "MXKRoomOutgoingAttachmentBubbleCell.h" +#import "MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.h" + +#import "MXKSearchCellData.h" +#import "MXKSearchTableViewCell.h" + +#import "MXKAccountManager.h" + +#import "MXKContactManager.h" + +#import "MXK3PID.h" + +#import "MXKDeviceView.h" +#import "MXKEncryptionInfoView.h" +#import "MXKEncryptionKeysExportView.h" + +#import "MXKCountryPickerViewController.h" +#import "MXKLanguagePickerViewController.h" + +#import "MXKGroupCellData.h" +#import "MXKSessionGroupsDataSource.h" +#import "MXKGroupListViewController.h" +#import "MXKGroupTableViewCell.h" + +#import "MXKSlashCommands.h" diff --git a/Riot/Modules/MatrixKit/MatrixKitVersion.m b/Riot/Modules/MatrixKit/MatrixKitVersion.m new file mode 100644 index 000000000..1ba29cf68 --- /dev/null +++ b/Riot/Modules/MatrixKit/MatrixKitVersion.m @@ -0,0 +1,19 @@ +/* + Copyright 2020 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +NSString *const MatrixKitVersion = @"0.16.10"; diff --git a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.h b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.h new file mode 100644 index 000000000..8d21f6e72 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.h @@ -0,0 +1,435 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +@class MXKAccount; + +/** + Posted when account user information (display name, picture, presence) has been updated. + The notification object is the matrix user id of the account. + */ +extern NSString *const kMXKAccountUserInfoDidChangeNotification; + +/** + Posted when the activity of the Apple Push Notification Service has been changed. + The notification object is the matrix user id of the account. + */ +extern NSString *const kMXKAccountAPNSActivityDidChangeNotification; + +/** + Posted when the activity of the Push notification based on PushKit has been changed. + The notification object is the matrix user id of the account. + */ +extern NSString *const kMXKAccountPushKitActivityDidChangeNotification; + +/** + MXKAccount error domain + */ +extern NSString *const kMXKAccountErrorDomain; + +/** + Block called when a certificate change is observed during authentication challenge from a server. + + @param mxAccount the account concerned by this certificate change. + @param certificate the server certificate to evaluate. + @return YES to accept/trust this certificate, NO to cancel/ignore it. + */ +typedef BOOL (^MXKAccountOnCertificateChange)(MXKAccount *mxAccount, NSData *certificate); + +/** + `MXKAccount` object contains the credentials of a logged matrix user. It is used to handle matrix + session and presence for this user. + */ +@interface MXKAccount : NSObject + +/** + The account's credentials: homeserver, access token, user id. + */ +@property (nonatomic, readonly) MXCredentials *mxCredentials; + +/** + The identity server URL. + */ +@property (nonatomic) NSString *identityServerURL; + +/** + The antivirus server URL, if any (nil by default). + Set a non-null url to configure the antivirus scanner use. + */ +@property (nonatomic) NSString *antivirusServerURL; + +/** + The Push Gateway URL used to send event notifications to (nil by default). + This URL should be over HTTPS and never over HTTP. + */ +@property (nonatomic) NSString *pushGatewayURL; + +/** + The matrix REST client used to make matrix API requests. + */ +@property (nonatomic, readonly) MXRestClient *mxRestClient; + +/** + The matrix session opened with the account (nil by default). + */ +@property (nonatomic, readonly) MXSession *mxSession; + +/** + The account user's display name (nil by default, available if matrix session `mxSession` is opened). + The notification `kMXKAccountUserInfoDidChangeNotification` is posted in case of change of this property. + */ +@property (nonatomic, readonly) NSString *userDisplayName; + +/** + The account user's avatar url (nil by default, available if matrix session `mxSession` is opened). + The notification `kMXKAccountUserInfoDidChangeNotification` is posted in case of change of this property. + */ +@property (nonatomic, readonly) NSString *userAvatarUrl; + +/** + The account display name based on user id and user displayname (if any). + */ +@property (nonatomic, readonly) NSString *fullDisplayName; + +/** + The 3PIDs linked to this account. + [self load3PIDs] must be called to update the property. + */ +@property (nonatomic, readonly) NSArray *threePIDs; + +/** + The email addresses linked to this account. + This is a subset of self.threePIDs. + */ +@property (nonatomic, readonly) NSArray *linkedEmails; + +/** + The phone numbers linked to this account. + This is a subset of self.threePIDs. + */ +@property (nonatomic, readonly) NSArray *linkedPhoneNumbers; + +/** + The account user's device. + [self loadDeviceInformation] must be called to update the property. + */ +@property (nonatomic, readonly) MXDevice *device; + +/** + The account user's presence (`MXPresenceUnknown` by default, available if matrix session `mxSession` is opened). + The notification `kMXKAccountUserInfoDidChangeNotification` is posted in case of change of this property. + */ +@property (nonatomic, readonly) MXPresence userPresence; + +/** + The account user's tint color: a unique color fixed by the user id. This tint color may be used to highlight + rooms which belong to this account's user. + */ +@property (nonatomic, readonly) UIColor *userTintColor; + +/** + The Apple Push Notification Service activity for this account. YES when APNS is turned on (locally available and synced with server). + */ +@property (nonatomic, readonly) BOOL pushNotificationServiceIsActive; + +/** + Transient information storage. + */ +@property (nonatomic, strong, readonly) NSMutableDictionary> *others; + +/** + Enable Push notification based on Apple Push Notification Service (APNS). + + This method creates or removes a pusher on the homeserver. + + @param enable YES to enable it. + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)enablePushNotifications:(BOOL)enable + success:(void (^)(void))success + failure:(void (^)(NSError *))failure; + +/** + Flag to indicate that an APNS pusher has been set on the homeserver for this device. + */ +@property (nonatomic, readonly) BOOL hasPusherForPushNotifications; + +/** + The Push notification activity (based on PushKit) for this account. + YES when Push is turned on (locally available and enabled homeserver side). + */ +@property (nonatomic, readonly) BOOL isPushKitNotificationActive; + +/** + Enable Push notification based on PushKit. + + This method creates or removes a pusher on the homeserver. + + @param enable YES to enable it. + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)enablePushKitNotifications:(BOOL)enable + success:(void (^)(void))success + failure:(void (^)(NSError *))failure; + +/** + Flag to indicate that a PushKit pusher has been set on the homeserver for this device. + */ +@property (nonatomic, readonly) BOOL hasPusherForPushKitNotifications; + + +/** + Enable In-App notifications based on Remote notifications rules. + NO by default. + */ +@property (nonatomic) BOOL enableInAppNotifications; + +/** + Disable the account without logging out (NO by default). + + A matrix session is automatically opened for the account when this property is toggled from YES to NO. + The session is closed when this property is set to YES. + */ +@property (nonatomic,getter=isDisabled) BOOL disabled; + +/** + Manage the online presence event. + + The presence event must not be sent if the application is launched by a push notification. + */ +@property (nonatomic) BOOL hideUserPresence; + +/** + Flag indicating if the end user has been warned about encryption and its limitations. + */ +@property (nonatomic,getter=isWarnedAboutEncryption) BOOL warnedAboutEncryption; + +/** + Register the MXKAccountOnCertificateChange block that will be used to handle certificate change during account use. + This block is nil by default, any new certificate is ignored/untrusted (this will abort the connection to the server). + + @param onCertificateChangeBlock the block that will be used to handle certificate change. + */ ++ (void)registerOnCertificateChangeBlock:(MXKAccountOnCertificateChange)onCertificateChangeBlock; + +/** + Get the color code related to a specific presence. + + @param presence a user presence + @return color defined for the provided presence (nil if no color is defined). + */ ++ (UIColor*)presenceColor:(MXPresence)presence; + +/** + Init `MXKAccount` instance with credentials. No matrix session is opened by default. + + @param credentials user's credentials + */ +- (instancetype)initWithCredentials:(MXCredentials*)credentials; + +/** + Create a matrix session based on the provided store. + When store data is ready, the live stream is automatically launched by synchronising the session with the server. + + In case of failure during server sync, the method is reiterated until the data is up-to-date with the server. + This loop is stopped if you call [MXCAccount closeSession:], it is suspended if you call [MXCAccount pauseInBackgroundTask]. + + @param store the store to use for the session. + */ +-(void)openSessionWithStore:(id)store; + +/** + Close the matrix session. + + @param clearStore set YES to delete all store data. + */ +- (void)closeSession:(BOOL)clearStore; + +/** + Invalidate the access token, close the matrix session and delete all store data. + + @note This method is equivalent to `logoutSendingServerRequest:completion:` with `sendLogoutServerRequest` parameter to YES + + @param completion the block to execute at the end of the operation (independently if it succeeded or not). + */ +- (void)logout:(void (^)(void))completion; + +/** + Invalidate the access token, close the matrix session and delete all store data. + + @param sendLogoutServerRequest indicate to send logout request to homeserver. + @param completion the block to execute at the end of the operation (independently if it succeeded or not). + */ +- (void)logoutSendingServerRequest:(BOOL)sendLogoutServerRequest + completion:(void (^)(void))completion; + + +#pragma mark - Soft logout + +/** + Flag to indicate if the account has been logged out by the homeserver admin. + */ +@property (nonatomic, readonly) BOOL isSoftLogout; + +/** + Soft logout the account. + + The matix session is stopped but the data is kept. + */ +- (void)softLogout; + +/** + Hydrate the account using the credentials provided. + + @param credentials the new credentials. +*/ +- (void)hydrateWithCredentials:(MXCredentials*)credentials; + +/** + Pause the current matrix session. + + @warning: This matrix session is paused without using background task if no background mode handler + is set in the MXSDKOptions sharedInstance (see `backgroundModeHandler`). + */ +- (void)pauseInBackgroundTask; + +/** + Perform a background sync by keeping the user offline. + + @warning: This operation failed when no background mode handler is set in the + MXSDKOptions sharedInstance (see `backgroundModeHandler`). + + @param timeout the timeout in milliseconds. + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)backgroundSync:(unsigned int)timeout success:(void (^)(void))success failure:(void (^)(NSError *))failure; + +/** + Resume the current matrix session. + */ +- (void)resume; + +/** + Close the potential matrix session and open a new one if the account is not disabled. + + @param clearCache set YES to delete all store data. + */ +- (void)reload:(BOOL)clearCache; + +/** + Set the display name of the account user. + + @param displayname the new display name. + + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)setUserDisplayName:(NSString*)displayname success:(void (^)(void))success failure:(void (^)(NSError *error))failure; + +/** + Set the avatar url of the account user. + + @param avatarUrl the new avatar url. + + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)setUserAvatarUrl:(NSString*)avatarUrl success:(void (^)(void))success failure:(void (^)(NSError *error))failure; + +/** + Update the account password. + + @param oldPassword the old password. + @param newPassword the new password. + + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)changePassword:(NSString*)oldPassword with:(NSString*)newPassword success:(void (^)(void))success failure:(void (^)(NSError *error))failure; + +/** + Load the 3PIDs linked to this account. + This method must be called to refresh self.threePIDs and self.linkedEmails. + + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)load3PIDs:(void (^)(void))success failure:(void (^)(NSError *error))failure; + +/** + Load the current device information for this account. + This method must be called to refresh self.device. + + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)loadDeviceInformation:(void (^)(void))success failure:(void (^)(NSError *error))failure; + +#pragma mark - Push notification listeners +/** + Register a listener to push notifications for the account's session. + + The listener will be called when a push rule matches a live event. + Note: only one listener is supported. Potential existing listener is removed. + + You may use `[MXCAccount updateNotificationListenerForRoomId:]` to disable/enable all notifications from a specific room. + + @param onNotification the block that will be called once a live event matches a push rule. + */ +- (void)listenToNotifications:(MXOnNotification)onNotification; + +/** + Unregister the listener. + */ +- (void)removeNotificationListener; + +/** + Update the listener to ignore or restore notifications from a specific room. + + @param roomID the id of the concerned room. + @param isIgnored YES to disable notifications from the specified room. NO to restore them. + */ +- (void)updateNotificationListenerForRoomId:(NSString*)roomID ignore:(BOOL)isIgnored; + +#pragma mark - Crypto +/** + Delete the device id. + + Call this method when the current device id cannot be used anymore. + */ +- (void)resetDeviceId; + +#pragma mark - Sync filter +/** + Check if the homeserver supports room members lazy loading. + @param completion the check result. + */ +- (void)supportLazyLoadOfRoomMembers:(void (^)(BOOL supportLazyLoadOfRoomMembers))completion; + +/** + Call this method at an appropriate time to attempt dehydrating to a new backup device + */ +- (void)attemptDeviceDehydrationWithKeyData:(NSData *)keyData + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m new file mode 100644 index 000000000..c30867251 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m @@ -0,0 +1,2228 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAccount.h" + +#import "MXKAccountManager.h" +#import "MXKRoomDataSourceManager.h" +#import "MXKEventFormatter.h" + +#import "MXKTools.h" +#import "MXKContactManager.h" + +#import "MXKConstants.h" + +#import "NSBundle+MatrixKit.h" + +#import + +#import + +#import "MXKSwiftHeader.h" + +NSString *const kMXKAccountUserInfoDidChangeNotification = @"kMXKAccountUserInfoDidChangeNotification"; +NSString *const kMXKAccountAPNSActivityDidChangeNotification = @"kMXKAccountAPNSActivityDidChangeNotification"; +NSString *const kMXKAccountPushKitActivityDidChangeNotification = @"kMXKAccountPushKitActivityDidChangeNotification"; + +NSString *const kMXKAccountErrorDomain = @"kMXKAccountErrorDomain"; + +static MXKAccountOnCertificateChange _onCertificateChangeBlock; +/** + HTTP status codes for error cases on initial sync requests, for which errors will not be propagated to the client. + */ +static NSArray *initialSyncSilentErrorsHTTPStatusCodes; + +@interface MXKAccount () +{ + // We will notify user only once on session failure + BOOL notifyOpenSessionFailure; + + // The timer used to postpone server sync on failure + NSTimer* initialServerSyncTimer; + + // Reachability observer + id reachabilityObserver; + + // Session state observer + id sessionStateObserver; + + // Handle user's settings change + id userUpdateListener; + + // Used for logging application start up + NSDate *openSessionStartDate; + + // Event notifications listener + id notificationCenterListener; + + // Internal list of ignored rooms + NSMutableArray* ignoredRooms; + + // If a server sync is in progress, the pause is delayed at the end of sync (except if resume is called). + BOOL isPauseRequested; + + // Background sync management + MXOnBackgroundSyncDone backgroundSyncDone; + MXOnBackgroundSyncFail backgroundSyncFails; + NSTimer* backgroundSyncTimer; + + // Observe UIApplicationSignificantTimeChangeNotification to refresh MXRoomSummaries on time formatting change. + id UIApplicationSignificantTimeChangeNotificationObserver; + + // Observe NSCurrentLocaleDidChangeNotification to refresh MXRoomSummaries on time formatting change. + id NSCurrentLocaleDidChangeNotificationObserver; +} + +@property (nonatomic, strong) id backgroundTask; +@property (nonatomic, strong) id backgroundSyncBgTask; + +@property (nonatomic, strong) NSMutableDictionary> *others; + +@end + +@implementation MXKAccount +@synthesize mxCredentials, mxSession, mxRestClient; +@synthesize threePIDs; +@synthesize userPresence; +@synthesize userTintColor; +@synthesize hideUserPresence; + ++ (void)load +{ + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + initialSyncSilentErrorsHTTPStatusCodes = @[ + @(504), + @(522), + @(524), + @(599) + ]; + }); +} + ++ (void)registerOnCertificateChangeBlock:(MXKAccountOnCertificateChange)onCertificateChangeBlock +{ + _onCertificateChangeBlock = onCertificateChangeBlock; +} + ++ (UIColor*)presenceColor:(MXPresence)presence +{ + switch (presence) + { + case MXPresenceOnline: + return [[MXKAppSettings standardAppSettings] presenceColorForOnlineUser]; + case MXPresenceUnavailable: + return [[MXKAppSettings standardAppSettings] presenceColorForUnavailableUser]; + case MXPresenceOffline: + return [[MXKAppSettings standardAppSettings] presenceColorForOfflineUser]; + case MXPresenceUnknown: + default: + return nil; + } +} + +- (instancetype)initWithCredentials:(MXCredentials*)credentials +{ + if (self = [super init]) + { + notifyOpenSessionFailure = YES; + + // Report credentials and alloc REST client. + mxCredentials = credentials; + [self prepareRESTClient]; + + userPresence = MXPresenceUnknown; + + // Refresh device information + [self loadDeviceInformation:nil failure:nil]; + + [self registerAccountDataDidChangeIdentityServerNotification]; + [self registerIdentityServiceDidChangeAccessTokenNotification]; + } + return self; +} + +- (void)dealloc +{ + [self closeSession:NO]; + mxSession = nil; + + [mxRestClient close]; + mxRestClient = nil; +} + +#pragma mark - NSCoding + +- (id)initWithCoder:(NSCoder *)coder +{ + self = [super init]; + + if (self) + { + notifyOpenSessionFailure = YES; + + NSString *homeServerURL = [coder decodeObjectForKey:@"homeserverurl"]; + NSString *userId = [coder decodeObjectForKey:@"userid"]; + NSString *accessToken = [coder decodeObjectForKey:@"accesstoken"]; + _identityServerURL = [coder decodeObjectForKey:@"identityserverurl"]; + NSString *identityServerAccessToken = [coder decodeObjectForKey:@"identityserveraccesstoken"]; + + mxCredentials = [[MXCredentials alloc] initWithHomeServer:homeServerURL + userId:userId + accessToken:accessToken]; + + mxCredentials.identityServer = _identityServerURL; + mxCredentials.identityServerAccessToken = identityServerAccessToken; + mxCredentials.deviceId = [coder decodeObjectForKey:@"deviceId"]; + mxCredentials.allowedCertificate = [coder decodeObjectForKey:@"allowedCertificate"]; + + [self prepareRESTClient]; + + [self registerAccountDataDidChangeIdentityServerNotification]; + [self registerIdentityServiceDidChangeAccessTokenNotification]; + + if ([coder decodeObjectForKey:@"threePIDs"]) + { + threePIDs = [coder decodeObjectForKey:@"threePIDs"]; + } + + if ([coder decodeObjectForKey:@"device"]) + { + _device = [coder decodeObjectForKey:@"device"]; + } + + userPresence = MXPresenceUnknown; + + if ([coder decodeObjectForKey:@"antivirusserverurl"]) + { + _antivirusServerURL = [coder decodeObjectForKey:@"antivirusserverurl"]; + } + + if ([coder decodeObjectForKey:@"pushgatewayurl"]) + { + _pushGatewayURL = [coder decodeObjectForKey:@"pushgatewayurl"]; + } + + _hasPusherForPushNotifications = [coder decodeBoolForKey:@"_enablePushNotifications"]; + _hasPusherForPushKitNotifications = [coder decodeBoolForKey:@"enablePushKitNotifications"]; + _enableInAppNotifications = [coder decodeBoolForKey:@"enableInAppNotifications"]; + + _disabled = [coder decodeBoolForKey:@"disabled"]; + _isSoftLogout = [coder decodeBoolForKey:@"isSoftLogout"]; + + _warnedAboutEncryption = [coder decodeBoolForKey:@"warnedAboutEncryption"]; + + _others = [coder decodeObjectForKey:@"others"]; + + // Refresh device information + [self loadDeviceInformation:nil failure:nil]; + } + + return self; +} + +- (void)encodeWithCoder:(NSCoder *)coder +{ + [coder encodeObject:mxCredentials.homeServer forKey:@"homeserverurl"]; + [coder encodeObject:mxCredentials.userId forKey:@"userid"]; + [coder encodeObject:mxCredentials.accessToken forKey:@"accesstoken"]; + [coder encodeObject:mxCredentials.identityServerAccessToken forKey:@"identityserveraccesstoken"]; + + if (mxCredentials.deviceId) + { + [coder encodeObject:mxCredentials.deviceId forKey:@"deviceId"]; + } + + if (mxCredentials.allowedCertificate) + { + [coder encodeObject:mxCredentials.allowedCertificate forKey:@"allowedCertificate"]; + } + + if (self.threePIDs) + { + [coder encodeObject:threePIDs forKey:@"threePIDs"]; + } + + if (self.device) + { + [coder encodeObject:_device forKey:@"device"]; + } + + if (self.identityServerURL) + { + [coder encodeObject:_identityServerURL forKey:@"identityserverurl"]; + } + + if (self.antivirusServerURL) + { + [coder encodeObject:_antivirusServerURL forKey:@"antivirusserverurl"]; + } + + if (self.pushGatewayURL) + { + [coder encodeObject:_pushGatewayURL forKey:@"pushgatewayurl"]; + } + + [coder encodeBool:_hasPusherForPushNotifications forKey:@"_enablePushNotifications"]; + [coder encodeBool:_hasPusherForPushKitNotifications forKey:@"enablePushKitNotifications"]; + [coder encodeBool:_enableInAppNotifications forKey:@"enableInAppNotifications"]; + + [coder encodeBool:_disabled forKey:@"disabled"]; + [coder encodeBool:_isSoftLogout forKey:@"isSoftLogout"]; + + [coder encodeBool:_warnedAboutEncryption forKey:@"warnedAboutEncryption"]; + + [coder encodeObject:_others forKey:@"others"]; +} + +#pragma mark - Properties + +- (void)setIdentityServerURL:(NSString *)identityServerURL +{ + if (identityServerURL.length) + { + _identityServerURL = identityServerURL; + mxCredentials.identityServer = identityServerURL; + + // Update services used in MXSession + [mxSession setIdentityServer:mxCredentials.identityServer andAccessToken:mxCredentials.identityServerAccessToken]; + } + else + { + _identityServerURL = nil; + [mxSession setIdentityServer:nil andAccessToken:nil]; + } + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; +} + +- (void)setAntivirusServerURL:(NSString *)antivirusServerURL +{ + _antivirusServerURL = antivirusServerURL; + // Update the current session if any + [mxSession setAntivirusServerURL:antivirusServerURL]; + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; +} + +- (void)setPushGatewayURL:(NSString *)pushGatewayURL +{ + _pushGatewayURL = pushGatewayURL.length ? pushGatewayURL : nil; + + MXLogDebug(@"[MXKAccount][Push] setPushGatewayURL: %@", _pushGatewayURL); + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; +} + +- (NSString*)userDisplayName +{ + if (mxSession) + { + return mxSession.myUser.displayname; + } + return nil; +} + +- (NSString*)userAvatarUrl +{ + if (mxSession) + { + return mxSession.myUser.avatarUrl; + } + return nil; +} + +- (NSString*)fullDisplayName +{ + if (self.userDisplayName.length) + { + return [NSString stringWithFormat:@"%@ (%@)", self.userDisplayName, mxCredentials.userId]; + } + else + { + return mxCredentials.userId; + } +} + +- (NSArray *)threePIDs +{ + return threePIDs; +} + +- (NSArray *)linkedEmails +{ + NSMutableArray *linkedEmails = [NSMutableArray array]; + + for (MXThirdPartyIdentifier *threePID in threePIDs) + { + if ([threePID.medium isEqualToString:kMX3PIDMediumEmail]) + { + [linkedEmails addObject:threePID.address]; + } + } + + return linkedEmails; +} + +- (NSArray *)linkedPhoneNumbers +{ + NSMutableArray *linkedPhoneNumbers = [NSMutableArray array]; + + for (MXThirdPartyIdentifier *threePID in threePIDs) + { + if ([threePID.medium isEqualToString:kMX3PIDMediumMSISDN]) + { + [linkedPhoneNumbers addObject:threePID.address]; + } + } + + return linkedPhoneNumbers; +} + +- (UIColor*)userTintColor +{ + if (!userTintColor) + { + userTintColor = [MXKTools colorWithRGBValue:[mxCredentials.userId hash]]; + } + + return userTintColor; +} + +- (BOOL)pushNotificationServiceIsActive +{ + BOOL pushNotificationServiceIsActive = ([[MXKAccountManager sharedManager] isAPNSAvailable] && _hasPusherForPushNotifications && mxSession); + MXLogDebug(@"[MXKAccount][Push] pushNotificationServiceIsActive: %@", @(pushNotificationServiceIsActive)); + + return pushNotificationServiceIsActive; +} + +- (void)enablePushNotifications:(BOOL)enable + success:(void (^)(void))success + failure:(void (^)(NSError *))failure +{ + MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: %@", @(enable)); + + if (enable) + { + if ([[MXKAccountManager sharedManager] isAPNSAvailable]) + { + MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: Enable Push for %@ account", self.mxCredentials.userId); + + // Create/restore the pusher + [self enableAPNSPusher:YES success:^{ + + MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: Enable Push: Success"); + if (success) + { + success(); + } + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: Enable Push: Error: %@", error); + if (failure) + { + failure(error); + } + }]; + } + else + { + MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: Error: Cannot enable Push"); + + NSError *error = [NSError errorWithDomain:kMXKAccountErrorDomain + code:0 + userInfo:@{ + NSLocalizedDescriptionKey: + [MatrixKitL10n accountErrorPushNotAllowed] + }]; + if (failure) + { + failure (error); + } + } + } + else if (_hasPusherForPushNotifications) + { + MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: Disable APNS for %@ account", self.mxCredentials.userId); + + // Delete the pusher, report the new value only on success. + [self enableAPNSPusher:NO + success:^{ + + MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: Disable Push: Success"); + if (success) + { + success(); + } + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: Disable Push: Error: %@", error); + if (failure) + { + failure(error); + } + }]; + } +} + +- (BOOL)isPushKitNotificationActive +{ + BOOL isPushKitNotificationActive = ([[MXKAccountManager sharedManager] isPushAvailable] && _hasPusherForPushKitNotifications && mxSession); + MXLogDebug(@"[MXKAccount][Push] isPushKitNotificationActive: %@", @(isPushKitNotificationActive)); + + return isPushKitNotificationActive; +} + +- (void)enablePushKitNotifications:(BOOL)enable + success:(void (^)(void))success + failure:(void (^)(NSError *))failure +{ + MXLogDebug(@"[MXKAccount][Push] enablePushKitNotifications: %@", @(enable)); + + if (enable) + { + if ([[MXKAccountManager sharedManager] isPushAvailable]) + { + MXLogDebug(@"[MXKAccount][Push] enablePushKitNotifications: Enable Push for %@ account", self.mxCredentials.userId); + + // Create/restore the pusher + [self enablePushKitPusher:YES success:^{ + + MXLogDebug(@"[MXKAccount][Push] enablePushKitNotifications: Enable Push: Success"); + if (success) + { + success(); + } + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKAccount][Push] enablePushKitNotifications: Enable Push: Error: %@", error); + if (failure) + { + failure(error); + } + }]; + } + else + { + MXLogDebug(@"[MXKAccount][Push] enablePushKitNotifications: Error: Cannot enable Push"); + + NSError *error = [NSError errorWithDomain:kMXKAccountErrorDomain + code:0 + userInfo:@{ + NSLocalizedDescriptionKey: + [MatrixKitL10n accountErrorPushNotAllowed] + }]; + failure (error); + } + } + else if (_hasPusherForPushKitNotifications) + { + MXLogDebug(@"[MXKAccount][Push] enablePushKitNotifications: Disable Push for %@ account", self.mxCredentials.userId); + + // Delete the pusher, report the new value only on success. + [self enablePushKitPusher:NO success:^{ + + MXLogDebug(@"[MXKAccount][Push] enablePushKitNotifications: Disable Push: Success"); + if (success) + { + success(); + } + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKAccount][Push] enablePushKitNotifications: Disable Push: Error: %@", error); + if (failure) + { + failure(error); + } + }]; + } + else + { + MXLogDebug(@"[MXKAccount][Push] enablePushKitNotifications: PushKit is already disabled for %@", self.mxCredentials.userId); + if (success) + { + success(); + } + } +} + +- (void)setEnableInAppNotifications:(BOOL)enableInAppNotifications +{ + MXLogDebug(@"[MXKAccount] setEnableInAppNotifications: %@", @(enableInAppNotifications)); + + _enableInAppNotifications = enableInAppNotifications; + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; +} + +- (void)setDisabled:(BOOL)disabled +{ + if (_disabled != disabled) + { + _disabled = disabled; + + if (_disabled) + { + [self deletePusher]; + [self enablePushKitNotifications:NO success:nil failure:nil]; + + // Close session (keep the storage). + [self closeSession:NO]; + } + else if (!mxSession) + { + // Open a new matrix session + id store = [[[MXKAccountManager sharedManager].storeClass alloc] init]; + + [self openSessionWithStore:store]; + } + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; + } +} + +- (void)setWarnedAboutEncryption:(BOOL)warnedAboutEncryption +{ + _warnedAboutEncryption = warnedAboutEncryption; + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; +} + +- (NSMutableDictionary> *)others +{ + if(_others == nil) + { + _others = [NSMutableDictionary dictionary]; + } + + return _others; +} + +#pragma mark - Matrix user's profile + +- (void)setUserDisplayName:(NSString*)displayname success:(void (^)(void))success failure:(void (^)(NSError *error))failure +{ + if (mxSession && mxSession.myUser) + { + [mxSession.myUser setDisplayName:displayname + success:^{ + if (success) { + success(); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountUserInfoDidChangeNotification object:self->mxCredentials.userId]; + } + failure:failure]; + } + else if (failure) + { + failure ([NSError errorWithDomain:kMXKAccountErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey: [MatrixKitL10n accountErrorMatrixSessionIsNotOpened]}]); + } +} + +- (void)setUserAvatarUrl:(NSString*)avatarUrl success:(void (^)(void))success failure:(void (^)(NSError *error))failure +{ + if (mxSession && mxSession.myUser) + { + [mxSession.myUser setAvatarUrl:avatarUrl + success:^{ + if (success) { + success(); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountUserInfoDidChangeNotification object:self->mxCredentials.userId]; + } + failure:failure]; + } + else if (failure) + { + failure ([NSError errorWithDomain:kMXKAccountErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey: [MatrixKitL10n accountErrorMatrixSessionIsNotOpened]}]); + } +} + +- (void)changePassword:(NSString*)oldPassword with:(NSString*)newPassword success:(void (^)(void))success failure:(void (^)(NSError *error))failure +{ + if (mxSession) + { + [mxRestClient changePassword:oldPassword + with:newPassword + success:^{ + + if (success) { + success(); + } + + } + failure:failure]; + } + else if (failure) + { + failure ([NSError errorWithDomain:kMXKAccountErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey: [MatrixKitL10n accountErrorMatrixSessionIsNotOpened]}]); + } +} + +- (void)load3PIDs:(void (^)(void))success failure:(void (^)(NSError *))failure +{ + [mxRestClient threePIDs:^(NSArray *threePIDs2) { + + self->threePIDs = threePIDs2; + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; + + if (success) + { + success(); + } + + } failure:^(NSError *error) { + if (failure) + { + failure(error); + } + }]; +} + +- (void)loadDeviceInformation:(void (^)(void))success failure:(void (^)(NSError *error))failure +{ + if (mxCredentials.deviceId) + { + [mxRestClient deviceByDeviceId:mxCredentials.deviceId success:^(MXDevice *device) { + + self->_device = device; + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; + + if (success) + { + success(); + } + + } failure:^(NSError *error) { + + if (failure) + { + failure(error); + } + + }]; + } + else + { + _device = nil; + if (success) + { + success(); + } + } +} + +- (void)setUserPresence:(MXPresence)presence andStatusMessage:(NSString *)statusMessage completion:(void (^)(void))completion +{ + userPresence = presence; + + if (mxSession && !hideUserPresence) + { + // Update user presence on server side + [mxSession.myUser setPresence:userPresence + andStatusMessage:statusMessage + success:^{ + MXLogDebug(@"[MXKAccount] %@: set user presence (%lu) succeeded", self->mxCredentials.userId, (unsigned long)self->userPresence); + if (completion) + { + completion(); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountUserInfoDidChangeNotification object:self->mxCredentials.userId]; + } + failure:^(NSError *error) { + MXLogDebug(@"[MXKAccount] %@: set user presence (%lu) failed", self->mxCredentials.userId, (unsigned long)self->userPresence); + }]; + } + else if (hideUserPresence) + { + MXLogDebug(@"[MXKAccount] %@: set user presence is disabled.", mxCredentials.userId); + } +} + +#pragma mark - + +/** + Create a matrix session based on the provided store. + When store data is ready, the live stream is automatically launched by synchronising the session with the server. + + In case of failure during server sync, the method is reiterated until the data is up-to-date with the server. + This loop is stopped if you call [MXCAccount closeSession:], it is suspended if you call [MXCAccount pauseInBackgroundTask]. + + @param store the store to use for the session. + */ +-(void)openSessionWithStore:(id)store +{ + // Sanity check + if (!mxCredentials || !mxRestClient) + { + MXLogDebug(@"[MXKAccount] Matrix session cannot be created without credentials"); + return; + } + + // Close potential session (keep associated store). + [self closeSession:NO]; + + openSessionStartDate = [NSDate date]; + + // Instantiate new session + mxSession = [[MXSession alloc] initWithMatrixRestClient:mxRestClient]; + + // Check whether an antivirus url is defined. + if (_antivirusServerURL) + { + // Enable the antivirus scanner in the current session. + [mxSession setAntivirusServerURL:_antivirusServerURL]; + } + + // Set default MXEvent -> NSString formatter + MXKEventFormatter *eventFormatter = [[MXKEventFormatter alloc] initWithMatrixSession:self.mxSession]; + eventFormatter.isForSubtitle = YES; + + // Apply the event types filter to display only the wanted event types. + eventFormatter.eventTypesFilterForMessages = [MXKAppSettings standardAppSettings].eventsFilterForMessages; + + mxSession.roomSummaryUpdateDelegate = eventFormatter; + + // Observe UIApplicationSignificantTimeChangeNotification to refresh to MXRoomSummaries if date/time are shown. + // UIApplicationSignificantTimeChangeNotification is posted if DST is updated, carrier time is updated + UIApplicationSignificantTimeChangeNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationSignificantTimeChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + [self onDateTimeFormatUpdate]; + }]; + + + // Observe NSCurrentLocaleDidChangeNotification to refresh MXRoomSummaries if date/time are shown. + // NSCurrentLocaleDidChangeNotification is triggered when the time swicthes to AM/PM to 24h time format + NSCurrentLocaleDidChangeNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:NSCurrentLocaleDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + [self onDateTimeFormatUpdate]; + }]; + + // Force a date refresh for all the last messages. + [self onDateTimeFormatUpdate]; + + // Register session state observer + sessionStateObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXSessionStateDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + // Check whether the concerned session is the associated one + if (notif.object == self->mxSession) + { + [self onMatrixSessionStateChange]; + } + }]; + + MXWeakify(self); + + [mxSession setStore:store success:^{ + + // Complete session registration by launching live stream + MXStrongifyAndReturnIfNil(self); + + // Validate the availability of local contact sync for any changes to the + // authorization of contacts access that may have occurred since the last launch. + // The session is passed in as the contacts manager may not have had a session added yet. + [MXKContactManager.sharedManager validateSyncLocalContactsStateForSession:self.mxSession]; + + // Refresh pusher state + [self refreshAPNSPusher]; + [self refreshPushKitPusher]; + + // Launch server sync + [self launchInitialServerSync]; + + } failure:^(NSError *error) { + + // This cannot happen. Loading of MXFileStore cannot fail. + MXStrongifyAndReturnIfNil(self); + self->mxSession = nil; + + [[NSNotificationCenter defaultCenter] removeObserver:self->sessionStateObserver]; + self->sessionStateObserver = nil; + + }]; +} + +/** + Close the matrix session. + + @param clearStore set YES to delete all store data. + */ +- (void)closeSession:(BOOL)clearStore +{ + MXLogDebug(@"[MXKAccount] closeSession (%u)", clearStore); + + if (NSCurrentLocaleDidChangeNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:NSCurrentLocaleDidChangeNotificationObserver]; + NSCurrentLocaleDidChangeNotificationObserver = nil; + } + + if (UIApplicationSignificantTimeChangeNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:UIApplicationSignificantTimeChangeNotificationObserver]; + UIApplicationSignificantTimeChangeNotificationObserver = nil; + } + + [self removeNotificationListener]; + + if (reachabilityObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:reachabilityObserver]; + reachabilityObserver = nil; + } + + if (sessionStateObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:sessionStateObserver]; + sessionStateObserver = nil; + } + + [initialServerSyncTimer invalidate]; + initialServerSyncTimer = nil; + + if (userUpdateListener) + { + [mxSession.myUser removeListener:userUpdateListener]; + userUpdateListener = nil; + } + + if (mxSession) + { + // Reset room data stored in memory + [MXKRoomDataSourceManager removeSharedManagerForMatrixSession:mxSession]; + + if (clearStore) + { + // Force a reload of device keys at the next session start. + // This will fix potential UISIs other peoples receive for our messages. + [mxSession.crypto resetDeviceKeys]; + + // Clean other stores + [mxSession.scanManager deleteAllAntivirusScans]; + [mxSession.aggregations resetData]; + } + else + { + // For recomputing of room summaries as they are a cache of computed data + [mxSession resetRoomsSummariesLastMessage]; + } + + // Close session + [mxSession close]; + + if (clearStore) + { + [mxSession.store deleteAllData]; + } + + mxSession = nil; + } + + notifyOpenSessionFailure = YES; +} + +- (void)logout:(void (^)(void))completion +{ + if (!mxSession) + { + MXLogDebug(@"[MXKAccount] logout: Need to open the closed session to make a logout request"); + id store = [[[MXKAccountManager sharedManager].storeClass alloc] init]; + mxSession = [[MXSession alloc] initWithMatrixRestClient:mxRestClient]; + + MXWeakify(self); + [mxSession setStore:store success:^{ + MXStrongifyAndReturnIfNil(self); + + [self logout:completion]; + + } failure:^(NSError *error) { + completion(); + }]; + return; + } + + [self deletePusher]; + [self enablePushKitNotifications:NO success:nil failure:nil]; + + MXHTTPOperation *operation = [mxSession logout:^{ + + [self closeSession:YES]; + if (completion) + { + completion(); + } + + } failure:^(NSError *error) { + + // Close the session even if the logout request failed + [self closeSession:YES]; + if (completion) + { + completion(); + } + + }]; + + // Do not retry on failure. + operation.maxNumberOfTries = 1; +} + +// Logout locally, do not send server request +- (void)logoutLocally:(void (^)(void))completion +{ + [self deletePusher]; + [self enablePushKitNotifications:NO success:nil failure:nil]; + + [mxSession enableCrypto:NO success:^{ + [self closeSession:YES]; + if (completion) + { + completion(); + } + + } failure:^(NSError *error) { + + // Close the session even if the logout request failed + [self closeSession:YES]; + if (completion) + { + completion(); + } + + }]; +} + +- (void)logoutSendingServerRequest:(BOOL)sendLogoutServerRequest + completion:(void (^)(void))completion +{ + if (sendLogoutServerRequest) + { + [self logout:completion]; + } + else + { + [self logoutLocally:completion]; + } +} + + +#pragma mark - Soft logout + +- (void)softLogout +{ + _isSoftLogout = YES; + [[MXKAccountManager sharedManager] saveAccounts]; + + // Stop SDK making requests to the homeserver + [mxSession close]; +} + +- (void)hydrateWithCredentials:(MXCredentials*)credentials +{ + // Sanity check + if ([mxCredentials.userId isEqualToString:credentials.userId]) + { + mxCredentials = credentials; + _isSoftLogout = NO; + [[MXKAccountManager sharedManager] saveAccounts]; + + [self prepareRESTClient]; + } + else + { + MXLogDebug(@"[MXKAccount] hydrateWithCredentials: Error: users ids mismatch: %@ vs %@", credentials.userId, mxCredentials.userId); + } +} + + +- (void)deletePusher +{ + if (self.pushNotificationServiceIsActive) + { + [self enableAPNSPusher:NO success:nil failure:nil]; + } +} + +- (void)pauseInBackgroundTask +{ + // Reset internal flag + isPauseRequested = NO; + + if (mxSession && mxSession.isPauseable) + { + id handler = [MXSDKOptions sharedInstance].backgroundModeHandler; + if (handler) + { + if (!self.backgroundTask.isRunning) + { + self.backgroundTask = [handler startBackgroundTaskWithName:@"[MXKAccount] pauseInBackgroundTask" expirationHandler:nil]; + } + } + + // Pause SDK + [mxSession pause]; + + // Update user presence + __weak typeof(self) weakSelf = self; + [self setUserPresence:MXPresenceUnavailable andStatusMessage:nil completion:^{ + + if (weakSelf) + { + typeof(self) self = weakSelf; + + if (self.backgroundTask.isRunning) + { + [self.backgroundTask stop]; + self.backgroundTask = nil; + } + } + + }]; + } + else + { + // Cancel pending actions + [[NSNotificationCenter defaultCenter] removeObserver:reachabilityObserver]; + reachabilityObserver = nil; + [initialServerSyncTimer invalidate]; + initialServerSyncTimer = nil; + + if (mxSession.state == MXSessionStateSyncInProgress || mxSession.state == MXSessionStateInitialised || mxSession.state == MXSessionStateStoreDataReady) + { + // Make sure the SDK finish its work before the app goes sleeping in background + id handler = [MXSDKOptions sharedInstance].backgroundModeHandler; + if (handler) + { + if (!self.backgroundTask.isRunning) + { + self.backgroundTask = [handler startBackgroundTaskWithName:@"[MXKAccount] pauseInBackgroundTask" expirationHandler:nil]; + } + } + + MXLogDebug(@"[MXKAccount] Pause is delayed at the end of sync (current state %tu)", mxSession.state); + isPauseRequested = YES; + } + } +} + +- (void)resume +{ + isPauseRequested = NO; + + if (mxSession) + { + MXLogVerbose(@"[MXKAccount] resume with session state: %tu", mxSession.state); + + [self cancelBackgroundSync]; + + if (mxSession.state == MXSessionStatePaused || mxSession.state == MXSessionStatePauseRequested) + { + // Resume SDK and update user presence + [mxSession resume:^{ + [self setUserPresence:MXPresenceOnline andStatusMessage:nil completion:nil]; + + [self refreshAPNSPusher]; + [self refreshPushKitPusher]; + }]; + } + else if (mxSession.state == MXSessionStateStoreDataReady || mxSession.state == MXSessionStateInitialSyncFailed) + { + // The session initialisation was uncompleted, we try to complete it here. + [self launchInitialServerSync]; + + [self refreshAPNSPusher]; + [self refreshPushKitPusher]; + } + else if (mxSession.state == MXSessionStateSyncInProgress) + { + [self refreshAPNSPusher]; + [self refreshPushKitPusher]; + } + + // Cancel background task + if (self.backgroundTask.isRunning) + { + [self.backgroundTask stop]; + self.backgroundTask = nil; + } + } +} + +- (void)reload:(BOOL)clearCache +{ + // close potential session + [self closeSession:clearCache]; + + if (!_disabled) + { + // Open a new matrix session + id store = [[[MXKAccountManager sharedManager].storeClass alloc] init]; + [self openSessionWithStore:store]; + } +} + +#pragma mark - Push notifications + +// Refresh the APNS pusher state for this account on this device. +- (void)refreshAPNSPusher +{ + MXLogDebug(@"[MXKAccount][Push] refreshAPNSPusher"); + + // Check the conditions required to run the pusher + if (self.pushNotificationServiceIsActive) + { + MXLogDebug(@"[MXKAccount][Push] refreshAPNSPusher: Refresh APNS pusher for %@ account", self.mxCredentials.userId); + + // Create/restore the pusher + [self enableAPNSPusher:YES + success:nil + failure:^(NSError *error) { + MXLogDebug(@"[MXKAccount][Push] ;: Error: %@", error); + }]; + } + else if (_hasPusherForPushNotifications) + { + if ([MXKAccountManager sharedManager].apnsDeviceToken) + { + if (mxSession) + { + // Turn off pusher if user denied remote notification. + MXLogDebug(@"[MXKAccount][Push] refreshAPNSPusher: Disable APNS pusher for %@ account (notifications are denied)", self.mxCredentials.userId); + [self enableAPNSPusher:NO success:nil failure:nil]; + } + } + else + { + MXLogDebug(@"[MXKAccount][Push] refreshAPNSPusher: APNS pusher for %@ account is already disabled. Reset _hasPusherForPushNotifications", self.mxCredentials.userId); + _hasPusherForPushNotifications = NO; + [[MXKAccountManager sharedManager] saveAccounts]; + } + } +} + +// Enable/Disable the APNS pusher for this account on this device on the homeserver. +- (void)enableAPNSPusher:(BOOL)enabled success:(void (^)(void))success failure:(void (^)(NSError *))failure +{ + MXLogDebug(@"[MXKAccount][Push] enableAPNSPusher: %@", @(enabled)); + +#ifdef DEBUG + NSString *appId = [[NSUserDefaults standardUserDefaults] objectForKey:@"pusherAppIdDev"]; +#else + NSString *appId = [[NSUserDefaults standardUserDefaults] objectForKey:@"pusherAppIdProd"]; +#endif + + NSString *locKey = MXKAppSettings.standardAppSettings.notificationBodyLocalizationKey; + + NSDictionary *pushData = @{ + @"url": self.pushGatewayURL, + @"format": @"event_id_only", + @"default_payload": @{@"aps": @{@"mutable-content": @(1), @"alert": @{@"loc-key": locKey, @"loc-args": @[]}}} + }; + + [self enablePusher:enabled appId:appId token:[MXKAccountManager sharedManager].apnsDeviceToken pushData:pushData success:^{ + + MXLogDebug(@"[MXKAccount][Push] enableAPNSPusher: Succeeded to update APNS pusher for %@ (%d)", self.mxCredentials.userId, enabled); + + self->_hasPusherForPushNotifications = enabled; + [[MXKAccountManager sharedManager] saveAccounts]; + + if (success) + { + success(); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountAPNSActivityDidChangeNotification object:self->mxCredentials.userId]; + + } failure:^(NSError *error) { + + // Ignore error if the client try to disable an unknown token + if (!enabled) + { + // Check whether the token was unknown + MXError *mxError = [[MXError alloc] initWithNSError:error]; + if (mxError && [mxError.errcode isEqualToString:kMXErrCodeStringUnknown]) + { + MXLogDebug(@"[MXKAccount][Push] enableAPNSPusher: APNS was already disabled for %@!", self.mxCredentials.userId); + + // Ignore the error + if (success) + { + success(); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountAPNSActivityDidChangeNotification object:self->mxCredentials.userId]; + + return; + } + + MXLogDebug(@"[MXKAccount][Push] enableAPNSPusher: Failed to disable APNS %@! (%@)", self.mxCredentials.userId, error); + } + else + { + MXLogDebug(@"[MXKAccount][Push] enableAPNSPusher: Failed to send APNS token for %@! (%@)", self.mxCredentials.userId, error); + } + + if (failure) + { + failure(error); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountAPNSActivityDidChangeNotification object:self->mxCredentials.userId]; + }]; +} + +// Refresh the PushKit pusher state for this account on this device. +- (void)refreshPushKitPusher +{ + MXLogDebug(@"[MXKAccount][Push] refreshPushKitPusher"); + + // Check the conditions required to run the pusher + if (![MXKAppSettings standardAppSettings].allowPushKitPushers) + { + // Turn off pusher if PushKit pushers are not allowed + MXLogDebug(@"[MXKAccount][Push] refreshPushKitPusher: Disable PushKit pusher for %@ account (pushers are not allowed)", self.mxCredentials.userId); + [self enablePushKitPusher:NO success:nil failure:nil]; + } + else if (self.isPushKitNotificationActive) + { + MXLogDebug(@"[MXKAccount][Push] refreshPushKitPusher: Refresh PushKit pusher for %@ account", self.mxCredentials.userId); + + // Create/restore the pusher + [self enablePushKitPusher:YES + success:nil + failure:^(NSError *error) { + MXLogDebug(@"[MXKAccount][Push] refreshPushKitPusher: Error: %@", error); + }]; + } + else if (_hasPusherForPushKitNotifications) + { + if ([MXKAccountManager sharedManager].pushDeviceToken) + { + if (mxSession) + { + // Turn off pusher if user denied remote notification. + MXLogDebug(@"[MXKAccount][Push] refreshPushKitPusher: Disable PushKit pusher for %@ account (notifications are denied)", self.mxCredentials.userId); + [self enablePushKitPusher:NO success:nil failure:nil]; + } + } + else + { + MXLogDebug(@"[MXKAccount][Push] refreshPushKitPusher: PushKit pusher for %@ account is already disabled. Reset _hasPusherForPushKitNotifications", self.mxCredentials.userId); + _hasPusherForPushKitNotifications = NO; + [[MXKAccountManager sharedManager] saveAccounts]; + } + } +} + +// Enable/Disable the pusher based on PushKit for this account on this device on the homeserver. +- (void)enablePushKitPusher:(BOOL)enabled success:(void (^)(void))success failure:(void (^)(NSError *))failure +{ + MXLogDebug(@"[MXKAccount][Push] enablePushKitPusher: %@", @(enabled)); + + if (enabled && ![MXKAppSettings standardAppSettings].allowPushKitPushers) + { + // sanity check, if accidently try to enable the pusher + MXLogDebug(@"[MXKAccount][Push] enablePushKitPusher: Do not enable it because PushKit pushers not allowed"); + if (failure) + { + failure([NSError errorWithDomain:kMXKAccountErrorDomain code:0 userInfo:nil]); + } + return; + } + + NSString *appIdKey; + #ifdef DEBUG + appIdKey = @"pushKitAppIdDev"; + #else + appIdKey = @"pushKitAppIdProd"; + #endif + + NSString *appId = [[NSUserDefaults standardUserDefaults] objectForKey:appIdKey]; + + NSMutableDictionary *pushData = [NSMutableDictionary dictionaryWithDictionary:@{@"url": self.pushGatewayURL}]; + + NSDictionary *options = [MXKAccountManager sharedManager].pushOptions; + if (options.count) + { + [pushData addEntriesFromDictionary:options]; + } + + NSData *token = [MXKAccountManager sharedManager].pushDeviceToken; + if (!token) + { + // sanity check, if no token there is no point of calling the endpoint + MXLogDebug(@"[MXKAccount][Push] enablePushKitPusher: Failed to update PushKit pusher to %@ for %@. (token is missing)", @(enabled), self.mxCredentials.userId); + if (failure) + { + failure([NSError errorWithDomain:kMXKAccountErrorDomain code:0 userInfo:nil]); + } + return; + } + [self enablePusher:enabled appId:appId token:token pushData:pushData success:^{ + + MXLogDebug(@"[MXKAccount][Push] enablePushKitPusher: Succeeded to update PushKit pusher for %@. Enabled: %@. Token: %@", self.mxCredentials.userId, @(enabled), [MXKTools logForPushToken:token]); + + self->_hasPusherForPushKitNotifications = enabled; + [[MXKAccountManager sharedManager] saveAccounts]; + + if (success) + { + success(); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountPushKitActivityDidChangeNotification object:self->mxCredentials.userId]; + + } failure:^(NSError *error) { + + // Ignore error if the client try to disable an unknown token + if (!enabled) + { + // Check whether the token was unknown + MXError *mxError = [[MXError alloc] initWithNSError:error]; + if (mxError && [mxError.errcode isEqualToString:kMXErrCodeStringUnknown]) + { + MXLogDebug(@"[MXKAccount][Push] enablePushKitPusher: Push was already disabled for %@!", self.mxCredentials.userId); + + // Ignore the error + if (success) + { + success(); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountPushKitActivityDidChangeNotification object:self->mxCredentials.userId]; + + return; + } + + MXLogDebug(@"[MXKAccount][Push] enablePushKitPusher: Failed to disable Push %@! (%@)", self.mxCredentials.userId, error); + } + else + { + MXLogDebug(@"[MXKAccount][Push] enablePushKitPusher: Failed to send Push token for %@! (%@)", self.mxCredentials.userId, error); + } + + if (failure) + { + failure(error); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountPushKitActivityDidChangeNotification object:self->mxCredentials.userId]; + }]; +} + +- (void)enablePusher:(BOOL)enabled appId:(NSString*)appId token:(NSData*)token pushData:(NSDictionary*)pushData success:(void (^)(void))success failure:(void (^)(NSError *))failure +{ + MXLogDebug(@"[MXKAccount][Push] enablePusher: %@", @(enabled)); + + // Refuse to try & turn push on if we're not logged in, it's nonsensical. + if (!mxCredentials) + { + MXLogDebug(@"[MXKAccount][Push] enablePusher: Not setting push token because we're not logged in"); + return; + } + + // Check whether the Push Gateway URL has been configured. + if (!self.pushGatewayURL) + { + MXLogDebug(@"[MXKAccount][Push] enablePusher: Not setting pusher because the Push Gateway URL is undefined"); + return; + } + + if (!appId) + { + MXLogDebug(@"[MXKAccount][Push] enablePusher: Not setting pusher because pusher app id is undefined"); + return; + } + + NSString *appDisplayName = [NSString stringWithFormat:@"%@ (iOS)", [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleDisplayName"]]; + + NSString *b64Token = [token base64EncodedStringWithOptions:0]; + + NSString *deviceLang = [NSLocale preferredLanguages][0]; + + NSString * profileTag = [[NSUserDefaults standardUserDefaults] valueForKey:@"pusherProfileTag"]; + if (!profileTag) + { + profileTag = @""; + NSString *alphabet = @"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + for (int i = 0; i < 16; ++i) + { + unsigned char c = [alphabet characterAtIndex:arc4random() % alphabet.length]; + profileTag = [profileTag stringByAppendingFormat:@"%c", c]; + } + MXLogDebug(@"[MXKAccount][Push] enablePusher: Generated fresh profile tag: %@", profileTag); + [[NSUserDefaults standardUserDefaults] setValue:profileTag forKey:@"pusherProfileTag"]; + } + else + { + MXLogDebug(@"[MXKAccount][Push] enablePusher: Using existing profile tag: %@", profileTag); + } + + NSObject *kind = enabled ? @"http" : [NSNull null]; + + // Use the append flag to handle multiple accounts registration. + BOOL append = NO; + // Check whether a pusher is running for another account + NSArray *activeAccounts = [MXKAccountManager sharedManager].activeAccounts; + for (MXKAccount *account in activeAccounts) + { + if (![account.mxCredentials.userId isEqualToString:self.mxCredentials.userId] && account.pushNotificationServiceIsActive) + { + append = YES; + break; + } + } + MXLogDebug(@"[MXKAccount][Push] enablePusher: append flag: %d", append); + + MXRestClient *restCli = self.mxRestClient; + + [restCli setPusherWithPushkey:b64Token kind:kind appId:appId appDisplayName:appDisplayName deviceDisplayName:[[UIDevice currentDevice] name] profileTag:profileTag lang:deviceLang data:pushData append:append success:success failure:failure]; +} + +#pragma mark - InApp notifications + +- (void)listenToNotifications:(MXOnNotification)onNotification +{ + // Check conditions required to add notification listener + if (!mxSession || !onNotification) + { + return; + } + + // Remove existing listener (if any) + [self removeNotificationListener]; + + // Register on notification center + notificationCenterListener = [self.mxSession.notificationCenter listenToNotifications:^(MXEvent *event, MXRoomState *roomState, MXPushRule *rule) + { + // Apply first the event filter defined in the related room data source + MXKRoomDataSourceManager *roomDataSourceManager = [MXKRoomDataSourceManager sharedManagerForMatrixSession:self->mxSession]; + [roomDataSourceManager roomDataSourceForRoom:event.roomId create:NO onComplete:^(MXKRoomDataSource *roomDataSource) { + if (roomDataSource) + { + if (!roomDataSource.eventFormatter.eventTypesFilterForMessages || [roomDataSource.eventFormatter.eventTypesFilterForMessages indexOfObject:event.type] != NSNotFound) + { + // Check conditions to report this notification + if (nil == self->ignoredRooms || [self->ignoredRooms indexOfObject:event.roomId] == NSNotFound) + { + onNotification(event, roomState, rule); + } + } + } + }]; + }]; +} + +- (void)removeNotificationListener +{ + if (notificationCenterListener) + { + [self.mxSession.notificationCenter removeListener:notificationCenterListener]; + notificationCenterListener = nil; + } + ignoredRooms = nil; +} + +- (void)updateNotificationListenerForRoomId:(NSString*)roomID ignore:(BOOL)isIgnored +{ + if (isIgnored) + { + if (!ignoredRooms) + { + ignoredRooms = [[NSMutableArray alloc] init]; + } + [ignoredRooms addObject:roomID]; + } + else if (ignoredRooms) + { + [ignoredRooms removeObject:roomID]; + } +} + +#pragma mark - Internals + +- (void)launchInitialServerSync +{ + // Complete the session registration when store data is ready. + + // Cancel potential reachability observer and pending action + [[NSNotificationCenter defaultCenter] removeObserver:reachabilityObserver]; + reachabilityObserver = nil; + [initialServerSyncTimer invalidate]; + initialServerSyncTimer = nil; + + // Sanity check + if (!mxSession || (mxSession.state != MXSessionStateStoreDataReady && mxSession.state != MXSessionStateInitialSyncFailed)) + { + MXLogDebug(@"[MXKAccount] Initial server sync is applicable only when store data is ready to complete session initialisation"); + return; + } + + // Use /sync filter corresponding to current settings and homeserver capabilities + MXWeakify(self); + [self buildSyncFilter:^(MXFilterJSONModel *syncFilter) { + MXStrongifyAndReturnIfNil(self); + + // Make sure the filter is compatible with the previously used one + MXWeakify(self); + [self checkSyncFilterCompatibility:syncFilter completion:^(BOOL compatible) { + MXStrongifyAndReturnIfNil(self); + + if (!compatible) + { + // Else clear the cache + MXLogDebug(@"[MXKAccount] New /sync filter not compatible with previous one. Clear cache"); + + [self reload:YES]; + return; + } + + // Launch mxSession + MXWeakify(self); + [self.mxSession startWithSyncFilter:syncFilter onServerSyncDone:^{ + MXStrongifyAndReturnIfNil(self); + + MXLogDebug(@"[MXKAccount] %@: The session is ready. Matrix SDK session has been started in %0.fms.", self->mxCredentials.userId, [[NSDate date] timeIntervalSinceDate:self->openSessionStartDate] * 1000); + + [self setUserPresence:MXPresenceOnline andStatusMessage:nil completion:nil]; + + } failure:^(NSError *error) { + MXStrongifyAndReturnIfNil(self); + + MXLogDebug(@"[MXKAccount] Initial Sync failed. Error: %@", error); + + BOOL isClientTimeout = [error.domain isEqualToString:NSURLErrorDomain] && error.code == NSURLErrorTimedOut; + NSHTTPURLResponse *httpResponse = [MXHTTPOperation urlResponseFromError:error]; + BOOL isServerTimeout = httpResponse && [initialSyncSilentErrorsHTTPStatusCodes containsObject:@(httpResponse.statusCode)]; + + if (isClientTimeout || isServerTimeout) + { + // do not propagate this error to the client + // the request will be retried or postponed according to the reachability status + MXLogDebug(@"[MXKAccount] Initial sync failure did not propagated"); + } + else if (self->notifyOpenSessionFailure && error) + { + // Notify MatrixKit user only once + self->notifyOpenSessionFailure = NO; + NSString *myUserId = self.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + } + + // Check if it is a network connectivity issue + AFNetworkReachabilityManager *networkReachabilityManager = [AFNetworkReachabilityManager sharedManager]; + MXLogDebug(@"[MXKAccount] Network reachability: %d", networkReachabilityManager.isReachable); + + if (networkReachabilityManager.isReachable) + { + // The problem is not the network + // Postpone a new attempt in 10 sec + self->initialServerSyncTimer = [NSTimer scheduledTimerWithTimeInterval:10 target:self selector:@selector(launchInitialServerSync) userInfo:self repeats:NO]; + } + else + { + // The device is not connected to the internet, wait for the connection to be up again before retrying + // Add observer to launch a new attempt according to reachability. + self->reachabilityObserver = [[NSNotificationCenter defaultCenter] addObserverForName:AFNetworkingReachabilityDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) { + + NSNumber *statusItem = note.userInfo[AFNetworkingReachabilityNotificationStatusItem]; + if (statusItem) + { + AFNetworkReachabilityStatus reachabilityStatus = statusItem.integerValue; + if (reachabilityStatus == AFNetworkReachabilityStatusReachableViaWiFi || reachabilityStatus == AFNetworkReachabilityStatusReachableViaWWAN) + { + // New attempt + [self launchInitialServerSync]; + } + } + + }]; + } + }]; + }]; + }]; +} + +- (void)attemptDeviceDehydrationWithKeyData:(NSData *)keyData + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + [self attemptDeviceDehydrationWithKeyData:keyData retry:YES success:success failure:failure]; +} + +- (void)attemptDeviceDehydrationWithKeyData:(NSData *)keyData + retry:(BOOL)retry + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + if (keyData == nil) + { + MXLogWarning(@"[MXKAccount] attemptDeviceDehydrationWithRetry: no key provided for device dehydration"); + + if (failure) + { + failure(nil); + } + + return; + } + + MXLogDebug(@"[MXKAccount] attemptDeviceDehydrationWithRetry: starting device dehydration"); + [[MXKAccountManager sharedManager].dehydrationService dehydrateDeviceWithMatrixRestClient:mxRestClient crypto:mxSession.crypto dehydrationKey:keyData success:^(NSString *deviceId) { + MXLogDebug(@"[MXKAccount] attemptDeviceDehydrationWithRetry: device successfully dehydrated"); + + if (success) + { + success(); + } + } failure:^(NSError *error) { + if (retry) + { + [self attemptDeviceDehydrationWithKeyData:keyData retry:NO success:success failure:failure]; + MXLogError(@"[MXKAccount] attemptDeviceDehydrationWithRetry: device dehydration failed due to error: %@. Retrying.", error); + } + else + { + MXLogError(@"[MXKAccount] attemptDeviceDehydrationWithRetry: device dehydration failed due to error: %@", error); + + if (failure) + { + failure(error); + } + } + }]; +} + +- (void)onMatrixSessionStateChange +{ + if (mxSession.state == MXSessionStateRunning) + { + // Check if pause has been requested + if (isPauseRequested) + { + MXLogDebug(@"[MXKAccount] Apply the pending pause."); + [self pauseInBackgroundTask]; + return; + } + + // Check whether the session was not already running + if (!userUpdateListener) + { + // Register listener to user's information change + userUpdateListener = [mxSession.myUser listenToUserUpdate:^(MXEvent *event) { + // Consider events related to user's presence + if (event.eventType == MXEventTypePresence) + { + self->userPresence = [MXTools presence:event.content[@"presence"]]; + } + + // Here displayname or other information have been updated, post update notification. + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountUserInfoDidChangeNotification object:self->mxCredentials.userId]; + }]; + + // User information are just up-to-date (`mxSession` is running), post update notification. + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountUserInfoDidChangeNotification object:mxCredentials.userId]; + } + } + else if (mxSession.state == MXSessionStateStoreDataReady || mxSession.state == MXSessionStateSyncInProgress) + { + // Remove listener (if any), this action is required to handle correctly matrix sdk handler reload (see clear cache) + if (userUpdateListener) + { + [mxSession.myUser removeListener:userUpdateListener]; + userUpdateListener = nil; + } + else + { + // Here the initial server sync is in progress. The session is not running yet, but some user's information are available (from local storage). + // We post update notification to let observer take into account this user's information even if they may not be up-to-date. + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountUserInfoDidChangeNotification object:mxCredentials.userId]; + } + } + else if (mxSession.state == MXSessionStatePaused) + { + isPauseRequested = NO; + } + else if (mxSession.state == MXSessionStateUnknownToken) + { + // Logout this account + [[MXKAccountManager sharedManager] removeAccount:self completion:nil]; + } + else if (mxSession.state == MXSessionStateSoftLogout) + { + // Soft logout this account + [[MXKAccountManager sharedManager] softLogout:self]; + } +} + +- (void)prepareRESTClient +{ + if (!mxCredentials) + { + return; + } + + mxRestClient = [[MXRestClient alloc] initWithCredentials:mxCredentials andOnUnrecognizedCertificateBlock:^BOOL(NSData *certificate) { + + if (_onCertificateChangeBlock) + { + if (_onCertificateChangeBlock (self, certificate)) + { + // Update the certificate in credentials + self->mxCredentials.allowedCertificate = certificate; + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; + + return YES; + } + + self->mxCredentials.ignoredCertificate = certificate; + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; + } + return NO; + + }]; +} + +- (void)onDateTimeFormatUpdate +{ + if ([mxSession.roomSummaryUpdateDelegate isKindOfClass:MXKEventFormatter.class]) + { + MXKEventFormatter *eventFormatter = (MXKEventFormatter*)mxSession.roomSummaryUpdateDelegate; + + // Update the date and time formatters + [eventFormatter initDateTimeFormatters]; + + dispatch_group_t dispatchGroup = dispatch_group_create(); + + for (MXRoomSummary *summary in mxSession.roomsSummaries) + { + dispatch_group_enter(dispatchGroup); + [summary.mxSession eventWithEventId:summary.lastMessage.eventId + inRoom:summary.roomId + success:^(MXEvent *event) { + + if (event) + { + if (summary.lastMessage.others == nil) + { + summary.lastMessage.others = [NSMutableDictionary dictionary]; + } + summary.lastMessage.others[@"lastEventDate"] = [eventFormatter dateStringFromEvent:event withTime:YES]; + [self->mxSession.store storeSummaryForRoom:summary.roomId summary:summary]; + } + + dispatch_group_leave(dispatchGroup); + } failure:^(NSError *error) { + dispatch_group_leave(dispatchGroup); + }]; + } + + dispatch_group_notify(dispatchGroup, dispatch_get_main_queue(), ^{ + + // Commit store changes done + if ([self->mxSession.store respondsToSelector:@selector(commit)]) + { + [self->mxSession.store commit]; + } + + // Broadcast the change which concerns all the room summaries. + [[NSNotificationCenter defaultCenter] postNotificationName:kMXRoomSummaryDidChangeNotification object:nil userInfo:nil]; + + }); + } +} + +#pragma mark - Crypto +- (void)resetDeviceId +{ + mxCredentials.deviceId = nil; + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; +} + +#pragma mark - backgroundSync management + +- (void)cancelBackgroundSync +{ + if (self.backgroundSyncBgTask.isRunning) + { + MXLogDebug(@"[MXKAccount] The background Sync is cancelled."); + + if (mxSession) + { + if (mxSession.state == MXSessionStateBackgroundSyncInProgress) + { + [mxSession pause]; + } + } + + [self onBackgroundSyncDone:[NSError errorWithDomain:kMXKAccountErrorDomain code:0 userInfo:nil]]; + } +} + +- (void)onBackgroundSyncDone:(NSError*)error +{ + if (backgroundSyncTimer) + { + [backgroundSyncTimer invalidate]; + backgroundSyncTimer = nil; + } + + if (backgroundSyncFails && error) + { + backgroundSyncFails(error); + } + + if (backgroundSyncDone && !error) + { + backgroundSyncDone(); + } + + backgroundSyncDone = nil; + backgroundSyncFails = nil; + + // End background task + if (self.backgroundSyncBgTask.isRunning) + { + [self.backgroundSyncBgTask stop]; + self.backgroundSyncBgTask = nil; + } +} + +- (void)onBackgroundSyncTimerOut +{ + [self cancelBackgroundSync]; +} + +- (void)backgroundSync:(unsigned int)timeout success:(void (^)(void))success failure:(void (^)(NSError *))failure +{ + // Check whether a background mode handler has been set. + id handler = [MXSDKOptions sharedInstance].backgroundModeHandler; + if (handler) + { + // Only work when the application is suspended. + // Check conditions before launching background sync + if (mxSession && mxSession.state == MXSessionStatePaused) + { + MXLogDebug(@"[MXKAccount] starts a background Sync"); + + backgroundSyncDone = success; + backgroundSyncFails = failure; + + MXWeakify(self); + + self.backgroundSyncBgTask = [handler startBackgroundTaskWithName:@"[MXKAccount] backgroundSync:success:failure:" expirationHandler:^{ + + MXStrongifyAndReturnIfNil(self); + + MXLogDebug(@"[MXKAccount] the background Sync fails because of the bg task timeout"); + [self cancelBackgroundSync]; + }]; + + // ensure that the backgroundSync will be really done in the expected time + // the request could be done but the treatment could be long so add a timer to cancel it + // if it takes too much time + backgroundSyncTimer = [[NSTimer alloc] initWithFireDate:[NSDate dateWithTimeIntervalSinceNow:(timeout - 1) / 1000] + interval:0 + target:self + selector:@selector(onBackgroundSyncTimerOut) + userInfo:nil + repeats:NO]; + + [[NSRunLoop mainRunLoop] addTimer:backgroundSyncTimer forMode:NSDefaultRunLoopMode]; + + [mxSession backgroundSync:timeout success:^{ + MXLogDebug(@"[MXKAccount] the background Sync succeeds"); + [self onBackgroundSyncDone:nil]; + + } + failure:^(NSError* error) { + + MXLogDebug(@"[MXKAccount] the background Sync fails"); + [self onBackgroundSyncDone:error]; + + } + + ]; + } + else + { + MXLogDebug(@"[MXKAccount] cannot start background Sync (invalid state %tu)", mxSession.state); + failure([NSError errorWithDomain:kMXKAccountErrorDomain code:0 userInfo:nil]); + } + } + else + { + MXLogDebug(@"[MXKAccount] cannot start background Sync"); + failure([NSError errorWithDomain:kMXKAccountErrorDomain code:0 userInfo:nil]); + } +} + +#pragma mark - Sync filter + +- (void)supportLazyLoadOfRoomMembers:(void (^)(BOOL supportLazyLoadOfRoomMembers))completion +{ + void(^onUnsupportedLazyLoadOfRoomMembers)(NSError *) = ^(NSError *error) { + completion(NO); + }; + + // Check if the server supports LL sync filter + MXFilterJSONModel *filter = [self syncFilterWithLazyLoadOfRoomMembers:YES]; + [mxSession.store filterIdForFilter:filter success:^(NSString * _Nullable filterId) { + + if (filterId) + { + // The LL filter is already in the store. The HS supports LL + completion(YES); + } + else + { + // Check the Matrix versions supported by the HS + [self.mxSession supportedMatrixVersions:^(MXMatrixVersions *matrixVersions) { + + if (matrixVersions.supportLazyLoadMembers) + { + // The HS supports LL + completion(YES); + } + else + { + onUnsupportedLazyLoadOfRoomMembers(nil); + } + + } failure:onUnsupportedLazyLoadOfRoomMembers]; + } + } failure:onUnsupportedLazyLoadOfRoomMembers]; +} + +/** + Build the sync filter according to application settings and HS capability. + + @param completion the block providing the sync filter to use. + */ +- (void)buildSyncFilter:(void (^)(MXFilterJSONModel *syncFilter))completion +{ + // Check settings + BOOL syncWithLazyLoadOfRoomMembersSetting = [MXKAppSettings standardAppSettings].syncWithLazyLoadOfRoomMembers; + + if (syncWithLazyLoadOfRoomMembersSetting) + { + // Check if the server supports LL sync filter before enabling it + [self supportLazyLoadOfRoomMembers:^(BOOL supportLazyLoadOfRoomMembers) { + + if (supportLazyLoadOfRoomMembers) + { + completion([self syncFilterWithLazyLoadOfRoomMembers:YES]); + } + else + { + // No support from the HS + // Disable the setting. That will avoid to make a request at every startup + [MXKAppSettings standardAppSettings].syncWithLazyLoadOfRoomMembers = NO; + completion([self syncFilterWithLazyLoadOfRoomMembers:NO]); + } + }]; + } + else + { + completion([self syncFilterWithLazyLoadOfRoomMembers:NO]); + } +} + +/** + Compute the sync filter to use according to the device screen size. + + @param syncWithLazyLoadOfRoomMembers enable LL support. + @return the sync filter to use. + */ +- (MXFilterJSONModel *)syncFilterWithLazyLoadOfRoomMembers:(BOOL)syncWithLazyLoadOfRoomMembers +{ + MXFilterJSONModel *syncFilter; + NSUInteger limit = 10; + + // Define a message limit for /sync requests that is high enough so that + // a full page of room messages can be displayed without an additional + // server request. + + // This limit value depends on the device screen size. So, the rough rule is: + // - use 10 for small phones (5S/SE) + // - use 15 for phones (6/6S/7/8) + // - use 20 for phablets (.Plus/X/XR/XS/XSMax) + // - use 30 for iPads + UIUserInterfaceIdiom userInterfaceIdiom = [[UIDevice currentDevice] userInterfaceIdiom]; + if (userInterfaceIdiom == UIUserInterfaceIdiomPhone) + { + CGFloat screenHeight = [[UIScreen mainScreen] nativeBounds].size.height; + if (screenHeight == 1334) // 6/6S/7/8 screen height + { + limit = 15; + } + else if (screenHeight > 1334) + { + limit = 20; + } + } + else if (userInterfaceIdiom == UIUserInterfaceIdiomPad) + { + limit = 30; + } + + // Set that limit in the filter + if (syncWithLazyLoadOfRoomMembers) + { + syncFilter = [MXFilterJSONModel syncFilterForLazyLoadingWithMessageLimit:limit]; + } + else + { + syncFilter = [MXFilterJSONModel syncFilterWithMessageLimit:limit]; + } + + // TODO: We could extend the filter to match other settings (self.showAllEventsInRoomHistory, + // self.eventsFilterForMessages, etc). + + return syncFilter; +} + + +/** + Check the sync filter we want to use is compatible with the one previously used. + + @param syncFilter the sync filter to use. + @param completion the block called to indicated the compatibility. + */ +- (void)checkSyncFilterCompatibility:(MXFilterJSONModel*)syncFilter completion:(void (^)(BOOL compatible))completion +{ + // There is no compatibility issue if no /sync was done before + if (!mxSession.store.eventStreamToken) + { + completion(YES); + } + + // Check the filter we want to use is compatible with the one previously used + else if (!syncFilter && !mxSession.syncFilterId) + { + // A nil filter implies a nil mxSession.syncFilterId. So, there is no filter change + completion(YES); + } + else if (!syncFilter || !mxSession.syncFilterId) + { + // Change from no filter with using a filter or vice-versa. So, there is a filter change + MXLogDebug(@"[MXKAccount] checkSyncFilterCompatibility: Incompatible filter. New or old is nil. mxSession.syncFilterId: %@ - syncFilter: %@", + mxSession.syncFilterId, syncFilter.JSONDictionary); + completion(NO); + } + else + { + // Check the filter is the one previously set + // It must be already in the store + MXWeakify(self); + [mxSession.store filterIdForFilter:syncFilter success:^(NSString * _Nullable filterId) { + MXStrongifyAndReturnIfNil(self); + + // Note: We could be more tolerant here + // We could accept filter hot change if the change is limited to the `limit` filter value + // But we do not have this requirement yet + BOOL compatible = [filterId isEqualToString:self.mxSession.syncFilterId]; + if (!compatible) + { + MXLogDebug(@"[MXKAccount] checkSyncFilterCompatibility: Incompatible filter ids. mxSession.syncFilterId: %@ - store.filterId: %@ - syncFilter: %@", + self.mxSession.syncFilterId, filterId, syncFilter.JSONDictionary); + } + completion(compatible); + + } failure:^(NSError * _Nullable error) { + // Should never happen + completion(NO); + }]; + } +} + + +#pragma mark - Identity server updates + +- (void)registerAccountDataDidChangeIdentityServerNotification +{ + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleAccountDataDidChangeIdentityServerNotification:) name:kMXSessionAccountDataDidChangeIdentityServerNotification object:nil]; +} + +- (void)handleAccountDataDidChangeIdentityServerNotification:(NSNotification*)notification +{ + MXSession *mxSession = notification.object; + if (mxSession == self.mxSession) + { + if (![mxCredentials.identityServer isEqualToString:self.mxSession.accountDataIdentityServer]) + { + _identityServerURL = self.mxSession.accountDataIdentityServer; + mxCredentials.identityServer = _identityServerURL; + mxCredentials.identityServerAccessToken = nil; + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; + } + } +} + + +#pragma mark - Identity server Access Token updates + +- (void)identityService:(MXIdentityService *)identityService didUpdateAccessToken:(NSString *)accessToken +{ + mxCredentials.identityServerAccessToken = accessToken; +} + +- (void)registerIdentityServiceDidChangeAccessTokenNotification +{ + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleIdentityServiceDidChangeAccessTokenNotification:) name:MXIdentityServiceDidChangeAccessTokenNotification object:nil]; +} + +- (void)handleIdentityServiceDidChangeAccessTokenNotification:(NSNotification*)notification +{ + NSDictionary *userInfo = notification.userInfo; + + NSString *userId = userInfo[MXIdentityServiceNotificationUserIdKey]; + NSString *identityServer = userInfo[MXIdentityServiceNotificationIdentityServerKey]; + NSString *accessToken = userInfo[MXIdentityServiceNotificationAccessTokenKey]; + + if (userId && identityServer && accessToken && [mxCredentials.identityServer isEqualToString:identityServer]) + { + mxCredentials.identityServerAccessToken = accessToken; + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Account/MXKAccountManager.h b/Riot/Modules/MatrixKit/Models/Account/MXKAccountManager.h new file mode 100644 index 000000000..70c4857c9 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccountManager.h @@ -0,0 +1,218 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKAccount.h" + +/** + Posted when the user logged in with a matrix account. + The notification object is the new added account. + */ +extern NSString *const kMXKAccountManagerDidAddAccountNotification; + +/** + Posted when an existing account is logged out. + The notification object is the removed account. + */ +extern NSString *const kMXKAccountManagerDidRemoveAccountNotification; + +/** + Posted when an existing account is soft logged out. + The notification object is the account. + */ +extern NSString *const kMXKAccountManagerDidSoftlogoutAccountNotification; + +/** + Used to identify the type of data when requesting MXKeyProvider + */ +extern NSString *const MXKAccountManagerDataType; + +/** + `MXKAccountManager` manages a pool of `MXKAccount` instances. + */ +@interface MXKAccountManager : NSObject + +/** + The class of store used to open matrix session for the accounts. This class must be conformed to MXStore protocol. + By default this class is MXFileStore. + */ +@property (nonatomic) Class storeClass; + +/** + List of all available accounts (enabled and disabled). + */ +@property (nonatomic, readonly) NSArray *accounts; + +/** + List of active accounts (only enabled accounts) + */ +@property (nonatomic, readonly) NSArray *activeAccounts; + +/** + The device token used for Apple Push Notification Service registration. + */ +@property (nonatomic, copy) NSData *apnsDeviceToken; + +/** + The APNS status: YES when app is registered for remote notif, and device token is known. + */ +@property (nonatomic) BOOL isAPNSAvailable; + +/** + The device token used for Push notifications registration (PushKit support). + */ +@property (nonatomic, copy, readonly) NSData *pushDeviceToken; + +/** + The current options of the Push notifications based on PushKit. + */ +@property (nonatomic, copy, readonly) NSDictionary *pushOptions; + +/** + Set the push token and the potential push options. + For example, for clients that want to go & fetch the body of the event themselves anyway, + the key-value `format: event_id_only` may be used in `pushOptions` dictionary to tell the + HTTP pusher to send just the event_id of the event it's notifying about, the room id and + the notification counts. + + @param pushDeviceToken the push token. + @param pushOptions dictionary of the push options (may be nil). + */ +- (void)setPushDeviceToken:(NSData *)pushDeviceToken withPushOptions:(NSDictionary *)pushOptions; + +/** + The PushKit status: YES when app is registered for push notif, and push token is known. + */ +@property (nonatomic) BOOL isPushAvailable; + +@property (nonatomic, readonly) MXDehydrationService *dehydrationService; + +/** + Retrieve the MXKAccounts manager. + + @return the MXKAccounts manager. + */ ++ (MXKAccountManager *)sharedManager; + +/** + Check for each enabled account if a matrix session is already opened. + Open a matrix session for each enabled account which doesn't have a session. + The developper must set 'storeClass' before the first call of this method + if the default class is not suitable. + */ +- (void)prepareSessionForActiveAccounts; + +/** + Save a snapshot of the current accounts. + */ +- (void)saveAccounts; + +/** + Add an account and save the new account list. Optionally a matrix session may be opened for the provided account. + + @param account a matrix account. + @param openSession YES to open a matrix session (this value is ignored if the account is disabled). + */ +- (void)addAccount:(MXKAccount *)account andOpenSession:(BOOL)openSession; + +/** + Remove the provided account and save the new account list. This method is used in case of logout. + + @note equivalent to `removeAccount:sendLogoutRequest:completion:` method with `sendLogoutRequest` parameter to YES + + @param account a matrix account. + @param completion the block to execute at the end of the operation. + */ +- (void)removeAccount:(MXKAccount *)account completion:(void (^)(void))completion; + + +/** + Remove the provided account and save the new account list. This method is used in case of logout or account deactivation. + + @param account a matrix account. + @param sendLogoutRequest Indicate whether send logout request to homeserver. + @param completion the block to execute at the end of the operation. + */ +- (void)removeAccount:(MXKAccount*)account + sendLogoutRequest:(BOOL)sendLogoutRequest + completion:(void (^)(void))completion; + +/** + Log out and remove all the existing accounts + + @param completion the block to execute at the end of the operation. + */ +- (void)logoutWithCompletion:(void (^)(void))completion; + +/** + Soft logout an account. + + @param account a matrix account. + */ +- (void)softLogout:(MXKAccount*)account; + +/** + Hydrate an existing account by using the credentials provided. + + This updates account credentials and restarts the account session + + If the credentials belong to a different user from the account already stored, + the old account will be cleared automatically. + + @param account a matrix account. + @param credentials the new credentials. + */ +- (void)hydrateAccount:(MXKAccount*)account withCredentials:(MXCredentials*)credentials; + +/** + Retrieve the account for a user id. + + @param userId the user id. + @return the user's account (nil if no account exist). + */ +- (MXKAccount *)accountForUserId:(NSString *)userId; + +/** + Retrieve an account that knows the room with the passed id or alias. + + Note: The method is not accurate as it returns the first account that matches. + + @param roomIdOrAlias the room id or alias. + @return the user's account. Nil if no account matches. + */ +- (MXKAccount *)accountKnowingRoomWithRoomIdOrAlias:(NSString *)roomIdOrAlias; + +/** + Retrieve an account that knows the user with the passed id. + + Note: The method is not accurate as it returns the first account that matches. + + @param userId the user id. + @return the user's account. Nil if no account matches. + */ +- (MXKAccount *)accountKnowingUserWithUserId:(NSString *)userId; + +/** + Force the account manager to reload existing accounts from the local storage. + The account manager is supposed to handle itself the list of the accounts. + Call this method only when an account has been changed from an other application from the same group. + */ +- (void)forceReloadAccounts; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Account/MXKAccountManager.m b/Riot/Modules/MatrixKit/Models/Account/MXKAccountManager.m new file mode 100644 index 000000000..0763dab53 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccountManager.m @@ -0,0 +1,726 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAccountManager.h" +#import "MXKAppSettings.h" + +#import "MXKTools.h" + +static NSString *const kMXKAccountsKeyOld = @"accounts"; +static NSString *const kMXKAccountsKey = @"accountsV2"; + +NSString *const kMXKAccountManagerDidAddAccountNotification = @"kMXKAccountManagerDidAddAccountNotification"; +NSString *const kMXKAccountManagerDidRemoveAccountNotification = @"kMXKAccountManagerDidRemoveAccountNotification"; +NSString *const kMXKAccountManagerDidSoftlogoutAccountNotification = @"kMXKAccountManagerDidSoftlogoutAccountNotification"; +NSString *const MXKAccountManagerDataType = @"org.matrix.kit.MXKAccountManagerDataType"; + +@interface MXKAccountManager() +{ + /** + The list of all accounts (enabled and disabled). Each value is a `MXKAccount` instance. + */ + NSMutableArray *mxAccounts; +} + +@end + +@implementation MXKAccountManager + ++ (MXKAccountManager *)sharedManager +{ + static MXKAccountManager *sharedAccountManager = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedAccountManager = [[super allocWithZone:NULL] init]; + }); + + return sharedAccountManager; +} + +- (instancetype)init +{ + self = [super init]; + if (self) + { + _storeClass = [MXFileStore class]; + _dehydrationService = [MXDehydrationService new]; + + // Migrate old account file to new format + [self migrateAccounts]; + + // Load existing accounts from local storage + [self loadAccounts]; + } + return self; +} + +- (void)dealloc +{ + mxAccounts = nil; +} + +#pragma mark - + +- (void)prepareSessionForActiveAccounts +{ + for (MXKAccount *account in mxAccounts) + { + // Check whether the account is enabled. Open a new matrix session if none. + if (!account.isDisabled && !account.isSoftLogout && !account.mxSession) + { + MXLogDebug(@"[MXKAccountManager] openSession for %@ account", account.mxCredentials.userId); + + id store = [[_storeClass alloc] init]; + [account openSessionWithStore:store]; + } + } +} + +- (void)saveAccounts +{ + NSDate *startDate = [NSDate date]; + + MXLogDebug(@"[MXKAccountManager] saveAccounts..."); + + NSMutableData *data = [NSMutableData data]; + NSKeyedArchiver *encoder = [[NSKeyedArchiver alloc] initForWritingWithMutableData:data]; + + [encoder encodeObject:mxAccounts forKey:@"mxAccounts"]; + + [encoder finishEncoding]; + + [data setData:[self encryptData:data]]; + + BOOL result = [data writeToFile:[self accountFile] atomically:YES]; + + MXLogDebug(@"[MXKAccountManager] saveAccounts. Done (result: %@) in %.0fms", @(result), [[NSDate date] timeIntervalSinceDate:startDate] * 1000); +} + +- (void)addAccount:(MXKAccount *)account andOpenSession:(BOOL)openSession +{ + MXLogDebug(@"[MXKAccountManager] login (%@)", account.mxCredentials.userId); + + [mxAccounts addObject:account]; + [self saveAccounts]; + + // Check conditions to open a matrix session + if (openSession && !account.disabled) + { + // Open a new matrix session by default + MXLogDebug(@"[MXKAccountManager] openSession for %@ account (device %@)", account.mxCredentials.userId, account.mxCredentials.deviceId); + id store = [[_storeClass alloc] init]; + [account openSessionWithStore:store]; + } + + // Post notification + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountManagerDidAddAccountNotification object:account userInfo:nil]; +} + +- (void)removeAccount:(MXKAccount*)theAccount completion:(void (^)(void))completion +{ + [self removeAccount:theAccount sendLogoutRequest:YES completion:completion]; +} + +- (void)removeAccount:(MXKAccount*)theAccount + sendLogoutRequest:(BOOL)sendLogoutRequest + completion:(void (^)(void))completion +{ + MXLogDebug(@"[MXKAccountManager] logout (%@), send logout request to homeserver: %d", theAccount.mxCredentials.userId, sendLogoutRequest); + + // Close session and clear associated store. + [theAccount logoutSendingServerRequest:sendLogoutRequest completion:^{ + + // Retrieve the corresponding account in the internal array + MXKAccount* removedAccount = nil; + + for (MXKAccount *account in self->mxAccounts) + { + if ([account.mxCredentials.userId isEqualToString:theAccount.mxCredentials.userId]) + { + removedAccount = account; + break; + } + } + + if (removedAccount) + { + [self->mxAccounts removeObject:removedAccount]; + + [self saveAccounts]; + + // Post notification + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountManagerDidRemoveAccountNotification object:removedAccount userInfo:nil]; + } + + if (completion) + { + completion(); + } + + }]; +} + + +- (void)logoutWithCompletion:(void (^)(void))completion +{ + // Logout one by one the existing accounts + if (mxAccounts.count) + { + [self removeAccount:mxAccounts.lastObject completion:^{ + + // loop: logout the next existing account (if any) + [self logoutWithCompletion:completion]; + + }]; + + return; + } + + NSUserDefaults *sharedUserDefaults = [MXKAppSettings standardAppSettings].sharedUserDefaults; + + // Remove APNS device token + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"apnsDeviceToken"]; + + // Remove Push device token + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"pushDeviceToken"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"pushOptions"]; + + // Be sure that no account survive in local storage + [[NSUserDefaults standardUserDefaults] removeObjectForKey:kMXKAccountsKey]; + [sharedUserDefaults removeObjectForKey:kMXKAccountsKey]; + [[NSFileManager defaultManager] removeItemAtPath:[self accountFile] error:nil]; + + if (completion) + { + completion(); + } +} + +- (void)softLogout:(MXKAccount*)account +{ + [account softLogout]; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountManagerDidSoftlogoutAccountNotification + object:account + userInfo:nil]; +} + +- (void)hydrateAccount:(MXKAccount*)account withCredentials:(MXCredentials*)credentials +{ + MXLogDebug(@"[MXKAccountManager] hydrateAccount: %@", account.mxCredentials.userId); + + if ([account.mxCredentials.userId isEqualToString:credentials.userId]) + { + // Restart the account + [account hydrateWithCredentials:credentials]; + + MXLogDebug(@"[MXKAccountManager] hydrateAccount: Open session"); + + id store = [[_storeClass alloc] init]; + [account openSessionWithStore:store]; + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountManagerDidAddAccountNotification + object:account + userInfo:nil]; + } + else + { + MXLogDebug(@"[MXKAccountManager] hydrateAccount: Credentials given for another account: %@", credentials.userId); + + // Logout the old account and create a new one with the new credentials + [self removeAccount:account sendLogoutRequest:YES completion:nil]; + + MXKAccount *newAccount = [[MXKAccount alloc] initWithCredentials:credentials]; + [self addAccount:newAccount andOpenSession:YES]; + } +} + +- (MXKAccount *)accountForUserId:(NSString *)userId +{ + for (MXKAccount *account in mxAccounts) + { + if ([account.mxCredentials.userId isEqualToString:userId]) + { + return account; + } + } + return nil; +} + +- (MXKAccount *)accountKnowingRoomWithRoomIdOrAlias:(NSString *)roomIdOrAlias +{ + MXKAccount *theAccount = nil; + + NSArray *activeAccounts = self.activeAccounts; + + for (MXKAccount *account in activeAccounts) + { + if ([roomIdOrAlias hasPrefix:@"#"]) + { + if ([account.mxSession roomWithAlias:roomIdOrAlias]) + { + theAccount = account; + break; + } + } + else + { + if ([account.mxSession roomWithRoomId:roomIdOrAlias]) + { + theAccount = account; + break; + } + } + } + return theAccount; +} + +- (MXKAccount *)accountKnowingUserWithUserId:(NSString *)userId +{ + MXKAccount *theAccount = nil; + + NSArray *activeAccounts = self.activeAccounts; + + for (MXKAccount *account in activeAccounts) + { + if ([account.mxSession userWithUserId:userId]) + { + theAccount = account; + break; + } + } + return theAccount; +} + +#pragma mark - + +- (void)setStoreClass:(Class)storeClass +{ + // Sanity check + NSAssert([storeClass conformsToProtocol:@protocol(MXStore)], @"MXKAccountManager only manages store class that conforms to MXStore protocol"); + + _storeClass = storeClass; +} + +- (NSArray *)accounts +{ + return [mxAccounts copy]; +} + +- (NSArray *)activeAccounts +{ + NSMutableArray *activeAccounts = [NSMutableArray arrayWithCapacity:mxAccounts.count]; + for (MXKAccount *account in mxAccounts) + { + if (!account.disabled && !account.isSoftLogout) + { + [activeAccounts addObject:account]; + } + } + return activeAccounts; +} + +- (NSData *)apnsDeviceToken +{ + NSData *token = [[NSUserDefaults standardUserDefaults] objectForKey:@"apnsDeviceToken"]; + if (!token.length) + { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"apnsDeviceToken"]; + token = nil; + } + + MXLogDebug(@"[MXKAccountManager][Push] apnsDeviceToken: %@", [MXKTools logForPushToken:token]); + return token; +} + +- (void)setApnsDeviceToken:(NSData *)apnsDeviceToken +{ + MXLogDebug(@"[MXKAccountManager][Push] setApnsDeviceToken: %@", [MXKTools logForPushToken:apnsDeviceToken]); + + NSData *oldToken = self.apnsDeviceToken; + if (!apnsDeviceToken.length) + { + MXLogDebug(@"[MXKAccountManager][Push] setApnsDeviceToken: reset APNS device token"); + + if (oldToken) + { + // turn off the Apns flag for all accounts if any + for (MXKAccount *account in mxAccounts) + { + [account enablePushNotifications:NO success:nil failure:nil]; + } + } + + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"apnsDeviceToken"]; + } + else + { + NSArray *activeAccounts = self.activeAccounts; + + if (!oldToken) + { + MXLogDebug(@"[MXKAccountManager][Push] setApnsDeviceToken: set APNS device token"); + + [[NSUserDefaults standardUserDefaults] setObject:apnsDeviceToken forKey:@"apnsDeviceToken"]; + + // turn on the Apns flag for all accounts, when the Apns registration succeeds for the first time + for (MXKAccount *account in activeAccounts) + { + [account enablePushNotifications:YES success:nil failure:nil]; + } + } + else if (![oldToken isEqualToData:apnsDeviceToken]) + { + MXLogDebug(@"[MXKAccountManager][Push] setApnsDeviceToken: update APNS device token"); + + NSMutableArray *accountsWithAPNSPusher = [NSMutableArray new]; + + // Delete the pushers related to the old token + for (MXKAccount *account in activeAccounts) + { + if (account.hasPusherForPushNotifications) + { + [accountsWithAPNSPusher addObject:account]; + } + + [account enablePushNotifications:NO success:nil failure:nil]; + } + + // Update the token + [[NSUserDefaults standardUserDefaults] setObject:apnsDeviceToken forKey:@"apnsDeviceToken"]; + + // Refresh pushers with the new token. + for (MXKAccount *account in activeAccounts) + { + if ([accountsWithAPNSPusher containsObject:account]) + { + MXLogDebug(@"[MXKAccountManager][Push] setApnsDeviceToken: Resync APNS for %@ account", account.mxCredentials.userId); + [account enablePushNotifications:YES success:nil failure:nil]; + } + else + { + MXLogDebug(@"[MXKAccountManager][Push] setApnsDeviceToken: hasPusherForPushNotifications = NO for %@ account. Do not enable Push", account.mxCredentials.userId); + } + } + } + else + { + MXLogDebug(@"[MXKAccountManager][Push] setApnsDeviceToken: Same token. Nothing to do."); + } + } +} + +- (BOOL)isAPNSAvailable +{ + // [UIApplication isRegisteredForRemoteNotifications] tells whether your app can receive + // remote notifications or not. Receiving remote notifications does not guarantee it will + // display them to the user as they may have notifications set to deliver quietly. + + BOOL isRemoteNotificationsAllowed = NO; + + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication) + { + isRemoteNotificationsAllowed = [sharedApplication isRegisteredForRemoteNotifications]; + + MXLogDebug(@"[MXKAccountManager][Push] isAPNSAvailable: The user %@ remote notification", (isRemoteNotificationsAllowed ? @"allowed" : @"denied")); + } + + BOOL isAPNSAvailable = (isRemoteNotificationsAllowed && self.apnsDeviceToken); + + MXLogDebug(@"[MXKAccountManager][Push] isAPNSAvailable: %@", @(isAPNSAvailable)); + + return isAPNSAvailable; +} + +- (NSData *)pushDeviceToken +{ + NSData *token = [[NSUserDefaults standardUserDefaults] objectForKey:@"pushDeviceToken"]; + if (!token.length) + { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"pushDeviceToken"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"pushOptions"]; + token = nil; + } + + MXLogDebug(@"[MXKAccountManager][Push] pushDeviceToken: %@", [MXKTools logForPushToken:token]); + return token; +} + +- (NSDictionary *)pushOptions +{ + NSDictionary *pushOptions = [[NSUserDefaults standardUserDefaults] objectForKey:@"pushOptions"]; + + MXLogDebug(@"[MXKAccountManager][Push] pushOptions: %@", pushOptions); + return pushOptions; +} + +- (void)setPushDeviceToken:(NSData *)pushDeviceToken withPushOptions:(NSDictionary *)pushOptions +{ + MXLogDebug(@"[MXKAccountManager][Push] setPushDeviceToken: %@ withPushOptions: %@", [MXKTools logForPushToken:pushDeviceToken], pushOptions); + + NSData *oldToken = self.pushDeviceToken; + if (!pushDeviceToken.length) + { + MXLogDebug(@"[MXKAccountManager][Push] setPushDeviceToken: Reset Push device token"); + + if (oldToken) + { + // turn off the Push flag for all accounts if any + for (MXKAccount *account in mxAccounts) + { + [account enablePushKitNotifications:NO success:^{ + // make sure pusher really removed before losing token. + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"pushDeviceToken"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"pushOptions"]; + } failure:nil]; + } + } + } + else + { + NSArray *activeAccounts = self.activeAccounts; + + if (!oldToken) + { + MXLogDebug(@"[MXKAccountManager][Push] setPushDeviceToken: Set Push device token"); + + [[NSUserDefaults standardUserDefaults] setObject:pushDeviceToken forKey:@"pushDeviceToken"]; + if (pushOptions) + { + [[NSUserDefaults standardUserDefaults] setObject:pushOptions forKey:@"pushOptions"]; + } + else + { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"pushOptions"]; + } + + // turn on the Push flag for all accounts + for (MXKAccount *account in activeAccounts) + { + [account enablePushKitNotifications:YES success:nil failure:nil]; + } + } + else if (![oldToken isEqualToData:pushDeviceToken]) + { + MXLogDebug(@"[MXKAccountManager][Push] setPushDeviceToken: Update Push device token"); + + NSMutableArray *accountsWithPushKitPusher = [NSMutableArray new]; + + // Delete the pushers related to the old token + for (MXKAccount *account in activeAccounts) + { + if (account.hasPusherForPushKitNotifications) + { + [accountsWithPushKitPusher addObject:account]; + } + + [account enablePushKitNotifications:NO success:nil failure:nil]; + } + + // Update the token + [[NSUserDefaults standardUserDefaults] setObject:pushDeviceToken forKey:@"pushDeviceToken"]; + if (pushOptions) + { + [[NSUserDefaults standardUserDefaults] setObject:pushOptions forKey:@"pushOptions"]; + } + else + { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"pushOptions"]; + } + + // Refresh pushers with the new token. + for (MXKAccount *account in activeAccounts) + { + if ([accountsWithPushKitPusher containsObject:account]) + { + MXLogDebug(@"[MXKAccountManager][Push] setPushDeviceToken: Resync Push for %@ account", account.mxCredentials.userId); + [account enablePushKitNotifications:YES success:nil failure:nil]; + } + else + { + MXLogDebug(@"[MXKAccountManager][Push] setPushDeviceToken: hasPusherForPushKitNotifications = NO for %@ account. Do not enable Push", account.mxCredentials.userId); + } + } + } + else + { + MXLogDebug(@"[MXKAccountManager][Push] setPushDeviceToken: Same token. Nothing to do."); + } + } +} + +- (BOOL)isPushAvailable +{ + // [UIApplication isRegisteredForRemoteNotifications] tells whether your app can receive + // remote notifications or not. Receiving remote notifications does not guarantee it will + // display them to the user as they may have notifications set to deliver quietly. + + BOOL isRemoteNotificationsAllowed = NO; + + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication) + { + isRemoteNotificationsAllowed = [sharedApplication isRegisteredForRemoteNotifications]; + + MXLogDebug(@"[MXKAccountManager][Push] isPushAvailable: The user %@ remote notification", (isRemoteNotificationsAllowed ? @"allowed" : @"denied")); + } + + BOOL isPushAvailable = (isRemoteNotificationsAllowed && self.pushDeviceToken); + + MXLogDebug(@"[MXKAccountManager][Push] isPushAvailable: %@", @(isPushAvailable)); + return isPushAvailable; +} + +#pragma mark - + +// Return the path of the file containing stored MXAccounts array +- (NSString*)accountFile +{ + NSString *matrixKitCacheFolder = [MXKAppSettings cacheFolder]; + return [matrixKitCacheFolder stringByAppendingPathComponent:kMXKAccountsKey]; +} + +- (void)loadAccounts +{ + MXLogDebug(@"[MXKAccountManager] loadAccounts"); + + NSString *accountFile = [self accountFile]; + if ([[NSFileManager defaultManager] fileExistsAtPath:accountFile]) + { + NSDate *startDate = [NSDate date]; + + NSError *error = nil; + NSData* filecontent = [NSData dataWithContentsOfFile:accountFile options:(NSDataReadingMappedAlways | NSDataReadingUncached) error:&error]; + + if (!error) + { + // Decrypt data if encryption method is provided + NSData *unciphered = [self decryptData:filecontent]; + NSKeyedUnarchiver *decoder = [[NSKeyedUnarchiver alloc] initForReadingWithData:unciphered]; + mxAccounts = [decoder decodeObjectForKey:@"mxAccounts"]; + + if (!mxAccounts && [[MXKeyProvider sharedInstance] isEncryptionAvailableForDataOfType:MXKAccountManagerDataType]) + { + // This happens if the V2 file has not been encrypted -> read file content then save encrypted accounts + MXLogDebug(@"[MXKAccountManager] loadAccounts. Failed to read decrypted data: reading file data without encryption."); + decoder = [[NSKeyedUnarchiver alloc] initForReadingWithData:filecontent]; + mxAccounts = [decoder decodeObjectForKey:@"mxAccounts"]; + + if (mxAccounts) + { + MXLogDebug(@"[MXKAccountManager] loadAccounts. saving encrypted accounts"); + [self saveAccounts]; + } + } + } + + MXLogDebug(@"[MXKAccountManager] loadAccounts. %tu accounts loaded in %.0fms", mxAccounts.count, [[NSDate date] timeIntervalSinceDate:startDate] * 1000); + } + else + { + // Migration of accountData from sharedUserDefaults to a file + NSUserDefaults *sharedDefaults = [MXKAppSettings standardAppSettings].sharedUserDefaults; + + NSData *accountData = [sharedDefaults objectForKey:kMXKAccountsKey]; + if (!accountData) + { + // Migration of accountData from [NSUserDefaults standardUserDefaults], the first location storage + accountData = [[NSUserDefaults standardUserDefaults] objectForKey:kMXKAccountsKey]; + } + + if (accountData) + { + mxAccounts = [NSMutableArray arrayWithArray:[NSKeyedUnarchiver unarchiveObjectWithData:accountData]]; + [self saveAccounts]; + + MXLogDebug(@"[MXKAccountManager] loadAccounts: performed data migration"); + + // Now that data has been migrated, erase old location of accountData + [[NSUserDefaults standardUserDefaults] removeObjectForKey:kMXKAccountsKey]; + + [sharedDefaults removeObjectForKey:kMXKAccountsKey]; + } + } + + if (!mxAccounts) + { + MXLogDebug(@"[MXKAccountManager] loadAccounts. No accounts"); + mxAccounts = [NSMutableArray array]; + } +} + +- (void)forceReloadAccounts +{ + MXLogDebug(@"[MXKAccountManager] Force reload existing accounts from local storage"); + [self loadAccounts]; +} + +- (NSData*)encryptData:(NSData*)data +{ + // Exceptions are not caught as the key is always needed if the KeyProviderDelegate + // is provided. + MXKeyData *keyData = [[MXKeyProvider sharedInstance] requestKeyForDataOfType:MXKAccountManagerDataType isMandatory:YES expectedKeyType:kAes]; + if (keyData && [keyData isKindOfClass:[MXAesKeyData class]]) + { + MXAesKeyData *aesKey = (MXAesKeyData *) keyData; + NSData *cipher = [MXAes encrypt:data aesKey:aesKey.key iv:aesKey.iv error:nil]; + return cipher; + } + + MXLogDebug(@"[MXKAccountManager] encryptData: no key method provided for encryption."); + return data; +} + +- (NSData*)decryptData:(NSData*)data +{ + // Exceptions are not cached as the key is always needed if the KeyProviderDelegate + // is provided. + MXKeyData *keyData = [[MXKeyProvider sharedInstance] requestKeyForDataOfType:MXKAccountManagerDataType isMandatory:YES expectedKeyType:kAes]; + if (keyData && [keyData isKindOfClass:[MXAesKeyData class]]) + { + MXAesKeyData *aesKey = (MXAesKeyData *) keyData; + NSData *decrypt = [MXAes decrypt:data aesKey:aesKey.key iv:aesKey.iv error:nil]; + return decrypt; + } + + MXLogDebug(@"[MXKAccountManager] decryptData: no key method provided for decryption."); + return data; +} + +- (void)migrateAccounts +{ + NSString *pathOld = [[MXKAppSettings cacheFolder] stringByAppendingPathComponent:kMXKAccountsKeyOld]; + NSString *pathNew = [[MXKAppSettings cacheFolder] stringByAppendingPathComponent:kMXKAccountsKey]; + NSFileManager *fileManager = [NSFileManager defaultManager]; + if ([fileManager fileExistsAtPath:pathOld]) + { + if (![fileManager fileExistsAtPath:pathNew]) + { + MXLogDebug(@"[MXKAccountManager] migrateAccounts: reading account"); + mxAccounts = [NSKeyedUnarchiver unarchiveObjectWithFile:pathOld]; + MXLogDebug(@"[MXKAccountManager] migrateAccounts: writing to accountV2"); + [self saveAccounts]; + } + + MXLogDebug(@"[MXKAccountManager] migrateAccounts: removing account"); + [fileManager removeItemAtPath:pathOld error:nil]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKContact.h b/Riot/Modules/MatrixKit/Models/Contact/MXKContact.h new file mode 100644 index 000000000..3d8e8af24 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKContact.h @@ -0,0 +1,170 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import + +#import "MXKCellData.h" + +#import "MXKEmail.h" +#import "MXKPhoneNumber.h" + +/** + Posted when the contact thumbnail is updated. + The notification object is a contact Id. + */ +extern NSString *const kMXKContactThumbnailUpdateNotification; + +extern NSString *const kMXKContactLocalContactPrefixId; +extern NSString *const kMXKContactMatrixContactPrefixId; +extern NSString *const kMXKContactDefaultContactPrefixId; + +@interface MXKContact : MXKCellData + +/** + The unique identifier + */ +@property (nonatomic, readonly) NSString * contactID; + +/** + The display name + */ +@property (nonatomic, readwrite) NSString *displayName; + +/** + The sorting display name built by trimming the symbols [_!~`@#$%^&*-+();:={}[],.<>?\/"'] from the display name. + */ +@property (nonatomic) NSString* sortingDisplayName; + +/** + The contact thumbnail. Default size: 256 X 256 pixels + */ +@property (nonatomic, copy, readonly) UIImage *thumbnail; + +/** + YES if the contact does not exist in the contacts book + the contact has been created from a MXUser or MXRoomThirdPartyInvite + */ +@property (nonatomic) BOOL isMatrixContact; + +/** + YES if the contact is coming from MXRoomThirdPartyInvite event (NO by default). + */ +@property (nonatomic) BOOL isThirdPartyInvite; + +/** + The array of MXKPhoneNumber + */ +@property (nonatomic, readonly) NSArray *phoneNumbers; + +/** + The array of MXKEmail + */ +@property (nonatomic, readonly) NSArray *emailAddresses; + +/** + The array of matrix identifiers + */ +@property (nonatomic, readonly) NSArray* matrixIdentifiers; + +/** + The matrix avatar url used (if any) to build the current thumbnail, nil by default. + */ +@property (nonatomic, readonly) NSString* matrixAvatarURL; + +/** + Reset the current thumbnail if it is retrieved from a matrix url. May be used in case of the matrix avatar url change. + A new thumbnail will be automatically restored from the contact data. + */ +- (void)resetMatrixThumbnail; + +/** + The contact ID from native phonebook record + */ ++ (NSString*)contactID:(ABRecordRef)record; + +/** + Create a local contact from a device contact + + @param record device contact id + @return MXKContact instance + */ +- (id)initLocalContactWithABRecord:(ABRecordRef)record; + +/** + Create a matrix contact with the dedicated info + + @param displayName the contact display name + @param matrixID the contact matrix id + @return MXKContact instance + */ +- (id)initMatrixContactWithDisplayName:(NSString*)displayName andMatrixID:(NSString*)matrixID; + +/** + Create a matrix contact with the dedicated info + + @param displayName the contact display name + @param matrixID the contact matrix id + @param matrixAvatarURL the matrix avatar url + @return MXKContact instance + */ +- (id)initMatrixContactWithDisplayName:(NSString*)displayName matrixID:(NSString*)matrixID andMatrixAvatarURL:(NSString*)matrixAvatarURL; + +/** + Create a contact with the dedicated info + + @param displayName the contact display name + @param emails an array of emails + @param phones an array of phone numbers + @param thumbnail the contact thumbnail + @return MXKContact instance + */ +- (id)initContactWithDisplayName:(NSString*)displayName + emails:(NSArray *)emails + phoneNumbers:(NSArray *)phones + andThumbnail:(UIImage *)thumbnail; + +/** + The contact thumbnail with a prefered size. + + If the thumbnail is already loaded, this method returns this one by ignoring prefered size. + The prefered size is used only if a server request is required. + + @return thumbnail with a prefered size + */ +- (UIImage*)thumbnailWithPreferedSize:(CGSize)size; + +/** + Tell whether a component of the contact's displayName, or one of his matrix id/email has the provided prefix. + + @param prefix a non empty string. + @return YES when at least one matrix id, email or a component of the display name has this prefix. + */ +- (BOOL)hasPrefix:(NSString*)prefix; + +/** + Check if the patterns can match with this contact + */ +- (BOOL)matchedWithPatterns:(NSArray*)patterns; + +/** + The default ISO 3166-1 country code used to internationalize the contact phone numbers. + */ +@property (nonatomic) NSString *defaultCountryCode; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKContact.m b/Riot/Modules/MatrixKit/Models/Contact/MXKContact.m new file mode 100644 index 000000000..2b1e57727 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKContact.m @@ -0,0 +1,659 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKContact.h" + +#import "MXKEmail.h" +#import "MXKPhoneNumber.h" + +NSString *const kMXKContactThumbnailUpdateNotification = @"kMXKContactThumbnailUpdateNotification"; + +NSString *const kMXKContactLocalContactPrefixId = @"Local_"; +NSString *const kMXKContactMatrixContactPrefixId = @"Matrix_"; +NSString *const kMXKContactDefaultContactPrefixId = @"Default_"; + +@interface MXKContact() +{ + UIImage* contactThumbnail; + UIImage* matrixThumbnail; + + // The matrix id of the contact (used when the contact is not defined in the contacts book) + MXKContactField *matrixIdField; +} +@end + +@implementation MXKContact +@synthesize isMatrixContact, isThirdPartyInvite; + ++ (NSString*)contactID:(ABRecordRef)record +{ + return [NSString stringWithFormat:@"%@%d", kMXKContactLocalContactPrefixId, ABRecordGetRecordID(record)]; +} + +- (id)init +{ + self = [super init]; + if (self) + { + matrixIdField = nil; + isMatrixContact = NO; + _matrixAvatarURL = nil; + + isThirdPartyInvite = NO; + } + + return self; +} + +- (id)initLocalContactWithABRecord:(ABRecordRef)record +{ + self = [self init]; + if (self) + { + // compute a contact ID + _contactID = [MXKContact contactID:record]; + + // use the contact book display name + _displayName = (__bridge NSString*) ABRecordCopyCompositeName(record); + + // avoid nil display name + // the display name is used to sort contacts + if (!_displayName) + { + _displayName = @""; + } + + // extract the phone numbers and their related label + ABMultiValueRef multi = ABRecordCopyValue(record, kABPersonPhoneProperty); + CFIndex nCount = ABMultiValueGetCount(multi); + NSMutableArray* pns = [[NSMutableArray alloc] initWithCapacity:nCount]; + + for (int i = 0; i < nCount; i++) + { + CFTypeRef phoneRef = ABMultiValueCopyValueAtIndex(multi, i); + NSString *phoneVal = (__bridge NSString*)phoneRef; + + // sanity check + if (0 != [phoneVal length]) + { + CFStringRef lblRef = ABMultiValueCopyLabelAtIndex(multi, i); + CFStringRef localizedLblRef = nil; + NSString *lbl = @""; + + if (lblRef != nil) + { + localizedLblRef = ABAddressBookCopyLocalizedLabel(lblRef); + if (localizedLblRef) + { + lbl = (__bridge NSString*)localizedLblRef; + } + else + { + lbl = (__bridge NSString*)lblRef; + } + } + else + { + localizedLblRef = ABAddressBookCopyLocalizedLabel(kABOtherLabel); + if (localizedLblRef) + { + lbl = (__bridge NSString*)localizedLblRef; + } + } + + [pns addObject:[[MXKPhoneNumber alloc] initWithTextNumber:phoneVal type:lbl contactID:_contactID matrixID:nil]]; + + if (lblRef) + { + CFRelease(lblRef); + } + if (localizedLblRef) + { + CFRelease(localizedLblRef); + } + } + + // release meory + if (phoneRef) + { + CFRelease(phoneRef); + } + } + + CFRelease(multi); + _phoneNumbers = pns; + + // extract the emails + multi = ABRecordCopyValue(record, kABPersonEmailProperty); + nCount = ABMultiValueGetCount(multi); + + NSMutableArray *emails = [[NSMutableArray alloc] initWithCapacity:nCount]; + + for (int i = 0; i < nCount; i++) + { + CFTypeRef emailValRef = ABMultiValueCopyValueAtIndex(multi, i); + NSString *emailVal = (__bridge NSString*)emailValRef; + + // sanity check + if ((nil != emailVal) && (0 != [emailVal length])) + { + CFStringRef lblRef = ABMultiValueCopyLabelAtIndex(multi, i); + CFStringRef localizedLblRef = nil; + NSString *lbl = @""; + + if (lblRef != nil) + { + localizedLblRef = ABAddressBookCopyLocalizedLabel(lblRef); + + if (localizedLblRef) + { + lbl = (__bridge NSString*)localizedLblRef; + } + else + { + lbl = (__bridge NSString*)lblRef; + } + } + else + { + localizedLblRef = ABAddressBookCopyLocalizedLabel(kABOtherLabel); + if (localizedLblRef) + { + lbl = (__bridge NSString*)localizedLblRef; + } + } + + [emails addObject: [[MXKEmail alloc] initWithEmailAddress:emailVal type:lbl contactID:_contactID matrixID:nil]]; + + if (lblRef) + { + CFRelease(lblRef); + } + + if (localizedLblRef) + { + CFRelease(localizedLblRef); + } + } + + if (emailValRef) + { + CFRelease(emailValRef); + } + } + + CFRelease(multi); + + _emailAddresses = emails; + + // thumbnail/picture + // check whether the contact has a picture + if (ABPersonHasImageData(record)) + { + CFDataRef dataRef; + + dataRef = ABPersonCopyImageDataWithFormat(record, kABPersonImageFormatThumbnail); + if (dataRef) + { + contactThumbnail = [UIImage imageWithData:(__bridge NSData*)dataRef]; + CFRelease(dataRef); + } + } + } + return self; +} + +- (id)initMatrixContactWithDisplayName:(NSString*)displayName andMatrixID:(NSString*)matrixID +{ + self = [self init]; + if (self) + { + _contactID = [NSString stringWithFormat:@"%@%@", kMXKContactMatrixContactPrefixId, [[NSUUID UUID] UUIDString]]; + + // Sanity check + if (matrixID.length) + { + // used when the contact is not defined in the contacts book + matrixIdField = [[MXKContactField alloc] initWithContactID:_contactID matrixID:matrixID]; + isMatrixContact = YES; + } + + // _displayName must not be nil + // it is used to sort the contacts + if (displayName) + { + _displayName = displayName; + } + else + { + _displayName = @""; + } + } + + return self; +} + +- (id)initMatrixContactWithDisplayName:(NSString*)displayName matrixID:(NSString*)matrixID andMatrixAvatarURL:(NSString*)matrixAvatarURL +{ + self = [self initMatrixContactWithDisplayName:displayName andMatrixID:matrixID]; + if (self) + { + matrixIdField.matrixAvatarURL = matrixAvatarURL; + } + return self; +} + +- (id)initContactWithDisplayName:(NSString*)displayName + emails:(NSArray *)emails + phoneNumbers:(NSArray *)phones + andThumbnail:(UIImage *)thumbnail +{ + self = [self init]; + if (self) + { + _contactID = [NSString stringWithFormat:@"%@%@", kMXKContactDefaultContactPrefixId, [[NSUUID UUID] UUIDString]]; + + // _displayName must not be nil + // it is used to sort the contacts + if (displayName) + { + _displayName = displayName; + } + else + { + _displayName = @""; + } + + _emailAddresses = emails; + _phoneNumbers = phones; + + contactThumbnail = thumbnail; + } + + return self; +} + +#pragma mark - + +- (NSString*)sortingDisplayName +{ + if (!_sortingDisplayName) + { + // Sanity check - display name should not be nil here + if (self.displayName) + { + NSCharacterSet *specialCharacterSet = [NSCharacterSet characterSetWithCharactersInString:@"_!~`@#$%^&*-+();:={}[],.<>?\\/\"\'"]; + + _sortingDisplayName = [self.displayName stringByTrimmingCharactersInSet:specialCharacterSet]; + } + else + { + return @""; + } + } + + return _sortingDisplayName; +} + +- (BOOL)hasPrefix:(NSString*)prefix +{ + prefix = [prefix lowercaseString]; + + // Check first display name + if (_displayName.length) + { + NSString *lowercaseString = [_displayName lowercaseString]; + if ([lowercaseString hasPrefix:prefix]) + { + return YES; + } + + NSArray *components = [lowercaseString componentsSeparatedByString:@" "]; + for (NSString *component in components) + { + NSString *theComponent = [component stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if ([theComponent hasPrefix:prefix]) + { + return YES; + } + } + } + + // Check matrix identifiers + NSArray *identifiers = self.matrixIdentifiers; + NSString *idPrefix = prefix; + if (![prefix hasPrefix:@"@"]) + { + idPrefix = [NSString stringWithFormat:@"@%@", prefix]; + } + + for (NSString* mxId in identifiers) + { + if ([[mxId lowercaseString] hasPrefix:idPrefix]) + { + return YES; + } + } + + // Check email + for (MXKEmail* email in _emailAddresses) + { + if ([email.emailAddress hasPrefix:prefix]) + { + return YES; + } + } + + // Check phones + for (MXKPhoneNumber* phone in _phoneNumbers) + { + if ([phone hasPrefix:prefix]) + { + return YES; + } + } + + return NO; +} + +- (BOOL)matchedWithPatterns:(NSArray*)patterns +{ + BOOL matched = NO; + + if (patterns.count > 0) + { + matched = YES; + + // test first display name + for (NSString* pattern in patterns) + { + if ([_displayName rangeOfString:pattern options:NSCaseInsensitiveSearch].location == NSNotFound) + { + matched = NO; + break; + } + } + + NSArray *identifiers = self.matrixIdentifiers; + if (!matched && identifiers.count > 0) + { + for (NSString* mxId in identifiers) + { + // Consider only the first part of the matrix id (ignore homeserver name) + NSRange range = [mxId rangeOfString:@":"]; + if (range.location != NSNotFound) + { + NSString *mxIdName = [mxId substringToIndex:range.location]; + for (NSString* pattern in patterns) + { + if ([mxIdName rangeOfString:pattern options:NSCaseInsensitiveSearch].location != NSNotFound) + { + matched = YES; + break; + } + } + + if (matched) + { + break; + } + } + } + } + + if (!matched && _phoneNumbers.count > 0) + { + for (MXKPhoneNumber* phonenumber in _phoneNumbers) + { + if ([phonenumber matchedWithPatterns:patterns]) + { + matched = YES; + break; + } + } + } + + if (!matched && _emailAddresses.count > 0) + { + for (MXKEmail* email in _emailAddresses) + { + if ([email matchedWithPatterns:patterns]) + { + matched = YES; + break; + } + } + } + } + else + { + // if there is no pattern to search, it should always matched + matched = YES; + } + + return matched; +} + +- (void)setDefaultCountryCode:(NSString *)defaultCountryCode +{ + for (MXKPhoneNumber* phonenumber in _phoneNumbers) + { + phonenumber.defaultCountryCode = defaultCountryCode; + } + + _defaultCountryCode = defaultCountryCode; +} + +#pragma mark - getter/setter + +- (NSArray*)matrixIdentifiers +{ + NSMutableArray* identifiers = [[NSMutableArray alloc] init]; + + if (matrixIdField) + { + [identifiers addObject:matrixIdField.matrixID]; + } + + for (MXKEmail* email in _emailAddresses) + { + if (email.matrixID && ([identifiers indexOfObject:email.matrixID] == NSNotFound)) + { + [identifiers addObject:email.matrixID]; + } + } + + for (MXKPhoneNumber* pn in _phoneNumbers) + { + if (pn.matrixID && ([identifiers indexOfObject:pn.matrixID] == NSNotFound)) + { + [identifiers addObject:pn.matrixID]; + } + } + + return identifiers; +} + +- (void)setDisplayName:(NSString *)displayName +{ + // a display name must not be emptied + // it is used to sort the contacts + if (displayName.length == 0) + { + _displayName = _contactID; + } + else + { + _displayName = displayName; + } +} + +- (void)resetMatrixThumbnail +{ + matrixThumbnail = nil; + _matrixAvatarURL = nil; + + // Reset the avatar in the contact fields too. + [matrixIdField resetMatrixAvatar]; + + for (MXKEmail* email in _emailAddresses) + { + [email resetMatrixAvatar]; + } +} + +- (UIImage*)thumbnailWithPreferedSize:(CGSize)size +{ + // Consider first the local thumbnail if any. + if (contactThumbnail) + { + return contactThumbnail; + } + + // Check whether a matrix thumbnail is already found. + if (matrixThumbnail) + { + return matrixThumbnail; + } + + // Look for a thumbnail from the matrix identifiers + MXKContactField* firstField = matrixIdField; + if (firstField) + { + if (firstField.avatarImage) + { + matrixThumbnail = firstField.avatarImage; + _matrixAvatarURL = firstField.matrixAvatarURL; + return matrixThumbnail; + } + } + + // try to replace the thumbnail by the matrix one + if (_emailAddresses.count > 0) + { + // list the linked email + // search if one email field has a dedicated thumbnail + for (MXKEmail* email in _emailAddresses) + { + if (email.avatarImage) + { + matrixThumbnail = email.avatarImage; + _matrixAvatarURL = email.matrixAvatarURL; + return matrixThumbnail; + } + else if (!firstField && email.matrixID) + { + firstField = email; + } + } + } + + if (_phoneNumbers.count > 0) + { + // list the linked phones + // search if one phone field has a dedicated thumbnail + for (MXKPhoneNumber* phoneNb in _phoneNumbers) + { + if (phoneNb.avatarImage) + { + matrixThumbnail = phoneNb.avatarImage; + _matrixAvatarURL = phoneNb.matrixAvatarURL; + return matrixThumbnail; + } + else if (!firstField && phoneNb.matrixID) + { + firstField = phoneNb; + } + } + } + + // if no thumbnail has been found + // try to load the first field one + if (firstField) + { + // should be retrieved by the cell info + [firstField loadAvatarWithSize:size]; + } + + return nil; +} + +- (UIImage*)thumbnail +{ + return [self thumbnailWithPreferedSize:CGSizeMake(256, 256)]; +} + +#pragma mark NSCoding + +- (id)initWithCoder:(NSCoder *)coder +{ + _contactID = [coder decodeObjectForKey:@"contactID"]; + _displayName = [coder decodeObjectForKey:@"displayName"]; + + matrixIdField = [coder decodeObjectForKey:@"matrixIdField"]; + + _phoneNumbers = [coder decodeObjectForKey:@"phoneNumbers"]; + _emailAddresses = [coder decodeObjectForKey:@"emailAddresses"]; + + NSData *data = [coder decodeObjectForKey:@"contactThumbnail"]; + if (!data) + { + // Check the legacy storage. + data = [coder decodeObjectForKey:@"contactBookThumbnail"]; + } + + if (data) + { + contactThumbnail = [UIImage imageWithData:data]; + } + + return self; +} + +- (void)encodeWithCoder:(NSCoder *)coder +{ + + [coder encodeObject:_contactID forKey:@"contactID"]; + [coder encodeObject:_displayName forKey:@"displayName"]; + + if (matrixIdField) + { + [coder encodeObject:matrixIdField forKey:@"matrixIdField"]; + } + + if (_phoneNumbers.count) + { + [coder encodeObject:_phoneNumbers forKey:@"phoneNumbers"]; + } + + if (_emailAddresses.count) + { + [coder encodeObject:_emailAddresses forKey:@"emailAddresses"]; + } + + if (contactThumbnail) + { + @autoreleasepool + { + NSData *data = UIImageJPEGRepresentation(contactThumbnail, 0.8); + [coder encodeObject:data forKey:@"contactThumbnail"]; + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKContactField.h b/Riot/Modules/MatrixKit/Models/Contact/MXKContactField.h new file mode 100644 index 000000000..0fa4107d2 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKContactField.h @@ -0,0 +1,50 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +@interface MXKContactField : NSObject + +/** + The identifier of the contact to whom the data belongs to. + */ +@property (nonatomic, readonly) NSString *contactID; +/** + The linked matrix identifier if any + */ +@property (nonatomic, readwrite) NSString *matrixID; +/** + The matrix avatar url (Matrix Content URI), nil by default. + */ +@property (nonatomic) NSString* matrixAvatarURL; +/** + The current avatar downloaded by using the avatar url if any + */ +@property (nonatomic, readonly) UIImage *avatarImage; + +- (id)initWithContactID:(NSString*)contactID matrixID:(NSString*)matrixID; + +- (void)loadAvatarWithSize:(CGSize)avatarSize; + +/** + Reset the current avatar. May be used in case of the matrix avatar url change. + A new avatar will be automatically restored from the matrix data. + */ +- (void)resetMatrixAvatar; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKContactField.m b/Riot/Modules/MatrixKit/Models/Contact/MXKContactField.m new file mode 100644 index 000000000..b1850878e --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKContactField.m @@ -0,0 +1,235 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKContactField.h" + +@import MatrixSDK.MXMediaManager; + +#import "MXKContactManager.h" + +@interface MXKContactField() +{ + // Tell whether we already check the contact avatar definition. + BOOL shouldCheckAvatarURL; + // The media manager of the session used to retrieve the contect avatar url + // This manager is used to download this avatar if need + MXMediaManager *mediaManager; + // The current download id + NSString *downloadId; +} +@end + +@implementation MXKContactField + +- (void)initFields +{ + // init members + _contactID = nil; + _matrixID = nil; + + [self resetMatrixAvatar]; +} + +- (id)initWithContactID:(NSString*)contactID matrixID:(NSString*)matrixID +{ + self = [super init]; + + if (self) + { + [self initFields]; + _contactID = contactID; + _matrixID = matrixID; + } + + return self; +} + +- (void)resetMatrixAvatar +{ + _avatarImage = nil; + _matrixAvatarURL = nil; + shouldCheckAvatarURL = YES; + mediaManager = nil; + downloadId = nil; +} + +- (void)loadAvatarWithSize:(CGSize)avatarSize +{ + // Check whether the avatar image is already set + if (_avatarImage) + { + return; + } + + // Sanity check + if (_matrixID) + { + if (shouldCheckAvatarURL) + { + // Consider here all sessions reported into contact manager + NSArray* mxSessions = [MXKContactManager sharedManager].mxSessions; + + if (mxSessions.count) + { + // Check whether a matrix user is already known + MXUser* user; + MXSession *mxSession; + + for (mxSession in mxSessions) + { + user = [mxSession userWithUserId:_matrixID]; + if (user) + { + _matrixAvatarURL = user.avatarUrl; + if (_matrixAvatarURL) + { + shouldCheckAvatarURL = NO; + mediaManager = mxSession.mediaManager; + [self downloadAvatarImage:avatarSize]; + } + break; + } + } + + // Trigger a server request if this url has not been found. + if (shouldCheckAvatarURL) + { + MXWeakify(self); + [mxSession.matrixRestClient avatarUrlForUser:_matrixID + success:^(NSString *mxAvatarUrl) { + + MXStrongifyAndReturnIfNil(self); + self.matrixAvatarURL = mxAvatarUrl; + self->shouldCheckAvatarURL = NO; + self->mediaManager = mxSession.mediaManager; + [self downloadAvatarImage:avatarSize]; + + } failure:nil]; + } + } + } + else if (_matrixAvatarURL) + { + [self downloadAvatarImage:avatarSize]; + } + // Do nothing if the avatar url has been checked, and it is null. + } +} + +- (void)downloadAvatarImage:(CGSize)avatarSize +{ + // the avatar image is already done + if (_avatarImage) + { + return; + } + + if (_matrixAvatarURL) + { + NSString *cacheFilePath = [MXMediaManager thumbnailCachePathForMatrixContentURI:_matrixAvatarURL + andType:nil + inFolder:kMXMediaManagerAvatarThumbnailFolder + toFitViewSize:avatarSize + withMethod:MXThumbnailingMethodCrop]; + _avatarImage = [MXMediaManager loadPictureFromFilePath:cacheFilePath]; + + // the image is already in the cache + if (_avatarImage) + { + MXWeakify(self); + dispatch_async(dispatch_get_main_queue(), ^{ + MXStrongifyAndReturnIfNil(self); + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactThumbnailUpdateNotification object:self.contactID userInfo:nil]; + }); + } + else + { + NSString *downloadId = [MXMediaManager thumbnailDownloadIdForMatrixContentURI:_matrixAvatarURL inFolder:kMXMediaManagerAvatarThumbnailFolder toFitViewSize:avatarSize withMethod:MXThumbnailingMethodCrop]; + MXMediaLoader* loader = [MXMediaManager existingDownloaderWithIdentifier:downloadId]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMediaDownloadEnd:) name:kMXMediaLoaderStateDidChangeNotification object:loader]; + if (!loader && mediaManager) + { + [mediaManager downloadThumbnailFromMatrixContentURI:_matrixAvatarURL + withType:nil + inFolder:kMXMediaManagerAvatarThumbnailFolder + toFitViewSize:avatarSize + withMethod:MXThumbnailingMethodCrop + success:nil + failure:nil]; + } + } + } +} + +- (void)onMediaDownloadEnd:(NSNotification *)notif +{ + MXMediaLoader *loader = (MXMediaLoader*)notif.object; + if ([loader.downloadId isEqualToString:downloadId]) + { + // update the image + switch (loader.state) { + case MXMediaLoaderStateDownloadCompleted: + { + UIImage *image = [MXMediaManager loadPictureFromFilePath:loader.downloadOutputFilePath]; + if (image) + { + _avatarImage = image; + + MXWeakify(self); + dispatch_async(dispatch_get_main_queue(), ^{ + MXStrongifyAndReturnIfNil(self); + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactThumbnailUpdateNotification object:self.contactID userInfo:nil]; + }); + } + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:nil]; + downloadId = nil; + break; + } + case MXMediaLoaderStateDownloadFailed: + case MXMediaLoaderStateCancelled: + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:nil]; + downloadId = nil; + break; + default: + break; + } + } +} + +#pragma mark NSCoding + +- (id)initWithCoder:(NSCoder *)coder +{ + if (self) + { + [self initFields]; + _contactID = [coder decodeObjectForKey:@"contactID"]; + _matrixID = [coder decodeObjectForKey:@"matrixID"]; + } + + return self; +} + +- (void)encodeWithCoder:(NSCoder *)coder +{ + [coder encodeObject:_contactID forKey:@"contactID"]; + [coder encodeObject:_matrixID forKey:@"matrixID"]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKContactManager.h b/Riot/Modules/MatrixKit/Models/Contact/MXKContactManager.h new file mode 100644 index 000000000..c8b0082b0 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKContactManager.h @@ -0,0 +1,243 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import + +#import "MXKSectionedContacts.h" +#import "MXKContact.h" + +/** + Posted when the matrix contact list is loaded or updated. + The notification object is: + - a contact Id when a matrix contact has been added/updated/removed. + or + - nil when all matrix contacts are concerned. + */ +extern NSString * _Nonnull const kMXKContactManagerDidUpdateMatrixContactsNotification; + +/** + Posted when the local contact list is loaded and updated. + The notification object is: + - a contact Id when a local contact has been added/updated/removed. + or + - nil when all local contacts are concerned. + */ +extern NSString * _Nonnull const kMXKContactManagerDidUpdateLocalContactsNotification; + +/** + Posted when local contact matrix ids is updated. + The notification object is: + - a contact Id when a local contact has been added/updated/removed. + or + - nil when all local contacts are concerned. + */ +extern NSString * _Nonnull const kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification; + +/** + Posted when the presence of a matrix user linked at least to one contact has changed. + The notification object is the matrix Id. The `userInfo` dictionary contains an `MXPresenceString` object under the `kMXKContactManagerMatrixPresenceKey` key, representing the matrix user presence. + */ +extern NSString * _Nonnull const kMXKContactManagerMatrixUserPresenceChangeNotification; +extern NSString * _Nonnull const kMXKContactManagerMatrixPresenceKey; + +/** + Posted when all phonenumbers of local contacts have been internationalized. + The notification object is nil. + */ +extern NSString * _Nonnull const kMXKContactManagerDidInternationalizeNotification; + +/** + Used to identify the type of data when requesting MXKeyProvider + */ +extern NSString * _Nonnull const MXKContactManagerDataType; + +/** + Define the contact creation for the room members + */ +typedef NS_ENUM(NSInteger, MXKContactManagerMXRoomSource) { + MXKContactManagerMXRoomSourceNone = 0, // the MXMember does not create any new contact. + MXKContactManagerMXRoomSourceDirectChats = 1, // the direct chat users have their own contact even if they are not defined in the device contacts book + MXKContactManagerMXRoomSourceAll = 2, // all the room members have their own contact even if they are not defined in the device contacts book +}; + +/** + This manager handles 2 kinds of contact list: + - The local contacts retrieved from the device phonebook. + - The matrix contacts retrieved from the matrix one-to-one rooms. + + Note: The local contacts handling depends on the 'syncLocalContacts' and 'phonebookCountryCode' properties + of the shared application settings object '[MXKAppSettings standardAppSettings]'. + */ +@interface MXKContactManager : NSObject + +/** + The shared instance of contact manager. + */ ++ (MXKContactManager* _Nonnull)sharedManager; + +/** + Block called (if any) to discover the Matrix users bound to a set of third-party identifiers (email addresses, phone numbers). + If this property is unset, the contact manager will consider the potential identity server URL (see the `identityServer` property) + to build its own Restclient and trigger `lookup3PIDs` requests. + + @param threepids the list of 3rd party ids: [[<(MX3PIDMedium)media1>, <(NSString*)address1>], [<(MX3PIDMedium)media2>, <(NSString*)address2>], ...]. + @param success a block object called when the operation succeeds. It provides the array of the discovered users: + [[<(MX3PIDMedium)media>, <(NSString*)address>, <(NSString*)userId>], ...]. + @param failure a block object called when the operation fails. + */ +typedef void(^MXKContactManagerDiscoverUsersBoundTo3PIDs)(NSArray *> * _Nonnull threepids, + void (^ _Nonnull success)(NSArray *> *_Nonnull), + void (^ _Nonnull failure)(NSError *_Nonnull)); +@property (nonatomic, nullable) MXKContactManagerDiscoverUsersBoundTo3PIDs discoverUsersBoundTo3PIDsBlock; + +/** + Define if the room member must have their dedicated contact even if they are not define in the device contacts book. + The default value is MXKContactManagerMXRoomSourceDirectChats; + */ +@property (nonatomic) MXKContactManagerMXRoomSource contactManagerMXRoomSource; + +/** + Associated matrix sessions (empty by default). + */ +@property (nonatomic, readonly, nonnull) NSArray *mxSessions; + +/** + The current list of the contacts extracted from matrix data. Depends on 'contactManagerMXRoomSource'. + */ +@property (nonatomic, readonly, nullable) NSArray *matrixContacts; + +/** + The current list of the local contacts (nil by default until the contacts are loaded). + */ +@property (nonatomic, readonly, nullable) NSArray *localContacts; + +/** + The current list of the local contacts who have contact methods which can be used to invite them or to discover matrix users. + */ +@property (nonatomic, readonly, nullable) NSArray *localContactsWithMethods; + +/** + The contacts list obtained by splitting each local contact by contact method. + This list is alphabetically sorted. + Each contact has one and only one contact method. + */ +//- (void)localContactsSplitByContactMethod:(void (^)(NSArray *localContactsSplitByContactMethod))onComplete; + +@property (nonatomic, readonly, nullable) NSArray *localContactsSplitByContactMethod; + +/** + The current list of the contacts for whom a direct chat exists. + */ +@property (nonatomic, readonly, nonnull) NSArray *directMatrixContacts; + +/// Flag to allow local contacts access or not. Default value is YES. +@property (nonatomic, assign) BOOL allowLocalContactsAccess; + +/** + Add/remove matrix session. The matrix contact list is automatically updated (see kMXKContactManagerDidUpdateMatrixContactsNotification event). + */ +- (void)addMatrixSession:(MXSession* _Nonnull)mxSession; +- (void)removeMatrixSession:(MXSession* _Nonnull)mxSession; + +/** + Takes into account the state of the identity service's terms, local contacts access authorization along with + whether the user has left the app for the Settings app to update the contacts access, and enables/disables + the `syncLocalContacts` property of `MXKAppSettings` when necessary. + @param mxSession The session who's identity service shall be used. + */ +- (void)validateSyncLocalContactsStateForSession:(MXSession *)mxSession; + +/** + Load and/or refresh the local contacts. Observe kMXKContactManagerDidUpdateLocalContactsNotification to know when local contacts are available. + */ +- (void)refreshLocalContacts; + +/** + Delete contacts info + */ +- (void)reset; + +/** + Get contact by its identifier. + + @param contactID the contact identifier. + @return the contact defined with the provided id. + */ +- (MXKContact* _Nullable)contactWithContactID:(NSString* _Nonnull)contactID; + +/** + Refresh matrix IDs for a specific local contact. See kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification + posted when update is done. + + @param contact the local contact to refresh. + */ +- (void)updateMatrixIDsForLocalContact:(MXKContact* _Nonnull)contact; + +/** + Refresh matrix IDs for all local contacts. See kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification + posted when update for all local contacts is done. + */ +- (void)updateMatrixIDsForAllLocalContacts; + +/** + The contacts list obtained by splitting each local contact by contact method. + This list is alphabetically sorted. + Each contact has one and only one contact method. + */ +//- (void)localContactsSplitByContactMethod:(void (^)(NSArray *localContactsSplitByContactMethod))onComplete; + +/** + Sort a contacts array in sectioned arrays to be displayable in a UITableview + */ +- (MXKSectionedContacts* _Nullable)getSectionedContacts:(NSArray* _Nonnull)contactList; + +/** + Sort alphabetically an array of contacts. + + @param contactsArray the array of contacts to sort. + */ +- (void)sortAlphabeticallyContacts:(NSMutableArray * _Nonnull)contactsArray; + +/** + Sort an array of contacts by last active, with "active now" first. + ...and then alphabetically. + + @param contactsArray the array of contacts to sort. + */ +- (void)sortContactsByLastActiveInformation:(NSMutableArray * _Nonnull)contactsArray; + +/** + Refresh the international phonenumber of the local contacts (See kMXKContactManagerDidInternationalizeNotification). + + @param countryCode the country code. + */ +- (void)internationalizePhoneNumbers:(NSString* _Nonnull)countryCode; + +/** + Request user permission for syncing local contacts. + + @param viewController the view controller to attach the dialog to the user. + @param handler the block called with the result of requesting access + */ ++ (void)requestUserConfirmationForLocalContactsSyncInViewController:(UIViewController* _Nonnull)viewController + completionHandler:(void (^_Nonnull)(BOOL granted))handler; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKContactManager.m b/Riot/Modules/MatrixKit/Models/Contact/MXKContactManager.m new file mode 100644 index 000000000..52ac60a68 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKContactManager.m @@ -0,0 +1,1939 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKContactManager.h" + +#import "MXKContact.h" + +#import "MXKAppSettings.h" +#import "MXKTools.h" +#import "NSBundle+MatrixKit.h" +#import +#import +#import + +#import "MXKSwiftHeader.h" + +NSString *const kMXKContactManagerDidUpdateMatrixContactsNotification = @"kMXKContactManagerDidUpdateMatrixContactsNotification"; + +NSString *const kMXKContactManagerDidUpdateLocalContactsNotification = @"kMXKContactManagerDidUpdateLocalContactsNotification"; +NSString *const kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification = @"kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification"; + +NSString *const kMXKContactManagerMatrixUserPresenceChangeNotification = @"kMXKContactManagerMatrixUserPresenceChangeNotification"; +NSString *const kMXKContactManagerMatrixPresenceKey = @"kMXKContactManagerMatrixPresenceKey"; + +NSString *const kMXKContactManagerDidInternationalizeNotification = @"kMXKContactManagerDidInternationalizeNotification"; + +NSString *const MXKContactManagerDataType = @"org.matrix.kit.MXKContactManagerDataType"; + +@interface MXKContactManager() +{ + /** + Array of `MXSession` instances. + */ + NSMutableArray *mxSessionArray; + id mxSessionStateObserver; + id mxSessionNewSyncedRoomObserver; + + /** + Listeners registered on matrix presence and membership events (one by matrix session) + */ + NSMutableArray *mxEventListeners; + + /** + Local contacts handling + */ + BOOL isLocalContactListRefreshing; + dispatch_queue_t processingQueue; + NSDate *lastSyncDate; + // Local contacts by contact Id + NSMutableDictionary* localContactByContactID; + NSMutableArray* localContactsWithMethods; + NSMutableArray* splitLocalContacts; + + // Matrix id linked to 3PID. + NSMutableDictionary *matrixIDBy3PID; + + /** + Matrix contacts handling + */ + // Matrix contacts by contact Id + NSMutableDictionary* matrixContactByContactID; + // Matrix contacts by matrix id + NSMutableDictionary* matrixContactByMatrixID; +} + +@end + +@implementation MXKContactManager +@synthesize contactManagerMXRoomSource; + +#pragma mark Singleton Methods + ++ (instancetype)sharedManager +{ + static MXKContactManager *sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedInstance = [[MXKContactManager alloc] init]; + }); + return sharedInstance; +} + +#pragma mark - + +-(MXKContactManager *)init +{ + if (self = [super init]) + { + NSString *label = [NSString stringWithFormat:@"MatrixKit.%@.Contacts", [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleName"]]; + + [self deleteOldFiles]; + + processingQueue = dispatch_queue_create([label UTF8String], DISPATCH_QUEUE_SERIAL); + + // save the last sync date + // to avoid resync the whole phonebook + lastSyncDate = nil; + + self.contactManagerMXRoomSource = MXKContactManagerMXRoomSourceDirectChats; + + // Observe related settings change + [[MXKAppSettings standardAppSettings] addObserver:self forKeyPath:@"syncLocalContacts" options:0 context:nil]; + [[MXKAppSettings standardAppSettings] addObserver:self forKeyPath:@"phonebookCountryCode" options:0 context:nil]; + + [self registerAccountDataDidChangeIdentityServerNotification]; + self.allowLocalContactsAccess = YES; + } + + return self; +} + +-(void)dealloc +{ + matrixIDBy3PID = nil; + + localContactByContactID = nil; + localContactsWithMethods = nil; + splitLocalContacts = nil; + + matrixContactByContactID = nil; + matrixContactByMatrixID = nil; + + lastSyncDate = nil; + + while (mxSessionArray.count) { + [self removeMatrixSession:mxSessionArray.lastObject]; + } + mxSessionArray = nil; + mxEventListeners = nil; + + [[MXKAppSettings standardAppSettings] removeObserver:self forKeyPath:@"syncLocalContacts"]; + [[MXKAppSettings standardAppSettings] removeObserver:self forKeyPath:@"phonebookCountryCode"]; + + processingQueue = nil; +} + +#pragma mark - + +- (void)addMatrixSession:(MXSession*)mxSession +{ + if (!mxSessionArray) + { + mxSessionArray = [NSMutableArray array]; + } + if (!mxEventListeners) + { + mxEventListeners = [NSMutableArray array]; + } + + if ([mxSessionArray indexOfObject:mxSession] == NSNotFound) + { + [mxSessionArray addObject:mxSession]; + + MXWeakify(self); + + // Register a listener on matrix presence and membership events + id eventListener = [mxSession listenToEventsOfTypes:@[kMXEventTypeStringRoomMember, kMXEventTypeStringPresence] + onEvent:^(MXEvent *event, MXTimelineDirection direction, id customObject) { + + MXStrongifyAndReturnIfNil(self); + + // Consider only live event + if (direction == MXTimelineDirectionForwards) + { + // Consider first presence events + if (event.eventType == MXEventTypePresence) + { + // Check whether the concerned matrix user belongs to at least one contact. + BOOL isMatched = ([self->matrixContactByMatrixID objectForKey:event.sender] != nil); + if (!isMatched) + { + NSArray *matrixIDs = [self->matrixIDBy3PID allValues]; + isMatched = ([matrixIDs indexOfObject:event.sender] != NSNotFound); + } + + if (isMatched) { + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerMatrixUserPresenceChangeNotification object:event.sender userInfo:@{kMXKContactManagerMatrixPresenceKey:event.content[@"presence"]}]; + } + } + // Else the event type is MXEventTypeRoomMember. + // Ignore here membership events if the session is not running yet, + // Indeed all the contacts are refreshed when session state becomes running. + else if (mxSession.state == MXSessionStateRunning) + { + // Update matrix contact list on membership change + [self updateMatrixContactWithID:event.sender]; + } + } + }]; + + [mxEventListeners addObject:eventListener]; + + // Update matrix contact list in case of new synced one-to-one room + if (!mxSessionNewSyncedRoomObserver) + { + mxSessionNewSyncedRoomObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXRoomInitialSyncNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXStrongifyAndReturnIfNil(self); + + // create contact for known room members + if (self.contactManagerMXRoomSource != MXKContactManagerMXRoomSourceNone) + { + MXRoom *room = notif.object; + [room state:^(MXRoomState *roomState) { + + MXRoomMembers *roomMembers = roomState.members; + + NSArray *members = roomMembers.members; + + // Consider only 1:1 chat for MXKMemberContactCreationOneToOneRoom + // or adding all + if (((members.count == 2) && (self.contactManagerMXRoomSource == MXKContactManagerMXRoomSourceDirectChats)) || (self.contactManagerMXRoomSource == MXKContactManagerMXRoomSourceAll)) + { + NSString* myUserId = room.mxSession.myUser.userId; + + for (MXRoomMember* member in members) + { + if ([member.userId isEqualToString:myUserId]) + { + [self updateMatrixContactWithID:member.userId]; + } + } + } + }]; + } + }]; + } + + // Update all matrix contacts as soon as matrix session is ready + if (!mxSessionStateObserver) { + mxSessionStateObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXSessionStateDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXStrongifyAndReturnIfNil(self); + + MXSession *mxSession = notif.object; + + if ([self->mxSessionArray indexOfObject:mxSession] != NSNotFound) + { + if ((mxSession.state == MXSessionStateStoreDataReady) || (mxSession.state == MXSessionStateRunning)) { + [self refreshMatrixContacts]; + } + } + }]; + } + + // refreshMatrixContacts can take time. Delay its execution to not overload + // launch of apps that call [MXKContactManager addMatrixSession] at startup + dispatch_async(dispatch_get_main_queue(), ^{ + [self refreshMatrixContacts]; + }); + } +} + +- (void)removeMatrixSession:(MXSession*)mxSession +{ + NSUInteger index = [mxSessionArray indexOfObject:mxSession]; + if (index != NSNotFound) + { + id eventListener = [mxEventListeners objectAtIndex:index]; + [mxSession removeListener:eventListener]; + + [mxEventListeners removeObjectAtIndex:index]; + [mxSessionArray removeObjectAtIndex:index]; + + if (!mxSessionArray.count) { + if (mxSessionStateObserver) { + [[NSNotificationCenter defaultCenter] removeObserver:mxSessionStateObserver]; + mxSessionStateObserver = nil; + } + + if (mxSessionNewSyncedRoomObserver) { + [[NSNotificationCenter defaultCenter] removeObserver:mxSessionNewSyncedRoomObserver]; + mxSessionNewSyncedRoomObserver = nil; + } + } + + // Update matrix contacts list + [self refreshMatrixContacts]; + } +} + +- (NSArray*)mxSessions +{ + return [NSArray arrayWithArray:mxSessionArray]; +} + + +- (NSArray*)matrixContacts +{ + NSParameterAssert([NSThread isMainThread]); + + return [matrixContactByContactID allValues]; +} + +- (NSArray*)localContacts +{ + NSParameterAssert([NSThread isMainThread]); + + // Return nil if the loading step is in progress. + if (isLocalContactListRefreshing) + { + return nil; + } + + return [localContactByContactID allValues]; +} + +- (NSArray*)localContactsWithMethods +{ + NSParameterAssert([NSThread isMainThread]); + + // Return nil if the loading step is in progress. + if (isLocalContactListRefreshing) + { + return nil; + } + + // Check whether the array must be prepared + if (!localContactsWithMethods) + { + // List all the local contacts with emails and/or phones + NSArray *localContacts = self.localContacts; + localContactsWithMethods = [NSMutableArray arrayWithCapacity:localContacts.count]; + + for (MXKContact* contact in localContacts) + { + if (contact.emailAddresses) + { + [localContactsWithMethods addObject:contact]; + } + else if (contact.phoneNumbers) + { + [localContactsWithMethods addObject:contact]; + } + } + } + + return localContactsWithMethods; +} + +- (NSArray*)localContactsSplitByContactMethod +{ + NSParameterAssert([NSThread isMainThread]); + + // Return nil if the loading step is in progress. + if (isLocalContactListRefreshing) + { + return nil; + } + + // Check whether the array must be prepared + if (!splitLocalContacts) + { + // List all the local contacts with contact methods + NSArray *contactsArray = self.localContactsWithMethods; + + splitLocalContacts = [NSMutableArray arrayWithCapacity:contactsArray.count]; + + for (MXKContact* contact in contactsArray) + { + NSArray *emails = contact.emailAddresses; + NSArray *phones = contact.phoneNumbers; + + if (emails.count + phones.count > 1) + { + for (MXKEmail *email in emails) + { + MXKContact *splitContact = [[MXKContact alloc] initContactWithDisplayName:contact.displayName emails:@[email] phoneNumbers:nil andThumbnail:contact.thumbnail]; + [splitLocalContacts addObject:splitContact]; + } + + for (MXKPhoneNumber *phone in phones) + { + MXKContact *splitContact = [[MXKContact alloc] initContactWithDisplayName:contact.displayName emails:nil phoneNumbers:@[phone] andThumbnail:contact.thumbnail]; + [splitLocalContacts addObject:splitContact]; + } + } + else if (emails.count + phones.count) + { + [splitLocalContacts addObject:contact]; + } + } + + // Sort alphabetically the resulting list + [self sortAlphabeticallyContacts:splitLocalContacts]; + } + + return splitLocalContacts; +} + + +//- (void)localContactsSplitByContactMethod:(void (^)(NSArray *localContactsSplitByContactMethod))onComplete +//{ +// NSParameterAssert([NSThread isMainThread]); +// +// // Return nil if the loading step is in progress. +// if (isLocalContactListRefreshing) +// { +// onComplete(nil); +// return; +// } +// +// // Check whether the array must be prepared +// if (!splitLocalContacts) +// { +// // List all the local contacts with contact methods +// NSArray *contactsArray = self.localContactsWithMethods; +// +// splitLocalContacts = [NSMutableArray arrayWithCapacity:contactsArray.count]; +// +// for (MXKContact* contact in contactsArray) +// { +// NSArray *emails = contact.emailAddresses; +// NSArray *phones = contact.phoneNumbers; +// +// if (emails.count + phones.count > 1) +// { +// for (MXKEmail *email in emails) +// { +// MXKContact *splitContact = [[MXKContact alloc] initContactWithDisplayName:contact.displayName emails:@[email] phoneNumbers:nil andThumbnail:contact.thumbnail]; +// [splitLocalContacts addObject:splitContact]; +// } +// +// for (MXKPhoneNumber *phone in phones) +// { +// MXKContact *splitContact = [[MXKContact alloc] initContactWithDisplayName:contact.displayName emails:nil phoneNumbers:@[phone] andThumbnail:contact.thumbnail]; +// [splitLocalContacts addObject:splitContact]; +// } +// } +// else if (emails.count + phones.count) +// { +// [splitLocalContacts addObject:contact]; +// } +// } +// +// // Sort alphabetically the resulting list +// [self sortAlphabeticallyContacts:splitLocalContacts]; +// } +// +// onComplete(splitLocalContacts); +//} + +- (NSArray*)directMatrixContacts +{ + NSParameterAssert([NSThread isMainThread]); + + NSMutableDictionary *directContacts = [NSMutableDictionary dictionary]; + + NSArray *mxSessions = self.mxSessions; + + for (MXSession *mxSession in mxSessions) + { + // Check all existing users for whom a direct chat exists + NSArray *mxUserIds = mxSession.directRooms.allKeys; + + for (NSString *mxUserId in mxUserIds) + { + MXKContact* contact = [matrixContactByMatrixID objectForKey:mxUserId]; + + // Sanity check - the contact must be already defined here + if (contact) + { + [directContacts setValue:contact forKey:mxUserId]; + } + } + } + + return directContacts.allValues; +} + +// The current identity service used with the contact manager +- (MXIdentityService*)identityService +{ + // For the moment, only use the one of the first session + MXSession *mxSession = [mxSessionArray firstObject]; + return mxSession.identityService; +} + +- (BOOL)isUsersDiscoveringEnabled +{ + // Check whether the 3pid lookup is available + return (self.discoverUsersBoundTo3PIDsBlock || self.identityService); +} + +#pragma mark - + +- (void)validateSyncLocalContactsStateForSession:(MXSession *)mxSession +{ + if (!self.allowLocalContactsAccess) + { + return; + } + + // Get the status of the identity service terms. + BOOL areAllTermsAgreed = mxSession.identityService.areAllTermsAgreed; + + if (MXKAppSettings.standardAppSettings.syncLocalContacts) + { + // Disable local contact sync when all terms are no longer accepted or if contacts access has been revoked. + if (!areAllTermsAgreed || [CNContactStore authorizationStatusForEntityType:CNEntityTypeContacts] != CNAuthorizationStatusAuthorized) + { + MXLogDebug(@"[MXKContactManager] validateSyncLocalContactsState : Disabling contacts sync."); + MXKAppSettings.standardAppSettings.syncLocalContacts = false; + return; + } + } + else + { + // Check whether the user has been directed to the Settings app to enable contact access. + if (MXKAppSettings.standardAppSettings.syncLocalContactsPermissionOpenedSystemSettings) + { + // Reset the system settings app flag as they are back in the app. + MXKAppSettings.standardAppSettings.syncLocalContactsPermissionOpenedSystemSettings = false; + + // And if all other conditions are met for contacts sync enable it. + if (areAllTermsAgreed && [CNContactStore authorizationStatusForEntityType:CNEntityTypeContacts] == CNAuthorizationStatusAuthorized) + { + MXLogDebug(@"[MXKContactManager] validateSyncLocalContactsState : Enabling contacts sync after user visited Settings app."); + MXKAppSettings.standardAppSettings.syncLocalContacts = true; + } + } + } +} + +- (void)refreshLocalContacts +{ + MXLogDebug(@"[MXKContactManager] refreshLocalContacts : Started"); + + if (!self.allowLocalContactsAccess) + { + MXLogDebug(@"[MXKContactManager] refreshLocalContacts : Finished because local contacts access not allowed."); + return; + } + + NSDate *startDate = [NSDate date]; + + if ([CNContactStore authorizationStatusForEntityType:CNEntityTypeContacts] != CNAuthorizationStatusAuthorized) + { + if ([MXKAppSettings standardAppSettings].syncLocalContacts) + { + // The user authorised syncLocalContacts and allowed access to his contacts + // but he then removed contacts access from app permissions. + // So, reset syncLocalContacts value + [MXKAppSettings standardAppSettings].syncLocalContacts = NO; + } + + // Local contacts list is empty if the access is denied. + self->localContactByContactID = nil; + self->localContactsWithMethods = nil; + self->splitLocalContacts = nil; + [self cacheLocalContacts]; + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateLocalContactsNotification object:nil userInfo:nil]; + + MXLogDebug(@"[MXKContactManager] refreshLocalContacts : Complete"); + MXLogDebug(@"[MXKContactManager] refreshLocalContacts : Local contacts access denied"); + } + else + { + self->isLocalContactListRefreshing = YES; + + // Reset the internal contact lists (These arrays will be prepared only if need). + self->localContactsWithMethods = self->splitLocalContacts = nil; + + BOOL isColdStart = NO; + + // Check whether the local contacts sync has been disabled. + if (self->matrixIDBy3PID && ![MXKAppSettings standardAppSettings].syncLocalContacts) + { + // The user changed his mind and disabled the local contact sync, remove the cached data. + self->matrixIDBy3PID = nil; + [self cacheMatrixIDsDict]; + + // Reload the local contacts from the system + self->localContactByContactID = nil; + [self cacheLocalContacts]; + } + + // Check whether this is a cold start. + if (!self->matrixIDBy3PID) + { + isColdStart = YES; + + // Load the dictionary from the file system. It is cached to improve UX. + [self loadCachedMatrixIDsDict]; + } + + MXWeakify(self); + + dispatch_async(self->processingQueue, ^{ + + MXStrongifyAndReturnIfNil(self); + + // In case of cold start, retrieve the data from the file system + if (isColdStart) + { + [self loadCachedLocalContacts]; + [self loadCachedContactBookInfo]; + + // no local contact -> assume that the last sync date is useless + if (self->localContactByContactID.count == 0) + { + self->lastSyncDate = nil; + } + } + + BOOL didContactBookChange = NO; + + NSMutableArray* deletedContactIDs = [NSMutableArray arrayWithArray:[self->localContactByContactID allKeys]]; + + // can list local contacts? + if (ABAddressBookGetAuthorizationStatus() == kABAuthorizationStatusAuthorized) + { + NSString* countryCode = [[MXKAppSettings standardAppSettings] phonebookCountryCode]; + + ABAddressBookRef ab = ABAddressBookCreateWithOptions(nil, nil); + ABRecordRef contactRecord; + CFIndex index; + CFMutableArrayRef people = (CFMutableArrayRef)ABAddressBookCopyArrayOfAllPeople(ab); + + if (nil != people) + { + CFIndex peopleCount = CFArrayGetCount(people); + + for (index = 0; index < peopleCount; index++) + { + contactRecord = (ABRecordRef)CFArrayGetValueAtIndex(people, index); + + NSString* contactID = [MXKContact contactID:contactRecord]; + + // the contact still exists + [deletedContactIDs removeObject:contactID]; + + if (self->lastSyncDate) + { + // ignore unchanged contacts since the previous sync + CFDateRef lastModifDate = ABRecordCopyValue(contactRecord, kABPersonModificationDateProperty); + if (lastModifDate) + { + if (kCFCompareGreaterThan != CFDateCompare(lastModifDate, (__bridge CFDateRef)self->lastSyncDate, nil)) + + { + CFRelease(lastModifDate); + continue; + } + CFRelease(lastModifDate); + } + } + + didContactBookChange = YES; + + MXKContact* contact = [[MXKContact alloc] initLocalContactWithABRecord:contactRecord]; + + if (countryCode) + { + contact.defaultCountryCode = countryCode; + } + + // update the local contacts list + [self->localContactByContactID setValue:contact forKey:contactID]; + } + + CFRelease(people); + } + + if (ab) + { + CFRelease(ab); + } + } + + // some contacts have been deleted + for (NSString* contactID in deletedContactIDs) + { + didContactBookChange = YES; + [self->localContactByContactID removeObjectForKey:contactID]; + } + + // something has been modified in the local contact book + if (didContactBookChange) + { + [self cacheLocalContacts]; + } + + self->lastSyncDate = [NSDate date]; + [self cacheContactBookInfo]; + + // Update loaded contacts with the known dict 3PID -> matrix ID + [self updateAllLocalContactsMatrixIDs]; + + dispatch_async(dispatch_get_main_queue(), ^{ + + // Contacts are loaded, post a notification + self->isLocalContactListRefreshing = NO; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateLocalContactsNotification object:nil userInfo:nil]; + + // Check the conditions required before triggering a matrix users lookup. + if (isColdStart || didContactBookChange) + { + [self updateMatrixIDsForAllLocalContacts]; + } + + MXLogDebug(@"[MXKContactManager] refreshLocalContacts : Complete"); + MXLogDebug(@"[MXKContactManager] refreshLocalContacts : Refresh %tu local contacts in %.0fms", self->localContactByContactID.count, [[NSDate date] timeIntervalSinceDate:startDate] * 1000); + }); + }); + } +} + +- (void)updateMatrixIDsForLocalContact:(MXKContact *)contact +{ + // Check if the user allowed to sync local contacts. + // + Check whether users discovering is available. + if ([MXKAppSettings standardAppSettings].syncLocalContacts && !contact.isMatrixContact && [self isUsersDiscoveringEnabled]) + { + // Retrieve all 3PIDs of the contact + NSMutableArray* threepids = [[NSMutableArray alloc] init]; + NSMutableArray* lookup3pidsArray = [[NSMutableArray alloc] init]; + + for (MXKEmail* email in contact.emailAddresses) + { + // Not yet added + if (email.emailAddress.length && [threepids indexOfObject:email.emailAddress] == NSNotFound) + { + [lookup3pidsArray addObject:@[kMX3PIDMediumEmail, email.emailAddress]]; + [threepids addObject:email.emailAddress]; + } + } + + for (MXKPhoneNumber* phone in contact.phoneNumbers) + { + if (phone.msisdn) + { + [lookup3pidsArray addObject:@[kMX3PIDMediumMSISDN, phone.msisdn]]; + [threepids addObject:phone.msisdn]; + } + } + + if (lookup3pidsArray.count > 0) + { + MXWeakify(self); + + void (^success)(NSArray *> *) = ^(NSArray *> *discoveredUsers) { + MXStrongifyAndReturnIfNil(self); + + // Look for updates + BOOL isUpdated = NO; + + // Consider each discored user + for (NSArray *discoveredUser in discoveredUsers) + { + // Sanity check + if (discoveredUser.count == 3) + { + NSString *pid = discoveredUser[1]; + NSString *matrixId = discoveredUser[2]; + + // Remove the 3pid from the requested list + [threepids removeObject:pid]; + + NSString *currentMatrixID = [self->matrixIDBy3PID objectForKey:pid]; + + if (![currentMatrixID isEqualToString:matrixId]) + { + [self->matrixIDBy3PID setObject:matrixId forKey:pid]; + isUpdated = YES; + } + } + } + + // Remove existing information which is not valid anymore + for (NSString *pid in threepids) + { + if ([self->matrixIDBy3PID objectForKey:pid]) + { + [self->matrixIDBy3PID removeObjectForKey:pid]; + isUpdated = YES; + } + } + + if (isUpdated) + { + [self cacheMatrixIDsDict]; + + // Update only this contact + [self updateLocalContactMatrixIDs:contact]; + + dispatch_async(dispatch_get_main_queue(), ^{ + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification object:contact.contactID userInfo:nil]; + }); + } + }; + + void (^failure)(NSError *) = ^(NSError *error) { + MXLogDebug(@"[MXKContactManager] updateMatrixIDsForLocalContact failed"); + }; + + if (self.discoverUsersBoundTo3PIDsBlock) + { + self.discoverUsersBoundTo3PIDsBlock(lookup3pidsArray, success, failure); + } + else + { + // Consider the potential identity server url by default + [self.identityService lookup3pids:lookup3pidsArray + success:success + failure:failure]; + } + } + } +} + + +- (void)updateMatrixIDsForAllLocalContacts +{ + // If localContactByContactID is not loaded, the manager will consider there is no local contacts + // and will reset its cache + NSAssert(localContactByContactID, @"[MXKContactManager] updateMatrixIDsForAllLocalContacts: refreshLocalContacts must be called before"); + + // Check if the user allowed to sync local contacts. + // + Check if at least an identity server is available, and if the loading step is not in progress. + if (![MXKAppSettings standardAppSettings].syncLocalContacts || ![self isUsersDiscoveringEnabled] || isLocalContactListRefreshing) + { + return; + } + + MXWeakify(self); + + // Refresh the 3PIDs -> Matrix ID mapping + dispatch_async(processingQueue, ^{ + + MXStrongifyAndReturnIfNil(self); + + NSArray* contactsSnapshot = [self->localContactByContactID allValues]; + + // Retrieve all 3PIDs + NSMutableArray* threepids = [[NSMutableArray alloc] init]; + NSMutableArray* lookup3pidsArray = [[NSMutableArray alloc] init]; + + for (MXKContact* contact in contactsSnapshot) + { + for (MXKEmail* email in contact.emailAddresses) + { + // Not yet added + if (email.emailAddress.length && [threepids indexOfObject:email.emailAddress] == NSNotFound) + { + [lookup3pidsArray addObject:@[kMX3PIDMediumEmail, email.emailAddress]]; + [threepids addObject:email.emailAddress]; + } + } + + for (MXKPhoneNumber* phone in contact.phoneNumbers) + { + if (phone.msisdn) + { + // Not yet added + if ([threepids indexOfObject:phone.msisdn] == NSNotFound) + { + [lookup3pidsArray addObject:@[kMX3PIDMediumMSISDN, phone.msisdn]]; + [threepids addObject:phone.msisdn]; + } + } + } + } + + // Update 3PIDs mapping + if (lookup3pidsArray.count > 0) + { + MXWeakify(self); + + void (^success)(NSArray *> *) = ^(NSArray *> *discoveredUsers) { + MXStrongifyAndReturnIfNil(self); + + [threepids removeAllObjects]; + NSMutableArray* userIds = [[NSMutableArray alloc] init]; + + // Consider each discored user + for (NSArray *discoveredUser in discoveredUsers) + { + // Sanity check + if (discoveredUser.count == 3) + { + id threepid = discoveredUser[1]; + id userId = discoveredUser[2]; + + if ([threepid isKindOfClass:[NSString class]] && [userId isKindOfClass:[NSString class]]) + { + [threepids addObject:threepid]; + [userIds addObject:userId]; + } + } + } + + if (userIds.count) + { + self->matrixIDBy3PID = [[NSMutableDictionary alloc] initWithObjects:userIds forKeys:threepids]; + } + else + { + self->matrixIDBy3PID = nil; + } + + [self cacheMatrixIDsDict]; + + [self updateAllLocalContactsMatrixIDs]; + + dispatch_async(dispatch_get_main_queue(), ^{ + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification object:nil userInfo:nil]; + }); + }; + + void (^failure)(NSError *) = ^(NSError *error) { + MXLogDebug(@"[MXKContactManager] updateMatrixIDsForAllLocalContacts failed"); + }; + + if (self.discoverUsersBoundTo3PIDsBlock) + { + self.discoverUsersBoundTo3PIDsBlock(lookup3pidsArray, success, failure); + } + else if (self.identityService) + { + [self.identityService lookup3pids:lookup3pidsArray + success:success + failure:failure]; + } + else + { + // No IS, no detection of Matrix users in local contacts + self->matrixIDBy3PID = nil; + [self cacheMatrixIDsDict]; + } + } + else + { + self->matrixIDBy3PID = nil; + [self cacheMatrixIDsDict]; + } + }); +} + +- (void)resetMatrixIDs +{ + dispatch_async(processingQueue, ^{ + + self->matrixIDBy3PID = nil; + [self cacheMatrixIDsDict]; + + dispatch_async(dispatch_get_main_queue(), ^{ + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification object:nil userInfo:nil]; + }); + }); +} + +- (void)reset +{ + matrixIDBy3PID = nil; + [self cacheMatrixIDsDict]; + + isLocalContactListRefreshing = NO; + localContactByContactID = nil; + localContactsWithMethods = nil; + splitLocalContacts = nil; + [self cacheLocalContacts]; + + matrixContactByContactID = nil; + matrixContactByMatrixID = nil; + [self cacheMatrixContacts]; + + lastSyncDate = nil; + [self cacheContactBookInfo]; + + while (mxSessionArray.count) { + [self removeMatrixSession:mxSessionArray.lastObject]; + } + mxSessionArray = nil; + mxEventListeners = nil; + + // warn of the contacts list update + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateMatrixContactsNotification object:nil userInfo:nil]; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateLocalContactsNotification object:nil userInfo:nil]; +} + +- (MXKContact*)contactWithContactID:(NSString*)contactID +{ + if ([contactID hasPrefix:kMXKContactLocalContactPrefixId]) + { + return [localContactByContactID objectForKey:contactID]; + } + else + { + return [matrixContactByContactID objectForKey:contactID]; + } +} + +// refresh the international phonenumber of the contacts +- (void)internationalizePhoneNumbers:(NSString*)countryCode +{ + MXWeakify(self); + + dispatch_async(processingQueue, ^{ + + MXStrongifyAndReturnIfNil(self); + + NSArray* contactsSnapshot = [self->localContactByContactID allValues]; + + for (MXKContact* contact in contactsSnapshot) + { + contact.defaultCountryCode = countryCode; + } + + [self cacheLocalContacts]; + + dispatch_async(dispatch_get_main_queue(), ^{ + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidInternationalizeNotification object:nil userInfo:nil]; + }); + }); +} + +- (MXKSectionedContacts *)getSectionedContacts:(NSArray*)contactsList +{ + if (!contactsList.count) + { + return nil; + } + + UILocalizedIndexedCollation *collation = [UILocalizedIndexedCollation currentCollation]; + + int indexOffset = 0; + + NSInteger index, sectionTitlesCount = [[collation sectionTitles] count]; + NSMutableArray *tmpSectionsArray = [[NSMutableArray alloc] initWithCapacity:(sectionTitlesCount)]; + + sectionTitlesCount += indexOffset; + + for (index = 0; index < sectionTitlesCount; index++) + { + NSMutableArray *array = [[NSMutableArray alloc] init]; + [tmpSectionsArray addObject:array]; + } + + int contactsCount = 0; + + for (MXKContact *aContact in contactsList) + { + NSInteger section = [collation sectionForObject:aContact collationStringSelector:@selector(displayName)] + indexOffset; + + [[tmpSectionsArray objectAtIndex:section] addObject:aContact]; + ++contactsCount; + } + + NSMutableArray *tmpSectionedContactsTitle = [[NSMutableArray alloc] initWithCapacity:sectionTitlesCount]; + NSMutableArray *shortSectionsArray = [[NSMutableArray alloc] initWithCapacity:sectionTitlesCount]; + + for (index = indexOffset; index < sectionTitlesCount; index++) + { + NSMutableArray *usersArrayForSection = [tmpSectionsArray objectAtIndex:index]; + + if ([usersArrayForSection count] != 0) + { + NSArray* sortedUsersArrayForSection = [collation sortedArrayFromArray:usersArrayForSection collationStringSelector:@selector(displayName)]; + [shortSectionsArray addObject:sortedUsersArrayForSection]; + [tmpSectionedContactsTitle addObject:[[[UILocalizedIndexedCollation currentCollation] sectionTitles] objectAtIndex:(index - indexOffset)]]; + } + } + + return [[MXKSectionedContacts alloc] initWithContacts:shortSectionsArray andTitles:tmpSectionedContactsTitle andCount:contactsCount]; +} + +- (void)sortAlphabeticallyContacts:(NSMutableArray *)contactsArray +{ + NSComparator comparator = ^NSComparisonResult(MXKContact *contactA, MXKContact *contactB) { + + if (contactA.sortingDisplayName.length && contactB.sortingDisplayName.length) + { + return [contactA.sortingDisplayName compare:contactB.sortingDisplayName options:NSCaseInsensitiveSearch]; + } + else if (contactA.sortingDisplayName.length) + { + return NSOrderedAscending; + } + else if (contactB.sortingDisplayName.length) + { + return NSOrderedDescending; + } + return [contactA.displayName compare:contactB.displayName options:NSCaseInsensitiveSearch]; + }; + + // Sort the contacts list + [contactsArray sortUsingComparator:comparator]; +} + +- (void)sortContactsByLastActiveInformation:(NSMutableArray *)contactsArray +{ + // Sort invitable contacts by last active, with "active now" first. + // ...and then alphabetically. + NSComparator comparator = ^NSComparisonResult(MXKContact *contactA, MXKContact *contactB) { + + MXUser *userA = [self firstMatrixUserOfContact:contactA]; + MXUser *userB = [self firstMatrixUserOfContact:contactB]; + + // Non-Matrix-enabled contacts are moved to the end. + if (userA && !userB) + { + return NSOrderedAscending; + } + if (!userA && userB) + { + return NSOrderedDescending; + } + + // Display active contacts first. + if (userA.currentlyActive && userB.currentlyActive) + { + // Then order by name + if (contactA.sortingDisplayName.length && contactB.sortingDisplayName.length) + { + return [contactA.sortingDisplayName compare:contactB.sortingDisplayName options:NSCaseInsensitiveSearch]; + } + else if (contactA.sortingDisplayName.length) + { + return NSOrderedAscending; + } + else if (contactB.sortingDisplayName.length) + { + return NSOrderedDescending; + } + return [contactA.displayName compare:contactB.displayName options:NSCaseInsensitiveSearch]; + } + + if (userA.currentlyActive && !userB.currentlyActive) + { + return NSOrderedAscending; + } + if (!userA.currentlyActive && userB.currentlyActive) + { + return NSOrderedDescending; + } + + // Finally, compare the lastActiveAgo + NSUInteger lastActiveAgoA = userA.lastActiveAgo; + NSUInteger lastActiveAgoB = userB.lastActiveAgo; + + if (lastActiveAgoA == lastActiveAgoB) + { + return NSOrderedSame; + } + else + { + return ((lastActiveAgoA > lastActiveAgoB) ? NSOrderedDescending : NSOrderedAscending); + } + }; + + // Sort the contacts list + [contactsArray sortUsingComparator:comparator]; +} + ++ (void)requestUserConfirmationForLocalContactsSyncInViewController:(UIViewController *)viewController completionHandler:(void (^)(BOOL))handler +{ + NSString *appDisplayName = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleDisplayName"]; + + [MXKContactManager requestUserConfirmationForLocalContactsSyncWithTitle:[MatrixKitL10n localContactsAccessDiscoveryWarningTitle] + message:[MatrixKitL10n localContactsAccessDiscoveryWarning:appDisplayName] + manualPermissionChangeMessage:[MatrixKitL10n localContactsAccessNotGranted:appDisplayName] + showPopUpInViewController:viewController + completionHandler:handler]; +} + ++ (void)requestUserConfirmationForLocalContactsSyncWithTitle:(NSString*)title + message:(NSString*)message + manualPermissionChangeMessage:(NSString*)manualPermissionChangeMessage + showPopUpInViewController:(UIViewController*)viewController + completionHandler:(void (^)(BOOL granted))handler +{ + if ([[MXKAppSettings standardAppSettings] syncLocalContacts]) + { + handler(YES); + } + else + { + UIAlertController *alert = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + [MXKTools checkAccessForContacts:manualPermissionChangeMessage showPopUpInViewController:viewController completionHandler:^(BOOL granted) { + + handler(granted); + }]; + + }]]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + handler(NO); + + }]]; + + + [viewController presentViewController:alert animated:YES completion:nil]; + } +} + +#pragma mark - Internals + +- (NSDictionary*)matrixContactsByMatrixIDFromMXSessions:(NSArray*)mxSessions +{ + // The existing dictionary of contacts will be replaced by this one + NSMutableDictionary *matrixContactByMatrixID = [[NSMutableDictionary alloc] init]; + for (MXSession *mxSession in mxSessions) + { + // Check all existing users + NSArray *mxUsers = [mxSession.users copy]; + + for (MXUser *user in mxUsers) + { + // Check whether this user has already been added + if (!matrixContactByMatrixID[user.userId]) + { + if ((self.contactManagerMXRoomSource == MXKContactManagerMXRoomSourceAll) || ((self.contactManagerMXRoomSource == MXKContactManagerMXRoomSourceDirectChats) && mxSession.directRooms[user.userId])) + { + // Check whether a contact is already defined for this id in previous dictionary + // (avoid delete and create the same ones, it could save thumbnail downloads). + MXKContact* contact = matrixContactByMatrixID[user.userId]; + if (contact) + { + contact.displayName = (user.displayname.length > 0) ? user.displayname : user.userId; + + // Check the avatar change + if ((user.avatarUrl || contact.matrixAvatarURL) && ([user.avatarUrl isEqualToString:contact.matrixAvatarURL] == NO)) + { + [contact resetMatrixThumbnail]; + } + } + else + { + contact = [[MXKContact alloc] initMatrixContactWithDisplayName:((user.displayname.length > 0) ? user.displayname : user.userId) andMatrixID:user.userId]; + } + + matrixContactByMatrixID[user.userId] = contact; + } + } + } + } + + // Do not make an immutable copy to avoid performance penalty + return matrixContactByMatrixID; +} + +- (void)refreshMatrixContacts +{ + NSArray *mxSessions = self.mxSessions; + + // Check whether at least one session is available + if (!mxSessions.count) + { + matrixContactByMatrixID = nil; + matrixContactByContactID = nil; + [self cacheMatrixContacts]; + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateMatrixContactsNotification object:nil userInfo:nil]; + } + else if (self.contactManagerMXRoomSource != MXKContactManagerMXRoomSourceNone) + { + MXWeakify(self); + + BOOL shouldFetchLocalContacts = self->matrixContactByContactID == nil; + + dispatch_async(processingQueue, ^{ + + MXStrongifyAndReturnIfNil(self); + + NSArray *sessions = self.mxSessions; + + NSMutableDictionary *matrixContactsByMatrixID = nil; + NSMutableDictionary *matrixContactsByContactID = nil; + + if (shouldFetchLocalContacts) + { + NSDictionary *cachedMatrixContacts = [self fetchCachedMatrixContacts]; + + if (!matrixContactsByContactID) + { + matrixContactsByContactID = [NSMutableDictionary dictionary]; + } + else + { + matrixContactsByContactID = [cachedMatrixContacts mutableCopy]; + } + } + else + { + matrixContactsByContactID = [NSMutableDictionary dictionary]; + } + + NSDictionary *matrixContacts = [self matrixContactsByMatrixIDFromMXSessions:sessions]; + + if (!matrixContacts) + { + matrixContactsByMatrixID = [NSMutableDictionary dictionary]; + + for (MXKContact *contact in matrixContactsByContactID.allValues) + { + matrixContactsByMatrixID[contact.matrixIdentifiers.firstObject] = contact; + } + } + else + { + matrixContactsByMatrixID = [matrixContacts mutableCopy]; + } + + for (MXKContact *contact in matrixContactsByMatrixID.allValues) + { + matrixContactsByContactID[contact.contactID] = contact; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + MXStrongifyAndReturnIfNil(self); + + // Update the matrix contacts list + self->matrixContactByMatrixID = matrixContactsByMatrixID; + self->matrixContactByContactID = matrixContactsByContactID; + + [self cacheMatrixContacts]; + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateMatrixContactsNotification object:nil userInfo:nil]; + }); + }); + } +} + +- (void)updateMatrixContactWithID:(NSString*)matrixId +{ + // Check if a one-to-one room exist for this matrix user in at least one matrix session. + NSArray *mxSessions = self.mxSessions; + for (MXSession *mxSession in mxSessions) + { + if ((self.contactManagerMXRoomSource == MXKContactManagerMXRoomSourceAll) || ((self.contactManagerMXRoomSource == MXKContactManagerMXRoomSourceDirectChats) && mxSession.directRooms[matrixId])) + { + // Retrieve the user object related to this contact + MXUser* user = [mxSession userWithUserId:matrixId]; + + // This user may not exist (if the oneToOne room is a pending invitation to him). + if (user) + { + // Update or create a contact for this user + MXKContact* contact = [matrixContactByMatrixID objectForKey:matrixId]; + BOOL isUpdated = NO; + + // already defined + if (contact) + { + // Check the display name change + NSString *userDisplayName = (user.displayname.length > 0) ? user.displayname : user.userId; + if (![contact.displayName isEqualToString:userDisplayName]) + { + contact.displayName = userDisplayName; + + [self cacheMatrixContacts]; + isUpdated = YES; + } + + // Check the avatar change + if ((user.avatarUrl || contact.matrixAvatarURL) && ([user.avatarUrl isEqualToString:contact.matrixAvatarURL] == NO)) + { + [contact resetMatrixThumbnail]; + isUpdated = YES; + } + } + else + { + contact = [[MXKContact alloc] initMatrixContactWithDisplayName:((user.displayname.length > 0) ? user.displayname : user.userId) andMatrixID:user.userId]; + [matrixContactByMatrixID setValue:contact forKey:matrixId]; + + // update the matrix contacts list + [matrixContactByContactID setValue:contact forKey:contact.contactID]; + + [self cacheMatrixContacts]; + isUpdated = YES; + } + + if (isUpdated) + { + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateMatrixContactsNotification object:contact.contactID userInfo:nil]; + } + + // Done + return; + } + } + } + + // Here no one-to-one room exist, remove the contact if any + MXKContact* contact = [matrixContactByMatrixID objectForKey:matrixId]; + if (contact) + { + [matrixContactByContactID removeObjectForKey:contact.contactID]; + [matrixContactByMatrixID removeObjectForKey:matrixId]; + + [self cacheMatrixContacts]; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateMatrixContactsNotification object:contact.contactID userInfo:nil]; + } +} + +- (void)updateLocalContactMatrixIDs:(MXKContact*) contact +{ + for (MXKPhoneNumber* phoneNumber in contact.phoneNumbers) + { + if (phoneNumber.msisdn) + { + NSString* matrixID = [matrixIDBy3PID objectForKey:phoneNumber.msisdn]; + + dispatch_async(dispatch_get_main_queue(), ^{ + + [phoneNumber setMatrixID:matrixID]; + + }); + } + } + + for (MXKEmail* email in contact.emailAddresses) + { + if (email.emailAddress.length > 0) + { + NSString *matrixID = [matrixIDBy3PID objectForKey:email.emailAddress]; + + dispatch_async(dispatch_get_main_queue(), ^{ + + [email setMatrixID:matrixID]; + + }); + } + } +} + +- (void)updateAllLocalContactsMatrixIDs +{ + // Check if the user allowed to sync local contacts + if (![MXKAppSettings standardAppSettings].syncLocalContacts) + { + return; + } + + NSArray* localContacts = [localContactByContactID allValues]; + + // update the contacts info + for (MXKContact* contact in localContacts) + { + [self updateLocalContactMatrixIDs:contact]; + } +} + +- (MXUser*)firstMatrixUserOfContact:(MXKContact*)contact; +{ + MXUser *user = nil; + + NSArray *identifiers = contact.matrixIdentifiers; + if (identifiers.count) + { + for (MXSession *session in mxSessionArray) + { + user = [session userWithUserId:identifiers.firstObject]; + if (user) + { + break; + } + } + } + + return user; +} + + +#pragma mark - Identity server updates + +- (void)registerAccountDataDidChangeIdentityServerNotification +{ + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleAccountDataDidChangeIdentityServerNotification:) name:kMXSessionAccountDataDidChangeIdentityServerNotification object:nil]; +} + +- (void)handleAccountDataDidChangeIdentityServerNotification:(NSNotification*)notification +{ + MXLogDebug(@"[MXKContactManager] handleAccountDataDidChangeIdentityServerNotification"); + + if (!self.allowLocalContactsAccess) + { + MXLogDebug(@"[MXKContactManager] handleAccountDataDidChangeIdentityServerNotification. Does nothing because local contacts access not allowed."); + return; + } + + // Use the identity server of the up + MXSession *mxSession = notification.object; + if (mxSession != mxSessionArray.firstObject) + { + return; + } + + if (self.identityService) + { + // Do a full lookup + // But check first if the data is loaded + if (!self->localContactByContactID ) + { + // Load data. That will trigger updateMatrixIDsForAllLocalContacts if needed + [self refreshLocalContacts]; + } + else + { + [self updateMatrixIDsForAllLocalContacts]; + } + } + else + { + [self resetMatrixIDs]; + } +} + + +#pragma mark - KVO + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context +{ + if (!self.allowLocalContactsAccess) + { + MXLogDebug(@"[MXKContactManager] Ignoring KVO changes, because local contacts access not allowed."); + return; + } + + if ([@"syncLocalContacts" isEqualToString:keyPath]) + { + dispatch_async(dispatch_get_main_queue(), ^{ + + [self refreshLocalContacts]; + + }); + } + else if ([@"phonebookCountryCode" isEqualToString:keyPath]) + { + dispatch_async(dispatch_get_main_queue(), ^{ + + [self internationalizePhoneNumbers:[[MXKAppSettings standardAppSettings] phonebookCountryCode]]; + + // Refresh local contacts if we have some + if (MXKAppSettings.standardAppSettings.syncLocalContacts && self->localContactByContactID.count) + { + [self refreshLocalContacts]; + } + + }); + } +} + +#pragma mark - file caches + +static NSString *MXKContactManagerDomain = @"org.matrix.MatrixKit.MXKContactManager"; +static NSInteger MXContactManagerEncryptionDelegateNotReady = -1; + +static NSString *matrixContactsFileOld = @"matrixContacts"; +static NSString *matrixIDsDictFileOld = @"matrixIDsDict"; +static NSString *localContactsFileOld = @"localContacts"; +static NSString *contactsBookInfoFileOld = @"contacts"; + +static NSString *matrixContactsFile = @"matrixContactsV2"; +static NSString *matrixIDsDictFile = @"matrixIDsDictV2"; +static NSString *localContactsFile = @"localContactsV2"; +static NSString *contactsBookInfoFile = @"contactsV2"; + +- (NSString*)dataFilePathForComponent:(NSString*)component +{ + NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES); + NSString *documentsDirectory = [paths objectAtIndex:0]; + return [documentsDirectory stringByAppendingPathComponent:component]; +} + +- (void)cacheMatrixContacts +{ + NSString *dataFilePath = [self dataFilePathForComponent:matrixContactsFile]; + + if (matrixContactByContactID && (matrixContactByContactID.count > 0)) + { + // Switch on processing queue because matrixContactByContactID dictionary may be huge. + NSDictionary *matrixContactByContactIDCpy = [matrixContactByContactID copy]; + + dispatch_async(processingQueue, ^{ + + NSMutableData *theData = [NSMutableData data]; + NSKeyedArchiver *encoder = [[NSKeyedArchiver alloc] initForWritingWithMutableData:theData]; + + [encoder encodeObject:matrixContactByContactIDCpy forKey:@"matrixContactByContactID"]; + + [encoder finishEncoding]; + + [self encryptAndSaveData:theData toFile:matrixContactsFile]; + }); + } + else + { + NSFileManager *fileManager = [[NSFileManager alloc] init]; + [fileManager removeItemAtPath:dataFilePath error:nil]; + } +} + +- (NSDictionary*)fetchCachedMatrixContacts +{ + NSDate *startDate = [NSDate date]; + + NSString *dataFilePath = [self dataFilePathForComponent:matrixContactsFile]; + + NSFileManager *fileManager = [[NSFileManager alloc] init]; + + __block NSDictionary *matrixContactByContactID = nil; + + if ([fileManager fileExistsAtPath:dataFilePath]) + { + @try + { + NSData* filecontent = [NSData dataWithContentsOfFile:dataFilePath options:(NSDataReadingMappedAlways | NSDataReadingUncached) error:nil]; + + NSError *error = nil; + filecontent = [self decryptData:filecontent error:&error fileName:matrixContactsFile]; + + if (!error) + { + NSKeyedUnarchiver *decoder = [[NSKeyedUnarchiver alloc] initForReadingWithData:filecontent]; + + id object = [decoder decodeObjectForKey:@"matrixContactByContactID"]; + + if ([object isKindOfClass:[NSDictionary class]]) + { + matrixContactByContactID = object; + } + + [decoder finishDecoding]; + } + else + { + MXLogDebug(@"[MXKContactManager] fetchCachedMatrixContacts: failed to decrypt %@: %@", matrixContactsFile, error); + } + } + @catch (NSException *exception) + { + } + } + + MXLogDebug(@"[MXKContactManager] fetchCachedMatrixContacts : Loaded %tu contacts in %.0fms", matrixContactByContactID.count, [[NSDate date] timeIntervalSinceDate:startDate] * 1000); + + return matrixContactByContactID; +} + +- (void)cacheMatrixIDsDict +{ + NSString *dataFilePath = [self dataFilePathForComponent:matrixIDsDictFile]; + + if (matrixIDBy3PID.count) + { + NSMutableData *theData = [NSMutableData data]; + NSKeyedArchiver *encoder = [[NSKeyedArchiver alloc] initForWritingWithMutableData:theData]; + + [encoder encodeObject:matrixIDBy3PID forKey:@"matrixIDsDict"]; + [encoder finishEncoding]; + + [self encryptAndSaveData:theData toFile:matrixIDsDictFile]; + } + else + { + NSFileManager *fileManager = [[NSFileManager alloc] init]; + [fileManager removeItemAtPath:dataFilePath error:nil]; + } +} + +- (void)loadCachedMatrixIDsDict +{ + NSString *dataFilePath = [self dataFilePathForComponent:matrixIDsDictFile]; + + NSFileManager *fileManager = [[NSFileManager alloc] init]; + + if ([fileManager fileExistsAtPath:dataFilePath]) + { + // the file content could be corrupted + @try + { + NSData* filecontent = [NSData dataWithContentsOfFile:dataFilePath options:(NSDataReadingMappedAlways | NSDataReadingUncached) error:nil]; + + NSError *error = nil; + filecontent = [self decryptData:filecontent error:&error fileName:matrixIDsDictFile]; + + if (!error) + { + NSKeyedUnarchiver *decoder = [[NSKeyedUnarchiver alloc] initForReadingWithData:filecontent]; + + id object = [decoder decodeObjectForKey:@"matrixIDsDict"]; + + if ([object isKindOfClass:[NSDictionary class]]) + { + matrixIDBy3PID = [object mutableCopy]; + } + + [decoder finishDecoding]; + } + else + { + MXLogDebug(@"[MXKContactManager] loadCachedMatrixIDsDict: failed to decrypt %@: %@", matrixIDsDictFile, error); + } + } + @catch (NSException *exception) + { + } + } + + if (!matrixIDBy3PID) + { + matrixIDBy3PID = [[NSMutableDictionary alloc] init]; + } +} + +- (void)cacheLocalContacts +{ + NSString *dataFilePath = [self dataFilePathForComponent:localContactsFile]; + + if (localContactByContactID && (localContactByContactID.count > 0)) + { + NSMutableData *theData = [NSMutableData data]; + NSKeyedArchiver *encoder = [[NSKeyedArchiver alloc] initForWritingWithMutableData:theData]; + + [encoder encodeObject:localContactByContactID forKey:@"localContactByContactID"]; + + [encoder finishEncoding]; + + [self encryptAndSaveData:theData toFile:localContactsFile]; + } + else + { + NSFileManager *fileManager = [[NSFileManager alloc] init]; + [fileManager removeItemAtPath:dataFilePath error:nil]; + } +} + +- (void)loadCachedLocalContacts +{ + NSString *dataFilePath = [self dataFilePathForComponent:localContactsFile]; + + NSFileManager *fileManager = [[NSFileManager alloc] init]; + + if ([fileManager fileExistsAtPath:dataFilePath]) + { + // the file content could be corrupted + @try + { + NSData* filecontent = [NSData dataWithContentsOfFile:dataFilePath options:(NSDataReadingMappedAlways | NSDataReadingUncached) error:nil]; + + NSError *error = nil; + filecontent = [self decryptData:filecontent error:&error fileName:localContactsFile]; + + if (!error) + { + NSKeyedUnarchiver *decoder = [[NSKeyedUnarchiver alloc] initForReadingWithData:filecontent]; + + id object = [decoder decodeObjectForKey:@"localContactByContactID"]; + + if ([object isKindOfClass:[NSDictionary class]]) + { + localContactByContactID = [object mutableCopy]; + } + + [decoder finishDecoding]; + } + else + { + MXLogDebug(@"[MXKContactManager] loadCachedLocalContacts: failed to decrypt %@: %@", localContactsFile, error); + } + } + @catch (NSException *exception) + { + lastSyncDate = nil; + } + } + + if (!localContactByContactID) + { + localContactByContactID = [[NSMutableDictionary alloc] init]; + } +} + +- (void)cacheContactBookInfo +{ + NSString *dataFilePath = [self dataFilePathForComponent:contactsBookInfoFile]; + + if (lastSyncDate) + { + NSMutableData *theData = [NSMutableData data]; + NSKeyedArchiver *encoder = [[NSKeyedArchiver alloc] initForWritingWithMutableData:theData]; + + [encoder encodeObject:lastSyncDate forKey:@"lastSyncDate"]; + + [encoder finishEncoding]; + + [self encryptAndSaveData:theData toFile:contactsBookInfoFile]; + } + else + { + NSFileManager *fileManager = [[NSFileManager alloc] init]; + [fileManager removeItemAtPath:dataFilePath error:nil]; + } +} + +- (void)loadCachedContactBookInfo +{ + NSString *dataFilePath = [self dataFilePathForComponent:contactsBookInfoFile]; + + NSFileManager *fileManager = [[NSFileManager alloc] init]; + + if ([fileManager fileExistsAtPath:dataFilePath]) + { + // the file content could be corrupted + @try + { + NSData* filecontent = [NSData dataWithContentsOfFile:dataFilePath options:(NSDataReadingMappedAlways | NSDataReadingUncached) error:nil]; + + NSError *error = nil; + filecontent = [self decryptData:filecontent error:&error fileName:contactsBookInfoFile]; + + if (!error) + { + NSKeyedUnarchiver *decoder = [[NSKeyedUnarchiver alloc] initForReadingWithData:filecontent]; + + lastSyncDate = [decoder decodeObjectForKey:@"lastSyncDate"]; + + [decoder finishDecoding]; + } + else + { + lastSyncDate = nil; + MXLogDebug(@"[MXKContactManager] loadCachedContactBookInfo: failed to decrypt %@: %@", contactsBookInfoFile, error); + } + } + @catch (NSException *exception) + { + lastSyncDate = nil; + } + } +} + +- (BOOL)encryptAndSaveData:(NSData*)data toFile:(NSString*)fileName +{ + NSError *error = nil; + NSData *cipher = [self encryptData:data error:&error fileName:fileName]; + + if (error == nil) + { + [cipher writeToFile:[self dataFilePathForComponent:fileName] atomically:YES]; + } + else + { + MXLogDebug(@"[MXKContactManager] encryptAndSaveData: failed to encrypt %@", fileName); + } + + return error == nil; +} + +- (NSData*)encryptData:(NSData*)data error:(NSError**)error fileName:(NSString*)fileName +{ + @try + { + MXKeyData *keyData = (MXKeyData *) [[MXKeyProvider sharedInstance] requestKeyForDataOfType:MXKContactManagerDataType isMandatory:NO expectedKeyType:kAes]; + if (keyData && [keyData isKindOfClass:[MXAesKeyData class]]) + { + MXAesKeyData *aesKey = (MXAesKeyData *) keyData; + NSData *cipher = [MXAes encrypt:data aesKey:aesKey.key iv:aesKey.iv error:error]; + MXLogDebug(@"[MXKContactManager] encryptData: encrypted %lu Bytes for %@", cipher.length, fileName); + return cipher; + } + } + @catch (NSException *exception) + { + *error = [NSError errorWithDomain:MXKContactManagerDomain code:MXContactManagerEncryptionDelegateNotReady userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"encryptData failed: %@", exception.reason]}]; + } + + MXLogDebug(@"[MXKContactManager] encryptData: no key method provided for encryption of %@", fileName); + return data; +} + +- (NSData*)decryptData:(NSData*)data error:(NSError**)error fileName:(NSString*)fileName +{ + @try + { + MXKeyData *keyData = [[MXKeyProvider sharedInstance] requestKeyForDataOfType:MXKContactManagerDataType isMandatory:NO expectedKeyType:kAes]; + if (keyData && [keyData isKindOfClass:[MXAesKeyData class]]) + { + MXAesKeyData *aesKey = (MXAesKeyData *) keyData; + NSData *decrypt = [MXAes decrypt:data aesKey:aesKey.key iv:aesKey.iv error:error]; + MXLogDebug(@"[MXKContactManager] decryptData: decrypted %lu Bytes for %@", decrypt.length, fileName); + return decrypt; + } + } + @catch (NSException *exception) + { + *error = [NSError errorWithDomain:MXKContactManagerDomain code:MXContactManagerEncryptionDelegateNotReady userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"decryptData failed: %@", exception.reason]}]; + } + + MXLogDebug(@"[MXKContactManager] decryptData: no key method provided for decryption of %@", fileName); + return data; +} + +- (void)deleteOldFiles { + NSFileManager *fileManager = [[NSFileManager alloc] init]; + NSArray *oldFileNames = @[matrixContactsFileOld, matrixIDsDictFileOld, localContactsFileOld, contactsBookInfoFileOld]; + NSError *error = nil; + + for (NSString *fileName in oldFileNames) { + NSString *filePath = [self dataFilePathForComponent:fileName]; + if ([fileManager fileExistsAtPath:filePath]) + { + error = nil; + if (![fileManager removeItemAtPath:filePath error:&error]) + { + MXLogDebug(@"[MXKContactManager] deleteOldFiles: failed to remove %@", fileName); + } + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKEmail.h b/Riot/Modules/MatrixKit/Models/Contact/MXKEmail.h new file mode 100644 index 000000000..7073ae5c6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKEmail.h @@ -0,0 +1,30 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import "MXKContactField.h" + +@interface MXKEmail : MXKContactField + +// email info (the address is stored in lowercase) +@property (nonatomic, readonly) NSString *type; +@property (nonatomic, readonly) NSString *emailAddress; + +- (id)initWithEmailAddress:(NSString*)anEmailAddress type:(NSString*)aType contactID:(NSString*)aContactID matrixID:(NSString*)matrixID; + +- (BOOL)matchedWithPatterns:(NSArray*)patterns; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKEmail.m b/Riot/Modules/MatrixKit/Models/Contact/MXKEmail.m new file mode 100644 index 000000000..5ccda81c3 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKEmail.m @@ -0,0 +1,91 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKEmail.h" + +@implementation MXKEmail + +- (id)init +{ + self = [super init]; + + if (self) + { + _emailAddress = nil; + _type = nil; + } + + return self; +} + +- (id)initWithEmailAddress:(NSString*)anEmailAddress type:(NSString*)aType contactID:(NSString*)aContactID matrixID:(NSString*)matrixID +{ + self = [super initWithContactID:aContactID matrixID:matrixID]; + + if (self) + { + _emailAddress = [anEmailAddress lowercaseString]; + _type = aType; + } + + return self; +} + +- (BOOL)matchedWithPatterns:(NSArray*)patterns +{ + // no number -> cannot match + if (_emailAddress.length == 0) + { + return NO; + } + if (patterns.count > 0) + { + for(NSString *pattern in patterns) + { + if ([_emailAddress rangeOfString:pattern options:NSCaseInsensitiveSearch].location == NSNotFound) + { + return NO; + } + } + } + + return YES; +} +#pragma mark NSCoding + +- (id)initWithCoder:(NSCoder *)coder +{ + self = [super initWithCoder:coder]; + + if (self) + { + _type = [coder decodeObjectForKey:@"type"]; + _emailAddress = [[coder decodeObjectForKey:@"emailAddress"] lowercaseString]; + } + + return self; +} + +- (void)encodeWithCoder:(NSCoder *)coder +{ + [super encodeWithCoder:coder]; + + [coder encodeObject:_type forKey:@"type"]; + [coder encodeObject:_emailAddress forKey:@"emailAddress"]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKPhoneNumber.h b/Riot/Modules/MatrixKit/Models/Contact/MXKPhoneNumber.h new file mode 100644 index 000000000..b992dd358 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKPhoneNumber.h @@ -0,0 +1,78 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKContactField.h" + +@class NBPhoneNumber; + +@interface MXKPhoneNumber : MXKContactField + +/** + The phone number information + */ +@property (nonatomic, readonly) NSString *type; +@property (nonatomic, readonly) NSString *textNumber; +@property (nonatomic, readonly) NSString *cleanedPhonenumber; + +/** + When the number is considered to be a possible number. We expose here + the corresponding NBPhoneNumber instance. Use the NBPhoneNumberUtil interface + to format this phone number, or check whether the number is actually a + valid number. + */ +@property (nonatomic, readonly) NBPhoneNumber* nbPhoneNumber; + +/** + The default ISO 3166-1 country code used to parse the text number, + and create the nbPhoneNumber instance. + */ +@property (nonatomic) NSString *defaultCountryCode; + +/** + The Mobile Station International Subscriber Directory Number. + Available when the nbPhoneNumber is not nil. + */ +@property (nonatomic, readonly) NSString *msisdn; + +/** + Create a new MXKPhoneNumber instance + + @param textNumber the phone number + @param type the phone number type + @param contactID The identifier of the contact to whom the data belongs to. + @param matrixID The linked matrix identifier if any. + */ +- (id)initWithTextNumber:(NSString*)textNumber type:(NSString*)type contactID:(NSString*)contactID matrixID:(NSString*)matrixID; + +/** + Return YES when all the provided patterns are found in the phone number or its msisdn. + + @param patterns an array of patterns (The potential "+" (or "00") prefix is ignored during the msisdn handling). + */ +- (BOOL)matchedWithPatterns:(NSArray*)patterns; + +/** + Tell whether the phone number or its msisdn has the provided prefix. + + @param prefix a non empty string (The potential "+" (or "00") prefix is ignored during the msisdn handling). + @return YES when the phone number or its msisdn has the provided prefix. + */ +- (BOOL)hasPrefix:(NSString*)prefix; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKPhoneNumber.m b/Riot/Modules/MatrixKit/Models/Contact/MXKPhoneNumber.m new file mode 100644 index 000000000..5e4779926 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKPhoneNumber.m @@ -0,0 +1,213 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKPhoneNumber.h" + +@import libPhoneNumber_iOS; + +@implementation MXKPhoneNumber + +@synthesize msisdn; + +- (id)initWithTextNumber:(NSString*)textNumber type:(NSString*)type contactID:(NSString*)contactID matrixID:(NSString*)matrixID +{ + self = [super initWithContactID:contactID matrixID:matrixID]; + + if (self) + { + _type = type ? type : @""; + _textNumber = textNumber ? textNumber : @"" ; + _cleanedPhonenumber = [MXKPhoneNumber cleanPhonenumber:_textNumber]; + _defaultCountryCode = nil; + msisdn = nil; + + _nbPhoneNumber = [[NBPhoneNumberUtil sharedInstance] parse:_cleanedPhonenumber defaultRegion:nil error:nil]; + } + + return self; +} + +// remove the unuseful characters in a phonenumber ++ (NSString*)cleanPhonenumber:(NSString*)phoneNumber +{ + // sanity check + if (nil == phoneNumber) + { + return nil; + } + + // empty string + if (0 == [phoneNumber length]) + { + return @""; + } + + static NSCharacterSet *invertedPhoneCharSet = nil; + + if (!invertedPhoneCharSet) + { + invertedPhoneCharSet = [[NSCharacterSet characterSetWithCharactersInString:@"0123456789+*#,()"] invertedSet]; + } + + return [[phoneNumber componentsSeparatedByCharactersInSet:invertedPhoneCharSet] componentsJoinedByString:@""]; +} + + +- (BOOL)matchedWithPatterns:(NSArray*)patterns +{ + // no number -> cannot match + if (_textNumber.length == 0) + { + return NO; + } + + if (patterns.count > 0) + { + for (NSString *pattern in patterns) + { + if ([_textNumber rangeOfString:pattern].location == NSNotFound) + { + NSString *cleanPattern = [[pattern componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceCharacterSet]] componentsJoinedByString:@""]; + + if ([_cleanedPhonenumber rangeOfString:cleanPattern].location == NSNotFound) + { + NSString *msisdnPattern; + + if ([cleanPattern hasPrefix:@"+"]) + { + msisdnPattern = [cleanPattern substringFromIndex:1]; + } + else if ([cleanPattern hasPrefix:@"00"]) + { + msisdnPattern = [cleanPattern substringFromIndex:2]; + } + else + { + msisdnPattern = cleanPattern; + } + + // Check the msisdn + if (!self.msisdn || !msisdnPattern.length || [self.msisdn rangeOfString:msisdnPattern].location == NSNotFound) + { + return NO; + } + } + + } + } + } + + return YES; +} + +- (BOOL)hasPrefix:(NSString*)prefix +{ + // no number -> cannot match + if (_textNumber.length == 0) + { + return NO; + } + + if ([_textNumber hasPrefix:prefix]) + { + return YES; + } + + // Remove whitespace before checking the cleaned phone number + prefix = [[prefix componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceCharacterSet]] componentsJoinedByString:@""]; + + if ([_cleanedPhonenumber hasPrefix:prefix]) + { + return YES; + } + + if (self.msisdn) + { + if ([prefix hasPrefix:@"+"]) + { + prefix = [prefix substringFromIndex:1]; + } + else if ([prefix hasPrefix:@"00"]) + { + prefix = [prefix substringFromIndex:2]; + } + + return [self.msisdn hasPrefix:prefix]; + } + + return NO; +} + +- (void)setDefaultCountryCode:(NSString *)defaultCountryCode +{ + if (![defaultCountryCode isEqualToString:_defaultCountryCode]) + { + _nbPhoneNumber = [[NBPhoneNumberUtil sharedInstance] parse:_cleanedPhonenumber defaultRegion:defaultCountryCode error:nil]; + + _defaultCountryCode = defaultCountryCode; + msisdn = nil; + } +} + +- (NSString*)msisdn +{ + if (!msisdn && _nbPhoneNumber) + { + NSString *e164 = [[NBPhoneNumberUtil sharedInstance] format:_nbPhoneNumber numberFormat:NBEPhoneNumberFormatE164 error:nil]; + if ([e164 hasPrefix:@"+"]) + { + msisdn = [e164 substringFromIndex:1]; + } + else if ([e164 hasPrefix:@"00"]) + { + msisdn = [e164 substringFromIndex:2]; + } + } + return msisdn; +} + +#pragma mark NSCoding + +- (id)initWithCoder:(NSCoder *)coder +{ + self = [super initWithCoder:coder]; + + if (self) + { + _type = [coder decodeObjectForKey:@"type"]; + _textNumber = [coder decodeObjectForKey:@"textNumber"]; + _cleanedPhonenumber = [coder decodeObjectForKey:@"cleanedPhonenumber"]; + _defaultCountryCode = [coder decodeObjectForKey:@"countryCode"]; + + _nbPhoneNumber = [[NBPhoneNumberUtil sharedInstance] parse:_cleanedPhonenumber defaultRegion:_defaultCountryCode error:nil]; + msisdn = nil; + } + + return self; +} + +- (void)encodeWithCoder:(NSCoder *)coder +{ + [super encodeWithCoder:coder]; + + [coder encodeObject:_type forKey:@"type"]; + [coder encodeObject:_textNumber forKey:@"textNumber"]; + [coder encodeObject:_cleanedPhonenumber forKey:@"cleanedPhonenumber"]; + [coder encodeObject:_defaultCountryCode forKey:@"countryCode"]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKSectionedContacts.h b/Riot/Modules/MatrixKit/Models/Contact/MXKSectionedContacts.h new file mode 100644 index 000000000..aaee29742 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKSectionedContacts.h @@ -0,0 +1,33 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKContact.h" + +@interface MXKSectionedContacts : NSObject { + int contactsCount; + NSArray *sectionTitles; + NSArray*> *sectionedContacts; +} + +@property (nonatomic, readonly) int contactsCount; +@property (nonatomic, readonly) NSArray *sectionTitles; +@property (nonatomic, readonly) NSArray*> *sectionedContacts; + +- (instancetype)initWithContacts:(NSArray*> *)inSectionedContacts andTitles:(NSArray *)titles andCount:(int)count; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKSectionedContacts.m b/Riot/Modules/MatrixKit/Models/Contact/MXKSectionedContacts.m new file mode 100644 index 000000000..a8237dfcc --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKSectionedContacts.m @@ -0,0 +1,32 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKSectionedContacts.h" + +@implementation MXKSectionedContacts + +@synthesize contactsCount, sectionTitles, sectionedContacts; + +-(id)initWithContacts:(NSArray *> *)inSectionedContacts andTitles:(NSArray *)titles andCount:(int)count { + if (self = [super init]) { + contactsCount = count; + sectionedContacts = inSectionedContacts; + sectionTitles = titles; + } + return self; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Group/MXKGroupCellData.h b/Riot/Modules/MatrixKit/Models/Group/MXKGroupCellData.h new file mode 100644 index 000000000..9ecd35b9c --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Group/MXKGroupCellData.h @@ -0,0 +1,24 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKGroupCellDataStoring.h" + +/** + `MXKGroupCellData` modelised the data for a `MXKGroupTableViewCell` cell. + */ +@interface MXKGroupCellData : MXKCellData + +@end diff --git a/Riot/Modules/MatrixKit/Models/Group/MXKGroupCellData.m b/Riot/Modules/MatrixKit/Models/Group/MXKGroupCellData.m new file mode 100644 index 000000000..9bb53db22 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Group/MXKGroupCellData.m @@ -0,0 +1,49 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKGroupCellData.h" + +#import "MXKSessionGroupsDataSource.h" + +@implementation MXKGroupCellData +@synthesize group, groupsDataSource, groupDisplayname, sortingDisplayname; + +- (instancetype)initWithGroup:(MXGroup*)theGroup andGroupsDataSource:(MXKSessionGroupsDataSource*)theGroupsDataSource +{ + self = [self init]; + if (self) + { + groupsDataSource = theGroupsDataSource; + [self updateWithGroup:theGroup]; + } + return self; +} + +- (void)updateWithGroup:(MXGroup*)theGroup +{ + group = theGroup; + + groupDisplayname = sortingDisplayname = group.profile.name; + + if (!groupDisplayname.length) + { + groupDisplayname = group.groupId; + // Ignore the prefix '+' of the group id during sorting. + sortingDisplayname = [groupDisplayname substringFromIndex:1]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Group/MXKGroupCellDataStoring.h b/Riot/Modules/MatrixKit/Models/Group/MXKGroupCellDataStoring.h new file mode 100644 index 000000000..9ca2bd57b --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Group/MXKGroupCellDataStoring.h @@ -0,0 +1,53 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKCellData.h" + +@class MXKSessionGroupsDataSource; + +/** + `MXKGroupCellDataStoring` defines a protocol a class must conform in order to store group cell data + managed by `MXKSessionGroupsDataSource`. + */ +@protocol MXKGroupCellDataStoring + +@property (nonatomic, weak, readonly) MXKSessionGroupsDataSource *groupsDataSource; + +@property (nonatomic, readonly) MXGroup *group; + +@property (nonatomic, readonly) NSString *groupDisplayname; +@property (nonatomic, readonly) NSString *sortingDisplayname; + +#pragma mark - Public methods +/** + Create a new `MXKCellData` object for a new group cell. + + @param group the `MXGroup` object that has data about the group. + @param groupsDataSource the `MXKSessionGroupsDataSource` object that will use this instance. + @return the newly created instance. + */ +- (instancetype)initWithGroup:(MXGroup*)group andGroupsDataSource:(MXKSessionGroupsDataSource*)groupsDataSource; + +/** + The `MXKSessionGroupsDataSource` object calls this method when the group data has been updated. + + @param group the updated group. + */ +- (void)updateWithGroup:(MXGroup*)group; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Group/MXKSessionGroupsDataSource.h b/Riot/Modules/MatrixKit/Models/Group/MXKSessionGroupsDataSource.h new file mode 100644 index 000000000..7ee1ac3c0 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Group/MXKSessionGroupsDataSource.h @@ -0,0 +1,94 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKDataSource.h" +#import "MXKGroupCellData.h" + +/** + Identifier to use for cells that display a group. + */ +extern NSString *const kMXKGroupCellIdentifier; + +/** + 'MXKSessionGroupsDataSource' is a base class to handle the groups of a matrix session. + A 'MXKSessionGroupsDataSource' instance provides the data source for `MXKGroupListViewController`. + + A section is created to handle the invitations to a group, the first one if any. + */ +@interface MXKSessionGroupsDataSource : MXKDataSource +{ +@protected + + /** + The current list of the group invitations (sorted in the alphabetic order). + This list takes into account potential filter defined by`patternsList`. + */ + NSMutableArray *groupsInviteCellDataArray; + + /** + The current displayed list of the joined groups (sorted in the alphabetic order). + This list takes into account potential filter defined by`patternsList`. + */ + NSMutableArray *groupsCellDataArray; +} + +@property (nonatomic) NSInteger groupInvitesSection; +@property (nonatomic) NSInteger joinedGroupsSection; + +#pragma mark - Life cycle + +/** + Refresh all the groups summary. + The group data are not synced with the server, use this method to refresh them according to your needs. + + @param completion the block to execute when a request has been done for each group (whatever the result of the requests). + You may specify nil for this parameter. + */ +- (void)refreshGroupsSummary:(void (^)(void))completion; + +/** + Filter the current groups list according to the provided patterns. + When patterns are not empty, the search result is stored in `filteredGroupsCellDataArray`, + this array provides then data for the cells served by `MXKSessionGroupsDataSource`. + + @param patternsList the list of patterns (`NSString` instances) to match with. Set nil to cancel search. + */ +- (void)searchWithPatterns:(NSArray*)patternsList; + +/** + Get the data for the cell at the given index path. + + @param indexPath the index of the cell in the table + @return the cell data + */ +- (id)cellDataAtIndex:(NSIndexPath*)indexPath; + +/** + Get the index path of the cell related to the provided groupId. + + @param groupId the group identifier. + @return indexPath the index of the cell (nil if not found). + */ +- (NSIndexPath*)cellIndexPathWithGroupId:(NSString*)groupId; + +/** + Leave the group displayed at the provided path. + + @param indexPath the index of the group cell in the table + */ +- (void)leaveGroupAtIndexPath:(NSIndexPath *)indexPath; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Group/MXKSessionGroupsDataSource.m b/Riot/Modules/MatrixKit/Models/Group/MXKSessionGroupsDataSource.m new file mode 100644 index 000000000..cb9f7a948 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Group/MXKSessionGroupsDataSource.m @@ -0,0 +1,611 @@ +/* + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKSessionGroupsDataSource.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKConstants.h" + +#import "MXKSwiftHeader.h" + +#pragma mark - Constant definitions +NSString *const kMXKGroupCellIdentifier = @"kMXKGroupCellIdentifier"; + + +@interface MXKSessionGroupsDataSource () +{ + /** + Internal array used to regulate change notifications. + Cell data changes are stored instantly in this array. + We wait at least for 500 ms between two notifications of the delegate. + */ + NSMutableArray *internalCellDataArray; + + /* + Timer to not notify the delegate on every changes. + */ + NSTimer *timer; + + /* + Tells whether some changes must be notified. + */ + BOOL isDataChangePending; + + /** + Store the current search patterns list. + */ + NSArray* searchPatternsList; +} + +@end + +@implementation MXKSessionGroupsDataSource + +- (instancetype)initWithMatrixSession:(MXSession *)matrixSession +{ + self = [super initWithMatrixSession:matrixSession]; + if (self) + { + internalCellDataArray = [NSMutableArray array]; + groupsCellDataArray = [NSMutableArray array]; + groupsInviteCellDataArray = [NSMutableArray array]; + + isDataChangePending = NO; + + // Set default data and view classes + [self registerCellDataClass:MXKGroupCellData.class forCellIdentifier:kMXKGroupCellIdentifier]; + } + return self; +} + +- (void)destroy +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + groupsCellDataArray = nil; + groupsInviteCellDataArray = nil; + internalCellDataArray = nil; + + searchPatternsList = nil; + + [timer invalidate]; + timer = nil; + + [super destroy]; +} + +- (void)didMXSessionStateChange +{ + if (MXSessionStateRunning <= self.mxSession.state) + { + // Check whether some data have been already load + if (0 == internalCellDataArray.count) + { + [self loadData]; + } + else if (self.mxSession.state == MXSessionStateRunning) + { + // Refresh the group data + [self refreshGroupsSummary:nil]; + } + } +} + +#pragma mark - + +- (void)refreshGroupsSummary:(void (^)(void))completion +{ + MXLogDebug(@"[MXKSessionGroupsDataSource] refreshGroupsSummary"); + + __block NSUInteger count = internalCellDataArray.count; + + if (count) + { + for (id groupData in internalCellDataArray) + { + // Force the matrix session to refresh the group summary. + [self.mxSession updateGroupSummary:groupData.group success:^{ + + if (completion && !(--count)) + { + // All the requests have been done. + completion (); + } + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKSessionGroupsDataSource] refreshGroupsSummary: group summary update failed %@", groupData.group.groupId); + + if (completion && !(--count)) + { + // All the requests have been done. + completion (); + } + + }]; + } + } + else if (completion) + { + completion(); + } +} + +- (void)searchWithPatterns:(NSArray*)patternsList +{ + if (patternsList.count) + { + searchPatternsList = patternsList; + } + else + { + searchPatternsList = nil; + } + + [self onCellDataChange]; +} + +- (id)cellDataAtIndex:(NSIndexPath*)indexPath +{ + id groupData; + + if (indexPath.section == _groupInvitesSection) + { + if (indexPath.row < groupsInviteCellDataArray.count) + { + groupData = groupsInviteCellDataArray[indexPath.row]; + } + } + else if (indexPath.section == _joinedGroupsSection) + { + if (indexPath.row < groupsCellDataArray.count) + { + groupData = groupsCellDataArray[indexPath.row]; + } + } + + return groupData; +} + +- (NSIndexPath*)cellIndexPathWithGroupId:(NSString*)groupId +{ + // Look for the cell + if (_groupInvitesSection != -1) + { + for (NSInteger index = 0; index < groupsInviteCellDataArray.count; index ++) + { + id groupData = groupsInviteCellDataArray[index]; + if ([groupId isEqualToString:groupData.group.groupId]) + { + // Got it + return [NSIndexPath indexPathForRow:index inSection:_groupInvitesSection]; + } + } + } + + if (_joinedGroupsSection != -1) + { + for (NSInteger index = 0; index < groupsCellDataArray.count; index ++) + { + id groupData = groupsCellDataArray[index]; + if ([groupId isEqualToString:groupData.group.groupId]) + { + // Got it + return [NSIndexPath indexPathForRow:index inSection:_joinedGroupsSection]; + } + } + } + + return nil; +} + +#pragma mark - Groups processing + +- (void)loadData +{ + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionNewGroupInviteNotification object:self.mxSession]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidJoinGroupNotification object:self.mxSession]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidLeaveGroupNotification object:self.mxSession]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidUpdateGroupSummaryNotification object:self.mxSession]; + + // Reset the table + [internalCellDataArray removeAllObjects]; + + // Retrieve the MXKCellData class to manage the data + Class class = [self cellDataClassForCellIdentifier:kMXKGroupCellIdentifier]; + NSAssert([class conformsToProtocol:@protocol(MXKGroupCellDataStoring)], @"MXKSessionGroupsDataSource only manages MXKCellData that conforms to MXKGroupCellDataStoring protocol"); + + // Listen to MXSession groups changes + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onNewGroupInvite:) name:kMXSessionNewGroupInviteNotification object:self.mxSession]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didJoinGroup:) name:kMXSessionDidJoinGroupNotification object:self.mxSession]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didLeaveGroup:) name:kMXSessionDidLeaveGroupNotification object:self.mxSession]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didUpdateGroup:) name:kMXSessionDidUpdateGroupSummaryNotification object:self.mxSession]; + + NSDate *startDate = [NSDate date]; + + NSArray *groups = self.mxSession.groups; + for (MXGroup *group in groups) + { + id cellData = [[class alloc] initWithGroup:group andGroupsDataSource:self]; + if (cellData) + { + [internalCellDataArray addObject:cellData]; + + // Force the matrix session to refresh the group summary. + [self.mxSession updateGroupSummary:group success:nil failure:^(NSError *error) { + MXLogDebug(@"[MXKSessionGroupsDataSource] loadData: group summary update failed %@", group.groupId); + }]; + } + } + + MXLogDebug(@"[MXKSessionGroupsDataSource] Loaded %tu groups in %.3fms", groups.count, [[NSDate date] timeIntervalSinceDate:startDate] * 1000); + + [self sortCellData]; + [self onCellDataChange]; +} + +- (void)didUpdateGroup:(NSNotification *)notif +{ + MXGroup *group = notif.userInfo[kMXSessionNotificationGroupKey]; + if (group) + { + id groupData = [self cellDataWithGroupId:group.groupId]; + if (groupData) + { + [groupData updateWithGroup:group]; + } + else + { + MXLogDebug(@"[MXKSessionGroupsDataSource] didUpdateGroup: Cannot find the changed group for %@ (%@). It is probably not managed by this group data source", group.groupId, group); + return; + } + } + + [self sortCellData]; + [self onCellDataChange]; +} + +- (void)onNewGroupInvite:(NSNotification *)notif +{ + MXGroup *group = notif.userInfo[kMXSessionNotificationGroupKey]; + if (group) + { + // Add the group if there is not yet a cell for it + id groupData = [self cellDataWithGroupId:group.groupId]; + if (nil == groupData) + { + MXLogDebug(@"MXKSessionGroupsDataSource] Add new group invite: %@", group.groupId); + + // Retrieve the MXKCellData class to manage the data + Class class = [self cellDataClassForCellIdentifier:kMXKGroupCellIdentifier]; + + id cellData = [[class alloc] initWithGroup:group andGroupsDataSource:self]; + if (cellData) + { + [internalCellDataArray addObject:cellData]; + + [self sortCellData]; + [self onCellDataChange]; + } + } + } +} + +- (void)didJoinGroup:(NSNotification *)notif +{ + MXGroup *group = notif.userInfo[kMXSessionNotificationGroupKey]; + if (group) + { + id groupData = [self cellDataWithGroupId:group.groupId]; + if (groupData) + { + MXLogDebug(@"MXKSessionGroupsDataSource] Update joined room: %@", group.groupId); + [groupData updateWithGroup:group]; + } + else + { + MXLogDebug(@"MXKSessionGroupsDataSource] Add new joined invite: %@", group.groupId); + + // Retrieve the MXKCellData class to manage the data + Class class = [self cellDataClassForCellIdentifier:kMXKGroupCellIdentifier]; + + id cellData = [[class alloc] initWithGroup:group andGroupsDataSource:self]; + if (cellData) + { + [internalCellDataArray addObject:cellData]; + } + } + + [self sortCellData]; + [self onCellDataChange]; + } +} + +- (void)didLeaveGroup:(NSNotification *)notif +{ + NSString *groupId = notif.userInfo[kMXSessionNotificationGroupIdKey]; + if (groupId) + { + [self removeGroup:groupId]; + } +} + +- (void)removeGroup:(NSString*)groupId +{ + id groupData = [self cellDataWithGroupId:groupId]; + if (groupData) + { + MXLogDebug(@"MXKSessionGroupsDataSource] Remove left group: %@", groupId); + + [internalCellDataArray removeObject:groupData]; + + [self sortCellData]; + [self onCellDataChange]; + } +} + +- (void)onCellDataChange +{ + isDataChangePending = NO; + + // Check no notification was done recently. + // Note: do not wait in case of search + if (timer == nil || searchPatternsList) + { + [timer invalidate]; + timer = [NSTimer scheduledTimerWithTimeInterval:0.5 target:self selector:@selector(checkPendingUpdate:) userInfo:nil repeats:NO]; + + // Prepare cell data array, and notify the delegate. + [self prepareCellDataAndNotifyChanges]; + } + else + { + isDataChangePending = YES; + } +} + +- (IBAction)checkPendingUpdate:(id)sender +{ + [timer invalidate]; + timer = nil; + + if (isDataChangePending) + { + [self onCellDataChange]; + } +} + +- (void)sortCellData +{ + // Order alphabetically the groups + [internalCellDataArray sortUsingComparator:^NSComparisonResult(id cellData1, id cellData2) + { + if (cellData1.sortingDisplayname.length && cellData2.sortingDisplayname.length) + { + return [cellData1.sortingDisplayname compare:cellData2.sortingDisplayname options:NSCaseInsensitiveSearch]; + } + else if (cellData1.sortingDisplayname.length) + { + return NSOrderedAscending; + } + else if (cellData2.sortingDisplayname.length) + { + return NSOrderedDescending; + } + return NSOrderedSame; + }]; +} + +- (void)prepareCellDataAndNotifyChanges +{ + // Prepare the cell data arrays by considering the potential filter. + [groupsInviteCellDataArray removeAllObjects]; + [groupsCellDataArray removeAllObjects]; + for (id groupData in internalCellDataArray) + { + BOOL isKept = !searchPatternsList; + + for (NSString* pattern in searchPatternsList) + { + if (groupData.groupDisplayname && [groupData.groupDisplayname rangeOfString:pattern options:NSCaseInsensitiveSearch].location != NSNotFound) + { + isKept = YES; + break; + } + } + + if (isKept) + { + if (groupData.group.membership == MXMembershipInvite) + { + [groupsInviteCellDataArray addObject:groupData]; + } + else + { + [groupsCellDataArray addObject:groupData]; + } + } + } + + // Update here data source state + if (state != MXKDataSourceStateReady) + { + state = MXKDataSourceStateReady; + if (self.delegate && [self.delegate respondsToSelector:@selector(dataSource:didStateChange:)]) + { + [self.delegate dataSource:self didStateChange:state]; + } + } + + // And inform the delegate about the update + [self.delegate dataSource:self didCellChange:nil]; +} + +// Find the cell data that stores information about the given group id +- (id)cellDataWithGroupId:(NSString*)groupId +{ + id theGroupData; + for (id groupData in internalCellDataArray) + { + if ([groupData.group.groupId isEqualToString:groupId]) + { + theGroupData = groupData; + break; + } + } + return theGroupData; +} + +#pragma mark - UITableViewDataSource + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView +{ + NSInteger count = 0; + _groupInvitesSection = _joinedGroupsSection = -1; + + // Check whether all data sources are ready before rendering groups. + if (self.state == MXKDataSourceStateReady) + { + if (groupsInviteCellDataArray.count) + { + _groupInvitesSection = count++; + } + if (groupsCellDataArray.count) + { + _joinedGroupsSection = count++; + } + } + return count; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + if (section == _groupInvitesSection) + { + return groupsInviteCellDataArray.count; + } + else if (section == _joinedGroupsSection) + { + return groupsCellDataArray.count; + } + + return 0; +} + +- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section +{ + NSString* sectionTitle = nil; + + if (section == _groupInvitesSection) + { + sectionTitle = [MatrixKitL10n groupInviteSection]; + } + else if (section == _joinedGroupsSection) + { + sectionTitle = [MatrixKitL10n groupSection]; + } + + return sectionTitle; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + id groupData; + + if (indexPath.section == _groupInvitesSection) + { + if (indexPath.row < groupsInviteCellDataArray.count) + { + groupData = groupsInviteCellDataArray[indexPath.row]; + } + } + else if (indexPath.section == _joinedGroupsSection) + { + if (indexPath.row < groupsCellDataArray.count) + { + groupData = groupsCellDataArray[indexPath.row]; + } + } + + if (groupData) + { + NSString *cellIdentifier = [self.delegate cellReuseIdentifierForCellData:groupData]; + if (cellIdentifier) + { + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellIdentifier forIndexPath:indexPath]; + + // Make sure we listen to user actions on the cell + cell.delegate = self; + + // Make the bubble display the data + [cell render:groupData]; + + return cell; + } + } + + // Return a fake cell to prevent app from crashing. + return [[UITableViewCell alloc] init]; +} + +- (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath +{ + // Return NO if you do not want the specified item to be editable. + return YES; +} + +- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (editingStyle == UITableViewCellEditingStyleDelete) + { + [self leaveGroupAtIndexPath:indexPath]; + } +} + +- (void)leaveGroupAtIndexPath:(NSIndexPath *)indexPath +{ + id cellData = [self cellDataAtIndex:indexPath]; + + if (cellData.group) + { + __weak typeof(self) weakSelf = self; + + [self.mxSession leaveGroup:cellData.group.groupId success:^{ + + if (weakSelf) + { + // Refresh the table content + typeof(self) self = weakSelf; + [self removeGroup:cellData.group.groupId]; + } + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKSessionGroupsDataSource] Failed to leave group (%@)", cellData.group.groupId); + + // Notify MatrixKit user + NSString *myUserId = self.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } +} + + +@end diff --git a/Riot/Modules/MatrixKit/Models/MXK3PID.h b/Riot/Modules/MatrixKit/Models/MXK3PID.h new file mode 100644 index 000000000..1bf84cb3c --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/MXK3PID.h @@ -0,0 +1,119 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +typedef enum : NSUInteger { + MXK3PIDAuthStateUnknown, + MXK3PIDAuthStateTokenRequested, + MXK3PIDAuthStateTokenReceived, + MXK3PIDAuthStateTokenSubmitted, + MXK3PIDAuthStateAuthenticated +} MXK3PIDAuthState; + + +@interface MXK3PID : NSObject + +/** + The type of the third party media. + */ +@property (nonatomic, readonly) MX3PIDMedium medium; + +/** + The third party media (email address, msisdn,...). + */ +@property (nonatomic, readonly) NSString *address; + +/** + The current client secret key used during third party validation. + */ +@property (nonatomic, readonly) NSString *clientSecret; + +/** + The current session identifier during third party validation. + */ +@property (nonatomic, readonly) NSString *sid; + +/** + The id of the user on Matrix. + nil if unknown or not yet resolved. + */ +@property (nonatomic) NSString *userId; + +@property (nonatomic, readonly) MXK3PIDAuthState validationState; + +/** + Initialise the instance with a 3PID. + + @param medium the medium. + @param address the id of the contact on this medium. + @return the new instance. + */ +- (instancetype)initWithMedium:(NSString*)medium andAddress:(NSString*)address; + +/** + Cancel the current request, and reset parameters + */ +- (void)cancelCurrentRequest; + +/** + Start the validation process + The identity server will send a validation token by email or sms. + + In case of email, the end user must click on the link in the received email + to validate their email address in order to be able to call add3PIDToUser successfully. + + In case of phone number, the end user must send back the sms token + in order to be able to call add3PIDToUser successfully. + + @param restClient used to make matrix API requests during validation process. + @param isDuringRegistration tell whether this request occurs during a registration flow. + @param nextLink the link the validation page will automatically open. Can be nil. + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)requestValidationTokenWithMatrixRestClient:(MXRestClient*)restClient + isDuringRegistration:(BOOL)isDuringRegistration + nextLink:(NSString*)nextLink + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + Submit the received validation token. + + @param token the validation token. + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)submitValidationToken:(NSString *)token + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + Link a 3rd party id to the user. + + @param bind whether the homeserver should also bind this third party identifier + to the account's Matrix ID with the identity server. + @param success A block object called when the operation succeeds. It provides the raw + server response. + @param failure A block object called when the operation fails. + */ +- (void)add3PIDToUser:(BOOL)bind + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +@end diff --git a/Riot/Modules/MatrixKit/Models/MXK3PID.m b/Riot/Modules/MatrixKit/Models/MXK3PID.m new file mode 100644 index 000000000..5c4657cfa --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/MXK3PID.m @@ -0,0 +1,316 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXK3PID.h" + +@import libPhoneNumber_iOS; + +@interface MXK3PID () +{ + MXRestClient *mxRestClient; + MXHTTPOperation *currentRequest; +} +@property (nonatomic) NSString *clientSecret; +@property (nonatomic) NSUInteger sendAttempt; +@property (nonatomic) NSString *sid; +@property (nonatomic) MXIdentityService *identityService; +@property (nonatomic) NSString *submitUrl; + +@end + +@implementation MXK3PID + +- (instancetype)initWithMedium:(NSString *)medium andAddress:(NSString *)address +{ + self = [super init]; + if (self) + { + _medium = [medium copy]; + _address = [address copy]; + self.clientSecret = [MXTools generateSecret]; + } + return self; +} + +- (void)cancelCurrentRequest +{ + _validationState = MXK3PIDAuthStateUnknown; + + [currentRequest cancel]; + currentRequest = nil; + mxRestClient = nil; + self.identityService = nil; + + self.sendAttempt = 1; + self.sid = nil; + // Removed potential linked userId + self.userId = nil; +} + +- (void)requestValidationTokenWithMatrixRestClient:(MXRestClient*)restClient + isDuringRegistration:(BOOL)isDuringRegistration + nextLink:(NSString*)nextLink + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + // Sanity Check + if (_validationState != MXK3PIDAuthStateTokenRequested && restClient) + { + // Reset if the current state is different than "Unknown" + if (_validationState != MXK3PIDAuthStateUnknown) + { + [self cancelCurrentRequest]; + } + + NSString *identityServer = restClient.identityServer; + if (identityServer) + { + // Use same identity server as REST client for validation token submission + self.identityService = [[MXIdentityService alloc] initWithIdentityServer:identityServer accessToken:nil andHomeserverRestClient:restClient]; + } + + if ([self.medium isEqualToString:kMX3PIDMediumEmail]) + { + _validationState = MXK3PIDAuthStateTokenRequested; + mxRestClient = restClient; + + currentRequest = [mxRestClient requestTokenForEmail:self.address isDuringRegistration:isDuringRegistration clientSecret:self.clientSecret sendAttempt:self.sendAttempt nextLink:nextLink success:^(NSString *sid) { + + self->_validationState = MXK3PIDAuthStateTokenReceived; + self->currentRequest = nil; + self.sid = sid; + + if (success) + { + success(); + } + + } failure:^(NSError *error) { + + // Return in unknown state + self->_validationState = MXK3PIDAuthStateUnknown; + self->currentRequest = nil; + // Increment attempt counter + self.sendAttempt++; + + if (failure) + { + failure (error); + } + + }]; + } + else if ([self.medium isEqualToString:kMX3PIDMediumMSISDN]) + { + _validationState = MXK3PIDAuthStateTokenRequested; + mxRestClient = restClient; + + NSString *phoneNumber = [NSString stringWithFormat:@"+%@", self.address]; + + currentRequest = [mxRestClient requestTokenForPhoneNumber:phoneNumber isDuringRegistration:isDuringRegistration countryCode:nil clientSecret:self.clientSecret sendAttempt:self.sendAttempt nextLink:nextLink success:^(NSString *sid, NSString *msisdn, NSString *submitUrl) { + + self->_validationState = MXK3PIDAuthStateTokenReceived; + self->currentRequest = nil; + self.sid = sid; + self.submitUrl = submitUrl; + + if (success) + { + success(); + } + + } failure:^(NSError *error) { + + // Return in unknown state + self->_validationState = MXK3PIDAuthStateUnknown; + self->currentRequest = nil; + // Increment attempt counter + self.sendAttempt++; + + if (failure) + { + failure (error); + } + + }]; + } + else + { + MXLogDebug(@"[MXK3PID] requestValidationToken: is not supported for this 3PID: %@ (%@)", self.address, self.medium); + } + } + else + { + MXLogDebug(@"[MXK3PID] Failed to request validation token for 3PID: %@ (%@), state: %lu", self.address, self.medium, (unsigned long)_validationState); + } +} + +- (void)submitValidationToken:(NSString *)token + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + // Sanity Check + if (_validationState == MXK3PIDAuthStateTokenReceived) + { + if (self.submitUrl) + { + _validationState = MXK3PIDAuthStateTokenSubmitted; + + currentRequest = [self submitMsisdnTokenOtherUrl:self.submitUrl token:token medium:self.medium clientSecret:self.clientSecret sid:self.sid success:^{ + + self->_validationState = MXK3PIDAuthStateAuthenticated; + self->currentRequest = nil; + + if (success) + { + success(); + } + + } failure:^(NSError *error) { + + // Return in previous state + self->_validationState = MXK3PIDAuthStateTokenReceived; + self->currentRequest = nil; + + if (failure) + { + failure (error); + } + + }]; + } + else if (self.identityService) + { + _validationState = MXK3PIDAuthStateTokenSubmitted; + + currentRequest = [self.identityService submit3PIDValidationToken:token medium:self.medium clientSecret:self.clientSecret sid:self.sid success:^{ + + self->_validationState = MXK3PIDAuthStateAuthenticated; + self->currentRequest = nil; + + if (success) + { + success(); + } + + } failure:^(NSError *error) { + + // Return in previous state + self->_validationState = MXK3PIDAuthStateTokenReceived; + self->currentRequest = nil; + + if (failure) + { + failure (error); + } + + }]; + } + else + { + MXLogDebug(@"[MXK3PID] Failed to submit validation token for 3PID: %@ (%@), identity service is not set", self.address, self.medium); + + if (failure) + { + failure(nil); + } + } + } + else + { + MXLogDebug(@"[MXK3PID] Failed to submit validation token for 3PID: %@ (%@), state: %lu", self.address, self.medium, (unsigned long)_validationState); + + if (failure) + { + failure(nil); + } + } +} + +- (MXHTTPOperation *)submitMsisdnTokenOtherUrl:(NSString *)url + token:(NSString*)token + medium:(NSString *)medium + clientSecret:(NSString *)clientSecret + sid:(NSString *)sid + success:(void (^)(void))success + failure:(void (^)(NSError *))failure +{ + NSDictionary *parameters = @{ + @"sid": sid, + @"client_secret": clientSecret, + @"token": token + }; + + MXHTTPClient *httpClient = [[MXHTTPClient alloc] initWithBaseURL:nil andOnUnrecognizedCertificateBlock:nil]; + return [httpClient requestWithMethod:@"POST" + path:url + parameters:parameters + success:^(NSDictionary *JSONResponse) { + success(); + } + failure:failure]; +} + +- (void)add3PIDToUser:(BOOL)bind + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + if ([self.medium isEqualToString:kMX3PIDMediumEmail] || [self.medium isEqualToString:kMX3PIDMediumMSISDN]) + { + MXWeakify(self); + + currentRequest = [mxRestClient add3PID:self.sid clientSecret:self.clientSecret bind:bind success:^{ + + MXStrongifyAndReturnIfNil(self); + + // Update linked userId in 3PID + self.userId = self->mxRestClient.credentials.userId; + self->currentRequest = nil; + + if (success) + { + success(); + } + + } failure:^(NSError *error) { + + MXStrongifyAndReturnIfNil(self); + + self->currentRequest = nil; + + if (failure) + { + failure (error); + } + + }]; + + return; + } + else + { + MXLogDebug(@"[MXK3PID] bindWithUserId: is not supported for this 3PID: %@ (%@)", self.address, self.medium); + } + + // Here the validation process failed + if (failure) + { + failure (nil); + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/MXKAppSettings.h b/Riot/Modules/MatrixKit/Models/MXKAppSettings.h new file mode 100644 index 000000000..710e1d5db --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/MXKAppSettings.h @@ -0,0 +1,290 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +#import + +typedef NS_ENUM(NSUInteger, MXKKeyPreSharingStrategy) +{ + MXKKeyPreSharingNone = 0, + MXKKeyPreSharingWhenEnteringRoom = 1, + MXKKeyPreSharingWhenTyping = 2 +}; + +/** + `MXKAppSettings` represents the application settings. Most of them are used to handle matrix session data. + + The shared object `standardAppSettings` provides the default application settings defined in `standardUserDefaults`. + Any property change of this shared settings is reported into `standardUserDefaults`. + + Developper may define their own `MXKAppSettings` instances to handle specific setting values without impacting the shared object. + */ +@interface MXKAppSettings : NSObject + +#pragma mark - /sync filter + +/** + Lazy load room members when /syncing with the homeserver. + */ +@property (nonatomic) BOOL syncWithLazyLoadOfRoomMembers; + +#pragma mark - Room display + +/** + Display all received events in room history (Only recognized events are displayed, presently `custom` events are ignored). + + This boolean value is defined in shared settings object with the key: `showAllEventsInRoomHistory`. + Return NO if no value is defined. + */ +@property (nonatomic) BOOL showAllEventsInRoomHistory; + +/** + The types of events allowed to be displayed in room history. + Its value depends on `showAllEventsInRoomHistory`. + */ +@property (nonatomic, readonly) NSArray *eventsFilterForMessages; + +/** + All the event types which may be displayed in the room history. + */ +@property (nonatomic, readonly) NSArray *allEventTypesForMessages; + +/** + An allow list for the types of events allowed to be displayed as the last message. + + When `nil`, there is no list and all events are allowed. + */ +@property (nonatomic, readonly) NSArray *lastMessageEventTypesAllowList; + +/** + Add event types to `eventsFilterForMessages` and `eventsFilterForMessages`. + + @param eventTypes the event types to add. + */ +- (void)addSupportedEventTypes:(NSArray *)eventTypes; + +/** + Remove event types from `eventsFilterForMessages` and `eventsFilterForMessages`. + + @param eventTypes the event types to remove. + */ +- (void)removeSupportedEventTypes:(NSArray *)eventTypes; + +/** + Display redacted events in room history. + + This boolean value is defined in shared settings object with the key: `showRedactionsInRoomHistory`. + Return NO if no value is defined. + */ +@property (nonatomic) BOOL showRedactionsInRoomHistory; + +/** + Display unsupported/unexpected events in room history. + + This boolean value is defined in shared settings object with the key: `showUnsupportedEventsInRoomHistory`. + Return NO if no value is defined. + */ +@property (nonatomic) BOOL showUnsupportedEventsInRoomHistory; + +/** + Scheme with which to open HTTP links. e.g. if this is set to "googlechrome", any http:// links displayed in a room will be rewritten to use the googlechrome:// scheme. + Defaults to "http". + */ +@property (nonatomic) NSString *httpLinkScheme; + +/** + Scheme with which to open HTTPS links. e.g. if this is set to "googlechromes", any https:// links displayed in a room will be rewritten to use the googlechromes:// scheme. + Defaults to "https". + */ +@property (nonatomic) NSString *httpsLinkScheme; + +/** + Whether a bubble component should detect the first link in its event's body, storing it in the `link` property. + + This boolean value is defined in shared settings object with the key: `enableBubbleComponentLinkDetection`. + Return NO if no value is defined. + */ +@property (nonatomic) BOOL enableBubbleComponentLinkDetection; + +/** + Any hosts that should be ignored when calling `mxk_firstURLDetected` on an `NSString` without passing in any parameters. + Customising this value modifies the behaviour of link detection in `MXKRoomBubbleComponent`. + + This boolean value is defined in shared settings object with the key: `firstURLDetectionIgnoredHosts`. + The default value of this property only contains the matrix.to host. + */ +@property (nonatomic) NSArray *firstURLDetectionIgnoredHosts; + +/** + Indicate to hide un-decryptable events before joining the room. Default is `NO`. + */ +@property (nonatomic) BOOL hidePreJoinedUndecryptableEvents; + +/** + Indicate to hide un-decryptable events in the room. Default is `NO`. + */ +@property (nonatomic) BOOL hideUndecryptableEvents; + +/** + Indicates the strategy for sharing the outbound session key to other devices of the room + */ +@property (nonatomic) MXKKeyPreSharingStrategy outboundGroupSessionKeyPreSharingStrategy; + +#pragma mark - Room members + +/** + Sort room members by considering their presence. + Set NO to sort members in alphabetic order. + + This boolean value is defined in shared settings object with the key: `sortRoomMembersUsingLastSeenTime`. + Return YES if no value is defined. + */ +@property (nonatomic) BOOL sortRoomMembersUsingLastSeenTime; + +/** + Show left members in room member list. + + This boolean value is defined in shared settings object with the key: `showLeftMembersInRoomMemberList`. + Return NO if no value is defined. + */ +@property (nonatomic) BOOL showLeftMembersInRoomMemberList; + +/// Flag to allow sharing a message or not. Default value is YES. +@property (nonatomic) BOOL messageDetailsAllowSharing; + +/// Flag to allow saving a message or not. Default value is YES. +@property (nonatomic) BOOL messageDetailsAllowSaving; + +/// Flag to allow copying a media/file or not. Default value is YES. +@property (nonatomic) BOOL messageDetailsAllowCopyingMedia; + +/// Flag to allow pasting a media/file or not. Default value is YES. +@property (nonatomic) BOOL messageDetailsAllowPastingMedia; + +#pragma mark - Contacts + +/** + Return YES if the user allows the local contacts sync. + + This boolean value is defined in shared settings object with the key: `syncLocalContacts`. + Return NO if no value is defined. + */ +@property (nonatomic) BOOL syncLocalContacts; + +/** + Return YES if the user has been already asked for local contacts sync permission. + + This boolean value is defined in shared settings object with the key: `syncLocalContactsPermissionRequested`. + Return NO if no value is defined. + */ +@property (nonatomic) BOOL syncLocalContactsPermissionRequested; + +/** + Return YES if after the user has been asked for local contacts sync permission and choose to open + the system's Settings app to enable contacts access. + + This boolean value is defined in shared settings object with the key: `syncLocalContactsPermissionOpenedSystemSettings`. + Return NO if no value is defined. + */ +@property (nonatomic) BOOL syncLocalContactsPermissionOpenedSystemSettings; + +/** + The current selected country code for the phonebook. + + This value is defined in shared settings object with the key: `phonebookCountryCode`. + Return the SIM card information (if any) if no default value is defined. + */ +@property (nonatomic) NSString* phonebookCountryCode; + + +#pragma mark - Matrix users + +/** + Color associated to online matrix users. + + This color value is defined in shared settings object with the key: `presenceColorForOnlineUser`. + The default color is `[UIColor greenColor]`. + */ +@property (nonatomic) UIColor *presenceColorForOnlineUser; + +/** + Color associated to unavailable matrix users. + + This color value is defined in shared settings object with the key: `presenceColorForUnavailableUser`. + The default color is `[UIColor yellowColor]`. + */ +@property (nonatomic) UIColor *presenceColorForUnavailableUser; + +/** + Color associated to offline matrix users. + + This color value is defined in shared settings object with the key: `presenceColorForOfflineUser`. + The default color is `[UIColor redColor]`. + */ +@property (nonatomic) UIColor *presenceColorForOfflineUser; + +#pragma mark - Notifications + +/// Flag to allow PushKit pushers or not. Default value is `NO`. +@property (nonatomic, assign) BOOL allowPushKitPushers; + +/** + A localization key used when registering the default notification payload. + This key will be translated and displayed for APNS notifications as the body + content, unless it is modified locally by a Notification Service Extension. + + The default value for this setting is "MESSAGE". Changes are *not* persisted. + Updating the value after MXKAccount has called `enableAPNSPusher:success:failure:` + will have no effect. + */ +@property (nonatomic) NSString *notificationBodyLocalizationKey; + +#pragma mark - Calls + +/** + Return YES if the user enable CallKit support. + + This boolean value is defined in shared settings object with the key: `enableCallKit`. + Return YES if no value is defined. + */ +@property (nonatomic, getter=isCallKitEnabled) BOOL enableCallKit; + +#pragma mark - Shared userDefaults + +/** + A userDefaults object that is shared within the application group. The application group identifier + is retrieved from MXSDKOptions sharedInstance (see `applicationGroupIdentifier` property). + The default group is "group.org.matrix". + */ +@property (nonatomic, readonly) NSUserDefaults *sharedUserDefaults; + +#pragma mark - Class methods + +/** + Return the shared application settings object. These settings are retrieved/stored in the shared defaults object (`[NSUserDefaults standardUserDefaults]`). + */ ++ (MXKAppSettings *)standardAppSettings; + +/** + Return the folder to use for caching MatrixKit data. + */ ++ (NSString*)cacheFolder; + +/** + Restore the default values. + */ +- (void)reset; + +@end diff --git a/Riot/Modules/MatrixKit/Models/MXKAppSettings.m b/Riot/Modules/MatrixKit/Models/MXKAppSettings.m new file mode 100644 index 000000000..22b48f831 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/MXKAppSettings.m @@ -0,0 +1,865 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAppSettings.h" + +#import "MXKTools.h" + + +// get ISO country name +#import +#import + +static MXKAppSettings *standardAppSettings = nil; + +static NSString *const kMXAppGroupID = @"group.org.matrix"; + +@interface MXKAppSettings () +{ + NSMutableArray *eventsFilterForMessages; + NSMutableArray *allEventTypesForMessages; + NSMutableArray *lastMessageEventTypesAllowList; +} + +@property (nonatomic, readwrite) NSUserDefaults *sharedUserDefaults; +@property (nonatomic) NSString *currentApplicationGroup; + +@end + +@implementation MXKAppSettings +@synthesize syncWithLazyLoadOfRoomMembers; +@synthesize showAllEventsInRoomHistory, showRedactionsInRoomHistory, showUnsupportedEventsInRoomHistory, httpLinkScheme, httpsLinkScheme; +@synthesize enableBubbleComponentLinkDetection, firstURLDetectionIgnoredHosts, showLeftMembersInRoomMemberList, sortRoomMembersUsingLastSeenTime; +@synthesize syncLocalContacts, syncLocalContactsPermissionRequested, syncLocalContactsPermissionOpenedSystemSettings, phonebookCountryCode; +@synthesize presenceColorForOnlineUser, presenceColorForUnavailableUser, presenceColorForOfflineUser; +@synthesize enableCallKit; +@synthesize sharedUserDefaults; + ++ (MXKAppSettings *)standardAppSettings +{ + @synchronized(self) + { + if(standardAppSettings == nil) + { + standardAppSettings = [[super allocWithZone:NULL] init]; + } + } + return standardAppSettings; +} + ++ (NSString *)cacheFolder +{ + NSString *cacheFolder; + + // Check for a potential application group id + NSString *applicationGroupIdentifier = [MXSDKOptions sharedInstance].applicationGroupIdentifier; + if (applicationGroupIdentifier) + { + NSURL *sharedContainerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:applicationGroupIdentifier]; + cacheFolder = [sharedContainerURL path]; + } + else + { + NSArray *cacheDirList = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES); + cacheFolder = [cacheDirList objectAtIndex:0]; + } + + // Use a dedicated cache folder for MatrixKit + cacheFolder = [cacheFolder stringByAppendingPathComponent:@"MatrixKit"]; + + // Make sure the folder exists so that it can be used + if (cacheFolder && ![[NSFileManager defaultManager] fileExistsAtPath:cacheFolder]) + { + NSError *error; + [[NSFileManager defaultManager] createDirectoryAtPath:cacheFolder withIntermediateDirectories:YES attributes:nil error:&error]; + if (error) + { + MXLogDebug(@"[MXKAppSettings] cacheFolder: Error: Cannot create MatrixKit folder at %@. Error: %@", cacheFolder, error); + } + } + + return cacheFolder; +} + +#pragma mark - + +-(instancetype)init +{ + if (self = [super init]) + { + syncWithLazyLoadOfRoomMembers = YES; + + // Use presence to sort room members by default + if (![[NSUserDefaults standardUserDefaults] objectForKey:@"sortRoomMembersUsingLastSeenTime"]) + { + [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"sortRoomMembersUsingLastSeenTime"]; + } + _hidePreJoinedUndecryptableEvents = NO; + _hideUndecryptableEvents = NO; + sortRoomMembersUsingLastSeenTime = YES; + + presenceColorForOnlineUser = [UIColor greenColor]; + presenceColorForUnavailableUser = [UIColor yellowColor]; + presenceColorForOfflineUser = [UIColor redColor]; + + httpLinkScheme = @"http"; + httpsLinkScheme = @"https"; + enableBubbleComponentLinkDetection = NO; + firstURLDetectionIgnoredHosts = @[[NSURL URLWithString:kMXMatrixDotToUrl].host]; + + _allowPushKitPushers = NO; + _notificationBodyLocalizationKey = @"MESSAGE"; + enableCallKit = YES; + + eventsFilterForMessages = @[ + kMXEventTypeStringRoomCreate, + kMXEventTypeStringRoomName, + kMXEventTypeStringRoomTopic, + kMXEventTypeStringRoomMember, + kMXEventTypeStringRoomEncrypted, + kMXEventTypeStringRoomEncryption, + kMXEventTypeStringRoomHistoryVisibility, + kMXEventTypeStringRoomMessage, + kMXEventTypeStringRoomThirdPartyInvite, + kMXEventTypeStringRoomGuestAccess, + kMXEventTypeStringRoomJoinRules, + kMXEventTypeStringCallInvite, + kMXEventTypeStringCallAnswer, + kMXEventTypeStringCallHangup, + kMXEventTypeStringCallReject, + kMXEventTypeStringCallNegotiate, + kMXEventTypeStringSticker, + kMXEventTypeStringKeyVerificationCancel, + kMXEventTypeStringKeyVerificationDone + ].mutableCopy; + + + // List all the event types, except kMXEventTypeStringPresence which are not related to a specific room. + allEventTypesForMessages = @[ + kMXEventTypeStringRoomName, + kMXEventTypeStringRoomTopic, + kMXEventTypeStringRoomMember, + kMXEventTypeStringRoomCreate, + kMXEventTypeStringRoomEncrypted, + kMXEventTypeStringRoomEncryption, + kMXEventTypeStringRoomJoinRules, + kMXEventTypeStringRoomPowerLevels, + kMXEventTypeStringRoomAliases, + kMXEventTypeStringRoomHistoryVisibility, + kMXEventTypeStringRoomMessage, + kMXEventTypeStringRoomMessageFeedback, + kMXEventTypeStringRoomRedaction, + kMXEventTypeStringRoomThirdPartyInvite, + kMXEventTypeStringRoomRelatedGroups, + kMXEventTypeStringReaction, + kMXEventTypeStringCallInvite, + kMXEventTypeStringCallAnswer, + kMXEventTypeStringCallSelectAnswer, + kMXEventTypeStringCallHangup, + kMXEventTypeStringCallReject, + kMXEventTypeStringCallNegotiate, + kMXEventTypeStringSticker, + kMXEventTypeStringKeyVerificationCancel, + kMXEventTypeStringKeyVerificationDone + ].mutableCopy; + + lastMessageEventTypesAllowList = @[ + kMXEventTypeStringRoomCreate, // Without any messages, calls or stickers an event is needed to provide a date. + kMXEventTypeStringRoomEncrypted, // Show a UTD string rather than the previous message. + kMXEventTypeStringRoomMessage, + kMXEventTypeStringRoomMember, + kMXEventTypeStringCallInvite, + kMXEventTypeStringCallAnswer, + kMXEventTypeStringCallHangup, + kMXEventTypeStringSticker + ].mutableCopy; + + _messageDetailsAllowSharing = YES; + _messageDetailsAllowSaving = YES; + _messageDetailsAllowCopyingMedia = YES; + _messageDetailsAllowPastingMedia = YES; + _outboundGroupSessionKeyPreSharingStrategy = MXKKeyPreSharingWhenTyping; + } + return self; +} + +- (void)reset +{ + if (self == [MXKAppSettings standardAppSettings]) + { + // Flush shared user defaults + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"syncWithLazyLoadOfRoomMembers2"]; + + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"showAllEventsInRoomHistory"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"showRedactionsInRoomHistory"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"showUnsupportedEventsInRoomHistory"]; + + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"sortRoomMembersUsingLastSeenTime"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"showLeftMembersInRoomMemberList"]; + + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"syncLocalContactsPermissionRequested"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"syncLocalContacts"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"phonebookCountryCode"]; + + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"presenceColorForOnlineUser"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"presenceColorForUnavailableUser"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"presenceColorForOfflineUser"]; + + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"httpLinkScheme"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"httpsLinkScheme"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"enableBubbleComponentLinkDetection"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"firstURLDetectionIgnoredHosts"]; + + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"enableCallKit"]; + } + else + { + syncWithLazyLoadOfRoomMembers = YES; + + showAllEventsInRoomHistory = NO; + showRedactionsInRoomHistory = NO; + showUnsupportedEventsInRoomHistory = NO; + + sortRoomMembersUsingLastSeenTime = YES; + showLeftMembersInRoomMemberList = NO; + + syncLocalContactsPermissionRequested = NO; + syncLocalContacts = NO; + phonebookCountryCode = nil; + + presenceColorForOnlineUser = [UIColor greenColor]; + presenceColorForUnavailableUser = [UIColor yellowColor]; + presenceColorForOfflineUser = [UIColor redColor]; + + httpLinkScheme = @"http"; + httpsLinkScheme = @"https"; + + enableCallKit = YES; + } +} + +- (NSUserDefaults *)sharedUserDefaults +{ + if (sharedUserDefaults) + { + // Check whether the current group id did not change. + NSString *applicationGroup = [MXSDKOptions sharedInstance].applicationGroupIdentifier; + if (!applicationGroup.length) + { + applicationGroup = kMXAppGroupID; + } + + if (![_currentApplicationGroup isEqualToString:applicationGroup]) + { + // Reset the existing shared object + sharedUserDefaults = nil; + } + } + + if (!sharedUserDefaults) + { + _currentApplicationGroup = [MXSDKOptions sharedInstance].applicationGroupIdentifier; + if (!_currentApplicationGroup.length) + { + _currentApplicationGroup = kMXAppGroupID; + } + + sharedUserDefaults = [[NSUserDefaults alloc] initWithSuiteName:_currentApplicationGroup]; + } + + return sharedUserDefaults; +} + +#pragma mark - Calls + +- (BOOL)syncWithLazyLoadOfRoomMembers +{ + if (self == [MXKAppSettings standardAppSettings]) + { + id storedValue = [[NSUserDefaults standardUserDefaults] objectForKey:@"syncWithLazyLoadOfRoomMembers2"]; + if (storedValue) + { + return [(NSNumber *)storedValue boolValue]; + } + else + { + // Enabled by default + return YES; + } + } + else + { + return syncWithLazyLoadOfRoomMembers; + } +} + +- (void)setSyncWithLazyLoadOfRoomMembers:(BOOL)syncWithLazyLoadOfRoomMembers +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setBool:syncWithLazyLoadOfRoomMembers forKey:@"syncWithLazyLoadOfRoomMembers2"]; + } + else + { + syncWithLazyLoadOfRoomMembers = syncWithLazyLoadOfRoomMembers; + } +} + +#pragma mark - Room display + +- (BOOL)showAllEventsInRoomHistory +{ + if (self == [MXKAppSettings standardAppSettings]) + { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"showAllEventsInRoomHistory"]; + } + else + { + return showAllEventsInRoomHistory; + } +} + +- (void)setShowAllEventsInRoomHistory:(BOOL)boolValue +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setBool:boolValue forKey:@"showAllEventsInRoomHistory"]; + } + else + { + showAllEventsInRoomHistory = boolValue; + } +} + +- (NSArray *)eventsFilterForMessages +{ + if (showAllEventsInRoomHistory) + { + // Consider all the event types + return self.allEventTypesForMessages; + } + else + { + // Display only a subset of events + return eventsFilterForMessages; + } +} + +- (NSArray *)allEventTypesForMessages +{ + return allEventTypesForMessages; +} + +- (NSArray *)lastMessageEventTypesAllowList +{ + return lastMessageEventTypesAllowList; +} + +- (void)addSupportedEventTypes:(NSArray *)eventTypes +{ + [eventsFilterForMessages addObjectsFromArray:eventTypes]; + [allEventTypesForMessages addObjectsFromArray:eventTypes]; +} + +- (void)removeSupportedEventTypes:(NSArray *)eventTypes +{ + [eventsFilterForMessages removeObjectsInArray:eventTypes]; + [allEventTypesForMessages removeObjectsInArray:eventTypes]; + [lastMessageEventTypesAllowList removeObjectsInArray:eventTypes]; +} + +- (BOOL)showRedactionsInRoomHistory +{ + if (self == [MXKAppSettings standardAppSettings]) + { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"showRedactionsInRoomHistory"]; + } + else + { + return showRedactionsInRoomHistory; + } +} + +- (void)setShowRedactionsInRoomHistory:(BOOL)boolValue +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setBool:boolValue forKey:@"showRedactionsInRoomHistory"]; + } + else + { + showRedactionsInRoomHistory = boolValue; + } +} + +- (BOOL)showUnsupportedEventsInRoomHistory +{ + if (self == [MXKAppSettings standardAppSettings]) + { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"showUnsupportedEventsInRoomHistory"]; + } + else + { + return showUnsupportedEventsInRoomHistory; + } +} + +- (void)setShowUnsupportedEventsInRoomHistory:(BOOL)boolValue +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setBool:boolValue forKey:@"showUnsupportedEventsInRoomHistory"]; + } + else + { + showUnsupportedEventsInRoomHistory = boolValue; + } +} + +- (NSString *)httpLinkScheme +{ + if (self == [MXKAppSettings standardAppSettings]) + { + NSString *ret = [[NSUserDefaults standardUserDefaults] stringForKey:@"httpLinkScheme"]; + if (ret == nil) { + ret = @"http"; + } + return ret; + } + else + { + return httpLinkScheme; + } +} + +- (void)setHttpLinkScheme:(NSString *)stringValue +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setObject:stringValue forKey:@"httpLinkScheme"]; + } + else + { + httpLinkScheme = stringValue; + } +} + +- (NSString *)httpsLinkScheme +{ + if (self == [MXKAppSettings standardAppSettings]) + { + NSString *ret = [[NSUserDefaults standardUserDefaults] stringForKey:@"httpsLinkScheme"]; + if (ret == nil) { + ret = @"https"; + } + return ret; + } + else + { + return httpsLinkScheme; + } +} + +- (void)setHttpsLinkScheme:(NSString *)stringValue +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setObject:stringValue forKey:@"httpsLinkScheme"]; + } + else + { + httpsLinkScheme = stringValue; + } +} + +- (BOOL)enableBubbleComponentLinkDetection +{ + if (self == [MXKAppSettings standardAppSettings]) + { + return [NSUserDefaults.standardUserDefaults boolForKey:@"enableBubbleComponentLinkDetection"]; + } + else + { + return enableBubbleComponentLinkDetection; + } +} + +- (void)setEnableBubbleComponentLinkDetection:(BOOL)storeLinksInBubbleComponents +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [NSUserDefaults.standardUserDefaults setBool:storeLinksInBubbleComponents forKey:@"enableBubbleComponentLinkDetection"]; + } + else + { + enableBubbleComponentLinkDetection = storeLinksInBubbleComponents; + } +} + +- (NSArray *)firstURLDetectionIgnoredHosts +{ + if (self == [MXKAppSettings standardAppSettings]) + { + return [NSUserDefaults.standardUserDefaults objectForKey:@"firstURLDetectionIgnoredHosts"] ?: @[[NSURL URLWithString:kMXMatrixDotToUrl].host]; + } + else + { + return firstURLDetectionIgnoredHosts; + } +} + +- (void)setFirstURLDetectionIgnoredHosts:(NSArray *)ignoredHosts +{ + if (self == [MXKAppSettings standardAppSettings]) + { + if (ignoredHosts == nil) + { + ignoredHosts = @[]; + } + + [NSUserDefaults.standardUserDefaults setObject:ignoredHosts forKey:@"firstURLDetectionIgnoredHosts"]; + } + else + { + firstURLDetectionIgnoredHosts = ignoredHosts; + } +} + +#pragma mark - Room members + +- (BOOL)sortRoomMembersUsingLastSeenTime +{ + if (self == [MXKAppSettings standardAppSettings]) + { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"sortRoomMembersUsingLastSeenTime"]; + } + else + { + return sortRoomMembersUsingLastSeenTime; + } +} + +- (void)setSortRoomMembersUsingLastSeenTime:(BOOL)boolValue +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setBool:boolValue forKey:@"sortRoomMembersUsingLastSeenTime"]; + } + else + { + sortRoomMembersUsingLastSeenTime = boolValue; + } +} + +- (BOOL)showLeftMembersInRoomMemberList +{ + if (self == [MXKAppSettings standardAppSettings]) + { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"showLeftMembersInRoomMemberList"]; + } + else + { + return showLeftMembersInRoomMemberList; + } +} + +- (void)setShowLeftMembersInRoomMemberList:(BOOL)boolValue +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setBool:boolValue forKey:@"showLeftMembersInRoomMemberList"]; + } + else + { + showLeftMembersInRoomMemberList = boolValue; + } +} + +#pragma mark - Contacts + +- (BOOL)syncLocalContacts +{ + if (self == [MXKAppSettings standardAppSettings]) + { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"syncLocalContacts"]; + } + else + { + return syncLocalContacts; + } +} + +- (void)setSyncLocalContacts:(BOOL)boolValue +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setBool:boolValue forKey:@"syncLocalContacts"]; + } + else + { + syncLocalContacts = boolValue; + } +} + +- (BOOL)syncLocalContactsPermissionRequested +{ + if (self == [MXKAppSettings standardAppSettings]) + { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"syncLocalContactsPermissionRequested"]; + } + else + { + return syncLocalContactsPermissionRequested; + } +} + +- (void)setSyncLocalContactsPermissionRequested:(BOOL)theSyncLocalContactsPermissionRequested +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setBool:theSyncLocalContactsPermissionRequested forKey:@"syncLocalContactsPermissionRequested"]; + } + else + { + syncLocalContactsPermissionRequested = theSyncLocalContactsPermissionRequested; + } +} + +- (BOOL)syncLocalContactsPermissionOpenedSystemSettings +{ + if (self == [MXKAppSettings standardAppSettings]) + { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"syncLocalContactsPermissionOpenedSystemSettings"]; + } + else + { + return syncLocalContactsPermissionOpenedSystemSettings; + } +} + +- (void)setSyncLocalContactsPermissionOpenedSystemSettings:(BOOL)theSyncLocalContactsPermissionOpenedSystemSettings +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setBool:theSyncLocalContactsPermissionOpenedSystemSettings forKey:@"syncLocalContactsPermissionOpenedSystemSettings"]; + } + else + { + syncLocalContactsPermissionOpenedSystemSettings = theSyncLocalContactsPermissionOpenedSystemSettings; + } +} + +- (NSString*)phonebookCountryCode +{ + NSString* res = phonebookCountryCode; + + if (self == [MXKAppSettings standardAppSettings]) + { + res = [[NSUserDefaults standardUserDefaults] stringForKey:@"phonebookCountryCode"]; + } + + // does not exist : try to get the SIM card information + if (!res) + { + // get the current MCC + CTTelephonyNetworkInfo *netInfo = [[CTTelephonyNetworkInfo alloc] init]; + CTCarrier *carrier = [netInfo subscriberCellularProvider]; + + if (carrier) + { + res = [[carrier isoCountryCode] uppercaseString]; + + if (res) + { + [self setPhonebookCountryCode:res]; + } + } + } + + return res; +} + +- (void)setPhonebookCountryCode:(NSString *)stringValue +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setObject:stringValue forKey:@"phonebookCountryCode"]; + } + else + { + phonebookCountryCode = stringValue; + } +} + +#pragma mark - Matrix users + +- (UIColor*)presenceColorForOnlineUser +{ + UIColor *color = presenceColorForOnlineUser; + + if (self == [MXKAppSettings standardAppSettings]) + { + NSNumber *rgbValue = [[NSUserDefaults standardUserDefaults] objectForKey:@"presenceColorForOnlineUser"]; + if (rgbValue) + { + color = [MXKTools colorWithRGBValue:[rgbValue unsignedIntegerValue]]; + } + else + { + color = [UIColor greenColor]; + } + } + + return color; +} + +- (void)setPresenceColorForOnlineUser:(UIColor*)color +{ + if (self == [MXKAppSettings standardAppSettings]) + { + if (color) + { + NSUInteger rgbValue = [MXKTools rgbValueWithColor:color]; + [[NSUserDefaults standardUserDefaults] setInteger:rgbValue forKey:@"presenceColorForOnlineUser"]; + } + else + { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"presenceColorForOnlineUser"]; + } + } + else + { + presenceColorForOnlineUser = color ? color : [UIColor greenColor]; + } +} + +- (UIColor*)presenceColorForUnavailableUser +{ + UIColor *color = presenceColorForUnavailableUser; + + if (self == [MXKAppSettings standardAppSettings]) + { + NSNumber *rgbValue = [[NSUserDefaults standardUserDefaults] objectForKey:@"presenceColorForUnavailableUser"]; + if (rgbValue) + { + color = [MXKTools colorWithRGBValue:[rgbValue unsignedIntegerValue]]; + } + else + { + color = [UIColor yellowColor]; + } + } + + return color; +} + +- (void)setPresenceColorForUnavailableUser:(UIColor*)color +{ + if (self == [MXKAppSettings standardAppSettings]) + { + if (color) + { + NSUInteger rgbValue = [MXKTools rgbValueWithColor:color]; + [[NSUserDefaults standardUserDefaults] setInteger:rgbValue forKey:@"presenceColorForUnavailableUser"]; + } + else + { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"presenceColorForUnavailableUser"]; + } + } + else + { + presenceColorForUnavailableUser = color ? color : [UIColor yellowColor]; + } +} + +- (UIColor*)presenceColorForOfflineUser +{ + UIColor *color = presenceColorForOfflineUser; + + if (self == [MXKAppSettings standardAppSettings]) + { + NSNumber *rgbValue = [[NSUserDefaults standardUserDefaults] objectForKey:@"presenceColorForOfflineUser"]; + if (rgbValue) + { + color = [MXKTools colorWithRGBValue:[rgbValue unsignedIntegerValue]]; + } + else + { + color = [UIColor redColor]; + } + } + + return color; +} + +- (void)setPresenceColorForOfflineUser:(UIColor *)color +{ + if (self == [MXKAppSettings standardAppSettings]) + { + if (color) + { + NSUInteger rgbValue = [MXKTools rgbValueWithColor:color]; + [[NSUserDefaults standardUserDefaults] setInteger:rgbValue forKey:@"presenceColorForOfflineUser"]; + } + else + { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"presenceColorForOfflineUser"]; + } + } + else + { + presenceColorForOfflineUser = color ? color : [UIColor redColor]; + } +} + +#pragma mark - Calls + +- (BOOL)isCallKitEnabled +{ + if (self == [MXKAppSettings standardAppSettings]) + { + id storedValue = [[NSUserDefaults standardUserDefaults] objectForKey:@"enableCallKit"]; + if (storedValue) + { + return [(NSNumber *)storedValue boolValue]; + } + else + { + return YES; + } + } + else + { + return enableCallKit; + } +} + +- (void)setEnableCallKit:(BOOL)enable +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setBool:enable forKey:@"enableCallKit"]; + } + else + { + enableCallKit = enable; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/MXKCellData.h b/Riot/Modules/MatrixKit/Models/MXKCellData.h new file mode 100644 index 000000000..68fba4f95 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/MXKCellData.h @@ -0,0 +1,27 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +/** + `MXKCellData` objects contain data that is displayed by objects implementing `MXKCellRendering`. + + The goal of `MXKCellData` is mainly to cache computed data in order to avoid to compute it each time + a cell is displayed. + */ +@interface MXKCellData : NSObject + +@end diff --git a/Riot/Modules/MatrixKit/Models/MXKCellData.m b/Riot/Modules/MatrixKit/Models/MXKCellData.m new file mode 100644 index 000000000..faea25676 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/MXKCellData.m @@ -0,0 +1,21 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKCellData.h" + +@implementation MXKCellData + +@end diff --git a/Riot/Modules/MatrixKit/Models/MXKDataSource.h b/Riot/Modules/MatrixKit/Models/MXKDataSource.h new file mode 100644 index 000000000..a1332d6e2 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/MXKDataSource.h @@ -0,0 +1,225 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import +#import "MXKCellRendering.h" + +/** + List data source states. + */ +typedef enum : NSUInteger { + /** + Default value (used when all resources have been disposed). + The instance cannot be used anymore. + */ + MXKDataSourceStateUnknown, + + /** + Initialisation is in progress. + */ + MXKDataSourceStatePreparing, + + /** + Something wrong happens during initialisation. + */ + MXKDataSourceStateFailed, + + /** + Data source is ready to be used. + */ + MXKDataSourceStateReady + +} MXKDataSourceState; + +@protocol MXKDataSourceDelegate; + +/** + `MXKDataSource` is the base class for data sources managed by MatrixKit. + + Inherited 'MXKDataSource' instances are used to handle table or collection data. + They may conform to UITableViewDataSource or UICollectionViewDataSource protocol to be used as data source delegate + for a UITableView or a UICollectionView instance. + */ +@interface MXKDataSource : NSObject +{ +@protected + MXKDataSourceState state; +} + +/** + The matrix session. + */ +@property (nonatomic, weak, readonly) MXSession *mxSession; + +/** + The data source state + */ +@property (nonatomic, readonly) MXKDataSourceState state; + +/** + The delegate notified when the data has been updated. + */ +@property (weak, nonatomic) id delegate; + + +#pragma mark - Life cycle +/** + Base constructor of data source. + + Customization like class registrations must be done before loading data (see '[MXKDataSource registerCellDataClass: forCellIdentifier:]') . + That is why 3 steps should be considered during 'MXKDataSource' initialization: + 1- call [MXKDataSource initWithMatrixSession:] to initialize a new allocated object. + 2- customize classes and others... + 3- call [MXKDataSource finalizeInitialization] to finalize the initialization. + + @param mxSession the Matrix session to get data from. + @return the newly created instance. + */ +- (instancetype)initWithMatrixSession:(MXSession*)mxSession; + +/** + Finalize the initialization by adding an observer on matrix session state change. + */ +- (void)finalizeInitialization; + +/** + Dispose all resources. + */ +- (void)destroy; + +/** + This method is called when the state of the attached Matrix session has changed. + */ +- (void)didMXSessionStateChange; + + +#pragma mark - MXKCellData classes +/** + Register the MXKCellData class that will be used to process and store data for cells + with the designated identifier. + + @param cellDataClass a MXKCellData-inherited class that will handle data for cells. + @param identifier the identifier of targeted cell. + */ +- (void)registerCellDataClass:(Class)cellDataClass forCellIdentifier:(NSString *)identifier; + +/** + Return the MXKCellData class that handles data for cells with the designated identifier. + + @param identifier the cell identifier. + @return the associated MXKCellData-inherited class. + */ +- (Class)cellDataClassForCellIdentifier:(NSString *)identifier; + +#pragma mark - Pending HTTP requests + +/** + Cancel all registered requests. + */ +- (void)cancelAllRequests; + +@end + +@protocol MXKDataSourceDelegate + +/** + Ask the delegate which MXKCellRendering-compliant class must be used to render this cell data. + + This method is called when MXKDataSource instance is used as the data source delegate of a table or a collection. + CAUTION: The table or the collection MUST have registered the returned class with the same identifier than the one returned by [cellReuseIdentifierForCellData:]. + + @param cellData the cell data to display. + @return a MXKCellRendering-compliant class which inherits UITableViewCell or UICollectionViewCell class (nil if the cellData is not supported). + */ +- (Class)cellViewClassForCellData:(MXKCellData*)cellData; + +/** + Ask the delegate which identifier must be used to dequeue reusable cell for this cell data. + + This method is called when MXKDataSource instance is used as the data source delegate of a table or a collection. + CAUTION: The table or the collection MUST have registered the right class with the returned identifier (see [cellViewClassForCellData:]). + + @param cellData the cell data to display. + @return the reuse identifier for the cell (nil if the cellData is not supported). + */ +- (NSString *)cellReuseIdentifierForCellData:(MXKCellData*)cellData; + +/** + Tells the delegate that some cell data/views have been changed. + + @param dataSource the involved data source. + @param changes contains the index paths of objects that changed. + */ +- (void)dataSource:(MXKDataSource*)dataSource didCellChange:(id /* @TODO*/)changes; + +@optional + +/** + Tells the delegate that data source state changed + + @param dataSource the involved data source. + @param state the new data source state. + */ +- (void)dataSource:(MXKDataSource*)dataSource didStateChange:(MXKDataSourceState)state; + +/** + Relevant only for data source which support multi-sessions. + Tells the delegate that a matrix session has been added. + + @param dataSource the involved data source. + @param mxSession the new added session. + */ +- (void)dataSource:(MXKDataSource*)dataSource didAddMatrixSession:(MXSession*)mxSession; + +/** + Relevant only for data source which support multi-sessions. + Tells the delegate that a matrix session has been removed. + + @param dataSource the involved data source. + @param mxSession the removed session. + */ +- (void)dataSource:(MXKDataSource*)dataSource didRemoveMatrixSession:(MXSession*)mxSession; + +/** + Tells the delegate when a user action is observed inside a cell. + + @see `MXKCellRenderingDelegate` for more details. + + @param dataSource the involved data source. + @param actionIdentifier an identifier indicating the action type (tap, long press...) and which part of the cell is concerned. + @param cell the cell in which action has been observed. + @param userInfo a dict containing additional information. It depends on actionIdentifier. May be nil. + */ +- (void)dataSource:(MXKDataSource*)dataSource didRecognizeAction:(NSString*)actionIdentifier inCell:(id)cell userInfo:(NSDictionary*)userInfo; + +/** + Asks the delegate if a user action (click on a link) can be done. + + @see `MXKCellRenderingDelegate` for more details. + + @param dataSource the involved data source. + @param actionIdentifier an identifier indicating the action type (link click) and which part of the cell is concerned. + @param cell the cell in which action has been observed. + @param userInfo a dict containing additional information. It depends on actionIdentifier. May be nil. + @param defaultValue the value to return by default if the action is not handled. + @return a boolean value which depends on actionIdentifier. + */ +- (BOOL)dataSource:(MXKDataSource*)dataSource shouldDoAction:(NSString *)actionIdentifier inCell:(id)cell userInfo:(NSDictionary *)userInfo defaultValue:(BOOL)defaultValue; + +@end + diff --git a/Riot/Modules/MatrixKit/Models/MXKDataSource.m b/Riot/Modules/MatrixKit/Models/MXKDataSource.m new file mode 100644 index 000000000..49a787ae0 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/MXKDataSource.m @@ -0,0 +1,148 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKDataSource.h" + +#import "MXKCellData.h" +#import "MXKCellRendering.h" + +@interface MXKDataSource () +{ + /** + The mapping between cell identifiers and MXKCellData classes. + */ + NSMutableDictionary *cellDataMap; +} +@end + +@implementation MXKDataSource +@synthesize state; + +#pragma mark - Life cycle + +- (instancetype)init +{ + self = [super init]; + if (self) + { + state = MXKDataSourceStateUnknown; + cellDataMap = [NSMutableDictionary dictionary]; + } + return self; +} + +- (instancetype)initWithMatrixSession:(MXSession *)matrixSession +{ + self = [self init]; + if (self) + { + _mxSession = matrixSession; + state = MXKDataSourceStatePreparing; + } + return self; +} + +- (void)finalizeInitialization +{ + // Add an observer on matrix session state change (prevent multiple registrations). + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionStateDidChangeNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXSessionStateChange:) name:kMXSessionStateDidChangeNotification object:nil]; + + // Call the registered callback to finalize the initialisation step. + [self didMXSessionStateChange]; +} + +- (void)destroy +{ + state = MXKDataSourceStateUnknown; + if (_delegate && [_delegate respondsToSelector:@selector(dataSource:didStateChange:)]) + { + [_delegate dataSource:self didStateChange:state]; + } + + _mxSession = nil; + _delegate = nil; + + [self cancelAllRequests]; + + [[NSNotificationCenter defaultCenter] removeObserver:self]; + cellDataMap = nil; +} + +#pragma mark - MXSessionStateDidChangeNotification +- (void)didMXSessionStateChange:(NSNotification *)notif +{ + // Check this is our Matrix session that has changed + if (notif.object == _mxSession) + { + [self didMXSessionStateChange]; + } +} + +- (void)didMXSessionStateChange +{ + // The inherited class is highly invited to override this method for its business logic +} + + +#pragma mark - MXKCellData classes +- (void)registerCellDataClass:(Class)cellDataClass forCellIdentifier:(NSString *)identifier +{ + // Sanity check: accept only MXKCellData classes or sub-classes + NSParameterAssert([cellDataClass isSubclassOfClass:MXKCellData.class]); + + cellDataMap[identifier] = cellDataClass; +} + +- (Class)cellDataClassForCellIdentifier:(NSString *)identifier +{ + return cellDataMap[identifier]; +} + +#pragma mark - MXKCellRenderingDelegate +- (void)cell:(id)cell didRecognizeAction:(NSString*)actionIdentifier userInfo:(NSDictionary *)userInfo +{ + // The data source simply relays the information to its delegate + if (_delegate && [_delegate respondsToSelector:@selector(dataSource:didRecognizeAction:inCell:userInfo:)]) + { + [_delegate dataSource:self didRecognizeAction:actionIdentifier inCell:cell userInfo:userInfo]; + } +} + +- (BOOL)cell:(id)cell shouldDoAction:(NSString *)actionIdentifier userInfo:(NSDictionary *)userInfo defaultValue:(BOOL)defaultValue +{ + BOOL shouldDoAction = defaultValue; + + // The data source simply relays the question to its delegate + if (_delegate && [_delegate respondsToSelector:@selector(dataSource:shouldDoAction:inCell:userInfo:defaultValue:)]) + { + shouldDoAction = [_delegate dataSource:self shouldDoAction:actionIdentifier inCell:cell userInfo:userInfo defaultValue:defaultValue]; + } + + return shouldDoAction; +} + + +#pragma mark - Pending HTTP requests +/** + Cancel all registered requests. + */ +- (void)cancelAllRequests +{ + // The inherited class is invited to override this method +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/MXKPasteboardManager.swift b/Riot/Modules/MatrixKit/Models/MXKPasteboardManager.swift new file mode 100644 index 000000000..814ae10b4 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/MXKPasteboardManager.swift @@ -0,0 +1,33 @@ +/* + Copyright 2020 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import UIKit + +@objcMembers +public class MXKPasteboardManager: NSObject { + + public static let shared = MXKPasteboardManager(withPasteboard: .general) + + private init(withPasteboard pasteboard: UIPasteboard) { + self.pasteboard = pasteboard + super.init() + } + + /// Pasteboard to use on copy operations. Defaults to `UIPasteboard.generalPasteboard`. + public var pasteboard: UIPasteboard + +} diff --git a/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellData.h b/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellData.h new file mode 100644 index 000000000..e98dd16e1 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellData.h @@ -0,0 +1,27 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import "MXKDirectoryServerCellDataStoring.h" + +/** + `MXKRoomMemberCellData` modelised the data for a `MXKRoomMemberTableViewCell` cell. + */ +@interface MXKDirectoryServerCellData : MXKCellData + +@end diff --git a/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellData.m b/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellData.m new file mode 100644 index 000000000..e1f80368b --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellData.m @@ -0,0 +1,66 @@ +/* + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKDirectoryServerCellData.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@implementation MXKDirectoryServerCellData; +@synthesize desc, icon; +@synthesize homeserver, includeAllNetworks; +@synthesize thirdPartyProtocolInstance, thirdPartyProtocol; +@synthesize mediaManager; + +- (id)initWithHomeserver:(NSString *)theHomeserver includeAllNetworks:(BOOL)theIncludeAllNetworks +{ + self = [super init]; + if (self) + { + homeserver = theHomeserver; + includeAllNetworks = theIncludeAllNetworks; + + if (theIncludeAllNetworks) + { + desc = homeserver; + icon = nil; + } + else + { + // Use the Matrix name and logo when looking for Matrix rooms only + desc = [MatrixKitL10n matrix]; + icon = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"network_matrix"]; + } + } + return self; +} + +- (id)initWithProtocolInstance:(MXThirdPartyProtocolInstance *)instance protocol:(MXThirdPartyProtocol *)protocol +{ + self = [super init]; + if (self) + { + thirdPartyProtocolInstance = instance; + thirdPartyProtocol = protocol; + desc = thirdPartyProtocolInstance.desc; + icon = nil; + } + return self; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellDataStoring.h b/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellDataStoring.h new file mode 100644 index 000000000..01bba13ea --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellDataStoring.h @@ -0,0 +1,75 @@ +/* + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import "MXKCellData.h" + +/** + `MXKDirectoryServerCellDataStoring` defines a protocol a class must conform in order to + store directory cell data managed by `MXKDirectoryServersDataSource`. + */ +@protocol MXKDirectoryServerCellDataStoring + +#pragma mark - Data displayed by a server cell + +/** + The name of the directory server. + */ +@property (nonatomic) NSString *desc; + +/** + The icon of the server. + */ +@property (nonatomic) UIImage *icon; + +/** + The optional media manager used to download the icon of the server. + */ +@property (nonatomic) MXMediaManager *mediaManager; + +/** + In case the cell data represents a homeserver, its description. + */ +@property (nonatomic, readonly) NSString *homeserver; +@property (nonatomic, readonly) BOOL includeAllNetworks; + +/** + In case the cell data represents a third-party protocol instance, its description. + */ +@property (nonatomic, readonly) MXThirdPartyProtocolInstance *thirdPartyProtocolInstance; +@property (nonatomic, readonly) MXThirdPartyProtocol *thirdPartyProtocol; + +/** + Define a MXKDirectoryServerCellData that will store a homeserver. + + @param homeserver the homeserver name (ex: "matrix.org). + @param includeAllNetworks YES to list all public rooms on the homeserver whatever their protocol. + NO to list only matrix rooms. + */ +- (id)initWithHomeserver:(NSString*)homeserver includeAllNetworks:(BOOL)includeAllNetworks; + +/** + Define a MXKDirectoryServerCellData that will store a third-party protocol instance. + + @param instance the instance of the protocol. + @param protocol the protocol description. + */ +- (id)initWithProtocolInstance:(MXThirdPartyProtocolInstance*)instance protocol:(MXThirdPartyProtocol*)protocol; + +@end diff --git a/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServersDataSource.h b/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServersDataSource.h new file mode 100644 index 000000000..c7079343c --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServersDataSource.h @@ -0,0 +1,82 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKDataSource.h" +#import "MXKDirectoryServerCellDataStoring.h" + +/** + Identifier to use for cells that display a server in the servers list. + */ +FOUNDATION_EXPORT NSString *const kMXKDirectorServerCellIdentifier; + +/** + `DirectoryServersDataSource` is a base class to list servers and third-party protocols + instances available on the user homeserver. + + We can then list public rooms from the directory of these servers. This is done + with `PublicRoomsDirectoryDataSource`. + + As a `MXKDataSource` child class, the class has a state where values have the following meanings: + - MXKDataSourceStatePreparing: the data source is not yet ready or it is fetching data from the homeserver. + - MXKDataSourceStateReady: the data source data is ready. + - MXKDataSourceStateFailed: the data source failed to fetch data. + + There is no way in Matrix to be notified when there is a change. + */ +@interface MXKDirectoryServersDataSource : MXKDataSource +{ +@protected + /** + The data for the cells served by `DirectoryServersDataSource`. + */ + NSMutableArray> *cellDataArray; + + /** + The filtered servers: sub-list of `cellDataArray` defined by `searchWithPatterns:`. + */ + NSMutableArray> *filteredCellDataArray; +} + +/** + Additional room directory servers the datasource will list. + */ +@property (nonatomic) NSArray *roomDirectoryServers; + +/** + Fetch the data source data. + */ +- (void)loadData; + +/** + Filter the current recents list according to the provided patterns. + When patterns are not empty, the search result is stored in `filteredCellDataArray`, + this array provides then data for the cells served by `MXKDirectoryServersDataSource`. + + @param patternsList the list of patterns to match with. Set nil to cancel search. + */ +- (void)searchWithPatterns:(NSArray *)patternsList; + +/** + Get the data for the cell at the given index path. + + @param indexPath the index of the cell. + @return the cell data. + */ +- (id)cellDataAtIndexPath:(NSIndexPath*)indexPath; + +@end diff --git a/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServersDataSource.m b/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServersDataSource.m new file mode 100644 index 000000000..9520a16fb --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServersDataSource.m @@ -0,0 +1,230 @@ +/* + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKDirectoryServersDataSource.h" + +#import "MXKDirectoryServerCellData.h" + +NSString *const kMXKDirectorServerCellIdentifier = @"kMXKDirectorServerCellIdentifier"; + +#pragma mark - DirectoryServersDataSource + +@interface MXKDirectoryServersDataSource () +{ + // The pending request to load third-party protocols. + MXHTTPOperation *request; +} + +@end + +@implementation MXKDirectoryServersDataSource + +- (instancetype)init +{ + self = [super init]; + if (self) + { + cellDataArray = [NSMutableArray array]; + filteredCellDataArray = nil; + + // Set default data w classes + [self registerCellDataClass:MXKDirectoryServerCellData.class forCellIdentifier:kMXKDirectorServerCellIdentifier]; + } + return self; +} + +- (void)destroy +{ + cellDataArray = nil; + filteredCellDataArray = nil; +} + +- (void)cancelAllRequests +{ + [super cancelAllRequests]; + + [request cancel]; + request = nil; +} + +- (void)loadData +{ + // Cancel the previous request + if (request) + { + [request cancel]; + } + + // Reset all vars + [cellDataArray removeAllObjects]; + + [self setState:MXKDataSourceStatePreparing]; + + Class class = [self cellDataClassForCellIdentifier:kMXKDirectorServerCellIdentifier]; + + // Add user's HS + NSString *userHomeserver = self.mxSession.matrixRestClient.credentials.homeServerName; + id cellData = [[class alloc] initWithHomeserver:userHomeserver includeAllNetworks:YES]; + [cellDataArray addObject:cellData]; + + // Add user's HS but for Matrix public rooms only + cellData = [[class alloc] initWithHomeserver:userHomeserver includeAllNetworks:NO]; + [cellDataArray addObject:cellData]; + + // Add custom directory servers + for (NSString *homeserver in _roomDirectoryServers) + { + if (![homeserver isEqualToString:userHomeserver]) + { + cellData = [[class alloc] initWithHomeserver:homeserver includeAllNetworks:YES]; + [cellDataArray addObject:cellData]; + } + } + + MXWeakify(self); + request = [self.mxSession.matrixRestClient thirdpartyProtocols:^(MXThirdpartyProtocolsResponse *thirdpartyProtocolsResponse) { + + MXStrongifyAndReturnIfNil(self); + for (NSString *protocolName in thirdpartyProtocolsResponse.protocols) + { + MXThirdPartyProtocol *protocol = thirdpartyProtocolsResponse.protocols[protocolName]; + + for (MXThirdPartyProtocolInstance *instance in protocol.instances) + { + id cellData = [[class alloc] initWithProtocolInstance:instance protocol:protocol]; + cellData.mediaManager = self.mxSession.mediaManager; + [self->cellDataArray addObject:cellData]; + } + } + + [self setState:MXKDataSourceStateReady]; + + } failure:^(NSError *error) { + + MXStrongifyAndReturnIfNil(self); + if (!self->request || self->request.isCancelled) + { + // Do not take into account error coming from a cancellation + return; + } + + self->request = nil; + + MXLogDebug(@"[MXKDirectoryServersDataSource] Failed to fecth third-party protocols. The HS may be too old to support third party networks"); + + [self setState:MXKDataSourceStateReady]; + }]; +} + +- (void)searchWithPatterns:(NSArray*)patternsList +{ + if (patternsList.count) + { + if (filteredCellDataArray) + { + [filteredCellDataArray removeAllObjects]; + } + else + { + filteredCellDataArray = [NSMutableArray arrayWithCapacity:cellDataArray.count]; + } + + for (id cellData in cellDataArray) + { + for (NSString* pattern in patternsList) + { + if ([cellData.desc rangeOfString:pattern options:NSCaseInsensitiveSearch].location != NSNotFound) + { + [filteredCellDataArray addObject:cellData]; + break; + } + } + } + } + else + { + filteredCellDataArray = nil; + } + + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } +} + +/** + Get the data for the cell at the given index path. + + @param indexPath the index of the cell. + @return the cell data. + */ +- (id)cellDataAtIndexPath:(NSIndexPath*)indexPath; +{ + if (filteredCellDataArray) + { + return filteredCellDataArray[indexPath.row]; + } + return cellDataArray[indexPath.row]; +} + + +#pragma mark - Private methods + +// Update the MXKDataSource state and the delegate +- (void)setState:(MXKDataSourceState)newState +{ + state = newState; + if (self.delegate && [self.delegate respondsToSelector:@selector(dataSource:didStateChange:)]) + { + [self.delegate dataSource:self didStateChange:state]; + } +} + +#pragma mark - UITableViewDataSource + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + if (filteredCellDataArray) + { + return filteredCellDataArray.count; + } + return cellDataArray.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + id cellData = [self cellDataAtIndexPath:indexPath]; + + if (cellData && self.delegate) + { + NSString *identifier = [self.delegate cellReuseIdentifierForCellData:cellData]; + if (identifier) + { + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier forIndexPath:indexPath]; + + // Make the cell display the data + [cell render:cellData]; + + return cell; + } + } + + // Return a fake cell to prevent app from crashing. + return [[UITableViewCell alloc] init]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKAttachment.h b/Riot/Modules/MatrixKit/Models/Room/MXKAttachment.h new file mode 100644 index 000000000..07e878161 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKAttachment.h @@ -0,0 +1,212 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +@class MXKUTI; + +NS_ASSUME_NONNULL_BEGIN + +extern NSString * const kMXKAttachmentErrorDomain; + +/** + List attachment types + */ +typedef enum : NSUInteger { + MXKAttachmentTypeUndefined, + MXKAttachmentTypeImage, + MXKAttachmentTypeAudio, + MXKAttachmentTypeVoiceMessage, + MXKAttachmentTypeVideo, + MXKAttachmentTypeLocation, + MXKAttachmentTypeFile, + MXKAttachmentTypeSticker + +} MXKAttachmentType; + +/** + `MXKAttachment` represents a room attachment. + */ +@interface MXKAttachment : NSObject + +/** + The media manager instance used to download the attachment data. + */ +@property (nonatomic, readonly) MXMediaManager *mediaManager; + +/** + The attachment type. + */ +@property (nonatomic, readonly) MXKAttachmentType type; + +/** + The attachment information retrieved from the event content during the initialisation. + */ +@property (nonatomic, readonly, nullable) NSString *eventId; +@property (nonatomic, readonly, nullable) NSString *eventRoomId; +@property (nonatomic, readonly) MXEventSentState eventSentState; +@property (nonatomic, readonly, nullable) NSString *contentURL; +@property (nonatomic, readonly, nullable) NSDictionary *contentInfo; + +/** + The URL of a 'standard size' thumbnail. + */ +@property (nonatomic, readonly, nullable) NSString *mxcThumbnailURI; +@property (nonatomic, readonly, nullable) NSString *thumbnailMimeType; + +/** + The download identifier of the attachment content (related to contentURL). + */ +@property (nonatomic, readonly, nullable) NSString *downloadId; +/** + The download identifier of the attachment thumbnail. + */ +@property (nonatomic, readonly, nullable) NSString *thumbnailDownloadId; + +/** + The attached video thumbnail information. + */ +@property (nonatomic, readonly, nullable) NSDictionary *thumbnailInfo; + +/** + The original file name retrieved from the event body (if any). + */ +@property (nonatomic, readonly, nullable) NSString *originalFileName; + +/** + The thumbnail orientation (relevant in case of image). + */ +@property (nonatomic, readonly) UIImageOrientation thumbnailOrientation; + +/** + The cache file path of the attachment. + */ +@property (nonatomic, readonly, nullable) NSString *cacheFilePath; + +/** + The cache file path of the attachment thumbnail (may be nil). + */ +@property (nonatomic, readonly, nullable) NSString *thumbnailCachePath; + +/** + The preview of the attachment (nil by default). + */ +@property (nonatomic, nullable) UIImage *previewImage; + +/** + True if the attachment is encrypted + The encryption status of the thumbnail is not covered by this + property: it is possible for the thumbnail to be encrypted + whether this peoperty is true or false. + */ +@property (nonatomic, readonly) BOOL isEncrypted; + +/** + The UTI of this attachment. + */ +@property (nonatomic, readonly, nullable) MXKUTI *uti; + +/** + Create a `MXKAttachment` instance for the passed event. + The created instance copies the current data of the event (content, event id, sent state...). + It will ignore any future changes of these data. + + @param event a matrix event. + @param mediaManager the media manager instance used to download the attachment data. + @return `MXKAttachment` instance. + */ +- (nullable instancetype)initWithEvent:(MXEvent*)event andMediaManager:(MXMediaManager*)mediaManager; + +- (void)destroy; + +/** + Gets the thumbnail for this attachment if it is in the memory or disk cache, + otherwise return nil + */ +- (nullable UIImage *)getCachedThumbnail; + +/** + For image attachments, gets a UIImage for the full-res image + */ +- (void)getImage:(void (^_Nullable)(MXKAttachment *_Nullable, UIImage *_Nullable))onSuccess failure:(void (^_Nullable)(MXKAttachment *_Nullable, NSError * _Nullable error))onFailure; + +/** + Decrypt the attachment data into memory and provide it as an NSData + */ +- (void)getAttachmentData:(void (^_Nullable)(NSData *_Nullable))onSuccess failure:(void (^_Nullable)(NSError * _Nullable error))onFailure; + +/** + Decrypts the attachment to a newly created temporary file. + If the isEncrypted property is YES, this method (or getImage) should be used to + obtain the full decrypted attachment. The behaviour of this method is undefined + if isEncrypted is NO. + It is the caller's responsibility to delete the temporary file once it is no longer + needed. + */ +- (void)decryptToTempFile:(void (^_Nullable)(NSString *_Nullable))onSuccess failure:(void (^_Nullable)(NSError * _Nullable error))onFailure; + + +/** Deletes all previously created temporary files */ ++ (void)clearCache; + +/** + Gets the thumbnails for this attachment, downloading it or loading it from disk cache + if necessary + */ +- (void)getThumbnail:(void (^_Nullable)(MXKAttachment *_Nullable, UIImage *_Nullable))onSuccess failure:(void (^_Nullable)(MXKAttachment *_Nullable, NSError * _Nullable error))onFailure; + +/** + Download the attachment data if it is not already cached. + + @param onAttachmentReady block called when attachment is available at 'cacheFilePath'. + @param onFailure the block called on failure. + */ +- (void)prepare:(void (^_Nullable)(void))onAttachmentReady failure:(void (^_Nullable)(NSError * _Nullable error))onFailure; + +/** + Save the attachment in user's photo library. This operation is available only for images and video. + + @param onSuccess the block called on success. + @param onFailure the block called on failure. + */ +- (void)save:(void (^_Nullable)(void))onSuccess failure:(void (^_Nullable)(NSError * _Nullable error))onFailure; + +/** + Copy the attachment data in general pasteboard. + + @param onSuccess the block called on success. + @param onFailure the block called on failure. + */ +- (void)copy:(void (^_Nullable)(void))onSuccess failure:(void (^_Nullable)(NSError * _Nullable error))onFailure; + +/** + Prepare the attachment data to share it. The original name of the attachment (if any) is used + to name the prepared file. + + The developer must call 'onShareEnd' when share operation is ended in order to release potential + resources allocated here. + + @param onReadyToShare the block called when attachment is ready to share at the provided file URL. + @param onFailure the block called on failure. + */ +- (void)prepareShare:(void (^_Nullable)(NSURL * _Nullable fileURL))onReadyToShare failure:(void (^_Nullable)(NSError * _Nullable error))onFailure; +- (void)onShareEnded; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKAttachment.m b/Riot/Modules/MatrixKit/Models/Room/MXKAttachment.m new file mode 100644 index 000000000..e15fd70b3 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKAttachment.m @@ -0,0 +1,718 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAttachment.h" +#import "MXKSwiftHeader.h" + +@import MatrixSDK; +@import MobileCoreServices; + +#import "MXKTools.h" + +// The size of thumbnail we request from the server +// Note that this is smaller than the ones we upload: when sending, one size +// must fit all, including the web which will want relatively high res thumbnails. +// We, however, are a mobile client and so would prefer smaller thumbnails, which +// we can have if they're being generated by the media repo. +static const int kThumbnailWidth = 320; +static const int kThumbnailHeight = 240; + +NSString *const kMXKAttachmentErrorDomain = @"kMXKAttachmentErrorDomain"; +NSString *const kMXKAttachmentFileNameBase = @"attatchment"; + +@interface MXKAttachment () +{ + /** + The information on the encrypted content. + */ + MXEncryptedContentFile *contentFile; + + /** + The information on the encrypted thumbnail. + */ + MXEncryptedContentFile *thumbnailFile; + + /** + Observe Attachment download + */ + id onAttachmentDownloadObs; + + /** + The local path used to store the attachment with its original name + */ + NSString *documentCopyPath; + + /** + The attachment mimetype. + */ + NSString *mimetype; +} + +@end + +@implementation MXKAttachment + +- (instancetype)initWithEvent:(MXEvent*)event andMediaManager:(MXMediaManager*)mediaManager +{ + self = [super init]; + if (self) + { + _mediaManager = mediaManager; + + // Make a copy as the data can be read at anytime later + _eventId = event.eventId; + _eventRoomId = event.roomId; + _eventSentState = event.sentState; + + NSDictionary *eventContent = event.content; + + // Set default thumbnail orientation + _thumbnailOrientation = UIImageOrientationUp; + + if (event.eventType == MXEventTypeSticker) + { + _type = MXKAttachmentTypeSticker; + MXJSONModelSetDictionary(_thumbnailInfo, eventContent[@"info"][@"thumbnail_info"]); + } + else + { + // Note: mxEvent.eventType is supposed to be MXEventTypeRoomMessage here. + NSString *msgtype = eventContent[@"msgtype"]; + if ([msgtype isEqualToString:kMXMessageTypeImage]) + { + _type = MXKAttachmentTypeImage; + } + else if (event.isVoiceMessage) + { + _type = MXKAttachmentTypeVoiceMessage; + } + else if ([msgtype isEqualToString:kMXMessageTypeAudio]) + { + _type = MXKAttachmentTypeAudio; + } + else if ([msgtype isEqualToString:kMXMessageTypeVideo]) + { + _type = MXKAttachmentTypeVideo; + MXJSONModelSetDictionary(_thumbnailInfo, eventContent[@"info"][@"thumbnail_info"]); + } + else if ([msgtype isEqualToString:kMXMessageTypeLocation]) + { + // Not supported yet + // _type = MXKAttachmentTypeLocation; + return nil; + } + else if ([msgtype isEqualToString:kMXMessageTypeFile]) + { + _type = MXKAttachmentTypeFile; + } + else + { + return nil; + } + } + + MXJSONModelSetString(_originalFileName, eventContent[@"body"]); + MXJSONModelSetDictionary(_contentInfo, eventContent[@"info"]); + MXJSONModelSetMXJSONModel(contentFile, MXEncryptedContentFile, eventContent[@"file"]); + + // Retrieve the content url by taking into account the potential encryption. + if (contentFile) + { + _isEncrypted = YES; + _contentURL = contentFile.url; + + MXJSONModelSetMXJSONModel(thumbnailFile, MXEncryptedContentFile, _contentInfo[@"thumbnail_file"]); + } + else + { + _isEncrypted = NO; + MXJSONModelSetString(_contentURL, eventContent[@"url"]); + } + + mimetype = nil; + if (_contentInfo) + { + MXJSONModelSetString(mimetype, _contentInfo[@"mimetype"]); + } + + _cacheFilePath = [MXMediaManager cachePathForMatrixContentURI:_contentURL andType:mimetype inFolder:_eventRoomId]; + _downloadId = [MXMediaManager downloadIdForMatrixContentURI:_contentURL inFolder:_eventRoomId]; + + // Deduce the thumbnail information from the retrieved data. + _mxcThumbnailURI = [self getThumbnailURI]; + _thumbnailMimeType = [self getThumbnailMimeType]; + _thumbnailCachePath = [self getThumbnailCachePath]; + _thumbnailDownloadId = [self getThumbnailDownloadId]; + } + return self; +} + +- (void)dealloc +{ + [self destroy]; +} + +- (void)destroy +{ + if (onAttachmentDownloadObs) + { + [[NSNotificationCenter defaultCenter] removeObserver:onAttachmentDownloadObs]; + onAttachmentDownloadObs = nil; + } + + // Remove the temporary file created to prepare attachment sharing + if (documentCopyPath) + { + [[NSFileManager defaultManager] removeItemAtPath:documentCopyPath error:nil]; + documentCopyPath = nil; + } + + _previewImage = nil; +} + +- (NSString *)getThumbnailURI +{ + if (thumbnailFile) + { + // there's an encrypted thumbnail: we return the mxc url + return thumbnailFile.url; + } + + // Look for a clear thumbnail url + return _contentInfo[@"thumbnail_url"]; +} + +- (NSString *)getThumbnailMimeType +{ + return _thumbnailInfo[@"mimetype"]; +} + +- (NSString*)getThumbnailCachePath +{ + if (_mxcThumbnailURI) + { + return [MXMediaManager cachePathForMatrixContentURI:_mxcThumbnailURI andType:_thumbnailMimeType inFolder:_eventRoomId]; + } + // In case of an unencrypted image, consider the thumbnail URI deduced from the content URL, except if + // the attachment is currently uploading. + // Note: When the uploading is in progress, the upload id is stored in the content url (nasty trick). + else if (_type == MXKAttachmentTypeImage && !_isEncrypted && _contentURL && ![_contentURL hasPrefix:kMXMediaUploadIdPrefix]) + { + return [MXMediaManager thumbnailCachePathForMatrixContentURI:_contentURL + andType:@"image/jpeg" + inFolder:_eventRoomId + toFitViewSize:CGSizeMake(kThumbnailWidth, kThumbnailHeight) + withMethod:MXThumbnailingMethodScale]; + + + } + return nil; +} + +- (NSString *)getThumbnailDownloadId +{ + if (_mxcThumbnailURI) + { + return [MXMediaManager downloadIdForMatrixContentURI:_mxcThumbnailURI inFolder:_eventRoomId]; + } + // In case of an unencrypted image, consider the thumbnail URI deduced from the content URL, except if + // the attachment is currently uploading. + // Note: When the uploading is in progress, the upload id is stored in the content url (nasty trick). + else if (_type == MXKAttachmentTypeImage && !_isEncrypted && _contentURL && ![_contentURL hasPrefix:kMXMediaUploadIdPrefix]) + { + return [MXMediaManager thumbnailDownloadIdForMatrixContentURI:_contentURL + inFolder:_eventRoomId + toFitViewSize:CGSizeMake(kThumbnailWidth, kThumbnailHeight) + withMethod:MXThumbnailingMethodScale]; + } + return nil; +} + +- (UIImage *)getCachedThumbnail +{ + if (_thumbnailCachePath) + { + UIImage *thumb = [MXMediaManager getFromMemoryCacheWithFilePath:_thumbnailCachePath]; + if (thumb) return thumb; + + if ([[NSFileManager defaultManager] fileExistsAtPath:_thumbnailCachePath]) + { + return [MXMediaManager loadThroughCacheWithFilePath:_thumbnailCachePath]; + } + } + return nil; +} + +- (void)getThumbnail:(void (^)(MXKAttachment *, UIImage *))onSuccess failure:(void (^)(MXKAttachment *, NSError *error))onFailure +{ + // Check whether a thumbnail is defined. + if (!_thumbnailCachePath) + { + // there is no thumbnail: if we're an image, return the full size image. Otherwise, nothing we can do. + if (_type == MXKAttachmentTypeImage) + { + [self getImage:onSuccess failure:onFailure]; + } + else if (onFailure) + { + onFailure(self, nil); + } + + return; + } + + // Check the current memory cache. + UIImage *thumb = [MXMediaManager getFromMemoryCacheWithFilePath:_thumbnailCachePath]; + if (thumb) + { + onSuccess(self, thumb); + return; + } + + if (thumbnailFile) + { + MXWeakify(self); + + void (^decryptAndCache)(void) = ^{ + MXStrongifyAndReturnIfNil(self); + NSInputStream *instream = [[NSInputStream alloc] initWithFileAtPath:self.thumbnailCachePath]; + NSOutputStream *outstream = [[NSOutputStream alloc] initToMemory]; + [MXEncryptedAttachments decryptAttachment:self->thumbnailFile inputStream:instream outputStream:outstream success:^{ + UIImage *img = [UIImage imageWithData:[outstream propertyForKey:NSStreamDataWrittenToMemoryStreamKey]]; + // Save this image to in-memory cache. + [MXMediaManager cacheImage:img withCachePath:self.thumbnailCachePath]; + onSuccess(self, img); + } failure:^(NSError *err) { + if (err) { + MXLogDebug(@"Error decrypting attachment! %@", err.userInfo); + if (onFailure) onFailure(self, err); + return; + } + }]; + }; + + if ([[NSFileManager defaultManager] fileExistsAtPath:_thumbnailCachePath]) + { + decryptAndCache(); + } + else + { + [_mediaManager downloadEncryptedMediaFromMatrixContentFile:thumbnailFile + mimeType:_thumbnailMimeType + inFolder:_eventRoomId + success:^(NSString *outputFilePath) { + decryptAndCache(); + } + failure:^(NSError *error) { + if (onFailure) onFailure(self, error); + }]; + } + } + else + { + if ([[NSFileManager defaultManager] fileExistsAtPath:_thumbnailCachePath]) + { + onSuccess(self, [MXMediaManager loadThroughCacheWithFilePath:_thumbnailCachePath]); + } + else if (_mxcThumbnailURI) + { + [_mediaManager downloadMediaFromMatrixContentURI:_mxcThumbnailURI + withType:_thumbnailMimeType + inFolder:_eventRoomId + success:^(NSString *outputFilePath) { + // Here outputFilePath = thumbnailCachePath + onSuccess(self, [MXMediaManager loadThroughCacheWithFilePath:outputFilePath]); + } + failure:^(NSError *error) { + if (onFailure) onFailure(self, error); + }]; + } + else + { + // Here _thumbnailCachePath is defined, so a thumbnail is available. + // Because _mxcThumbnailURI is null, this means we have to consider the content uri (see getThumbnailCachePath). + [_mediaManager downloadThumbnailFromMatrixContentURI:_contentURL + withType:@"image/jpeg" + inFolder:_eventRoomId + toFitViewSize:CGSizeMake(kThumbnailWidth, kThumbnailHeight) + withMethod:MXThumbnailingMethodScale + success:^(NSString *outputFilePath) { + // Here outputFilePath = thumbnailCachePath + onSuccess(self, [MXMediaManager loadThroughCacheWithFilePath:outputFilePath]); + } + failure:^(NSError *error) { + if (onFailure) onFailure(self, error); + }]; + } + } +} + +- (void)getImage:(void (^)(MXKAttachment *, UIImage *))onSuccess failure:(void (^)(MXKAttachment *, NSError *error))onFailure +{ + [self getAttachmentData:^(NSData *data) { + + UIImage *img = [UIImage imageWithData:data]; + + if (img) + { + if (onSuccess) + { + onSuccess(self, img); + } + } + else + { + if (onFailure) + { + NSError *error = [NSError errorWithDomain:kMXKAttachmentErrorDomain code:0 userInfo:@{@"err": @"error_get_image_from_data"}]; + onFailure(self, error); + } + } + + } failure:^(NSError *error) { + + if (onFailure) onFailure(self, error); + + }]; +} + +- (void)getAttachmentData:(void (^)(NSData *))onSuccess failure:(void (^)(NSError *error))onFailure +{ + MXWeakify(self); + [self prepare:^{ + MXStrongifyAndReturnIfNil(self); + if (self.isEncrypted) + { + // decrypt the encrypted file + NSInputStream *instream = [[NSInputStream alloc] initWithFileAtPath:self.cacheFilePath]; + NSOutputStream *outstream = [[NSOutputStream alloc] initToMemory]; + [MXEncryptedAttachments decryptAttachment:self->contentFile inputStream:instream outputStream:outstream success:^{ + onSuccess([outstream propertyForKey:NSStreamDataWrittenToMemoryStreamKey]); + } failure:^(NSError *err) { + if (err) + { + MXLogDebug(@"Error decrypting attachment! %@", err.userInfo); + return; + } + }]; + } + else + { + onSuccess([NSData dataWithContentsOfFile:self.cacheFilePath]); + } + } failure:onFailure]; +} + +- (void)decryptToTempFile:(void (^)(NSString *))onSuccess failure:(void (^)(NSError *error))onFailure +{ + MXWeakify(self); + [self prepare:^{ + MXStrongifyAndReturnIfNil(self); + NSString *tempPath = [self getTempFile]; + if (!tempPath) + { + if (onFailure) onFailure([NSError errorWithDomain:kMXKAttachmentErrorDomain code:0 userInfo:@{@"err": @"error_creating_temp_file"}]); + return; + } + + NSInputStream *inStream = [NSInputStream inputStreamWithFileAtPath:self.cacheFilePath]; + NSOutputStream *outStream = [NSOutputStream outputStreamToFileAtPath:tempPath append:NO]; + + [MXEncryptedAttachments decryptAttachment:self->contentFile inputStream:inStream outputStream:outStream success:^{ + onSuccess(tempPath); + } failure:^(NSError *err) { + if (err) { + if (onFailure) onFailure(err); + return; + } + }]; + } failure:onFailure]; +} + +- (NSString *)getTempFile +{ + // create a file with an appropriate extension because iOS detects based on file extension + // all over the place + NSString *ext = [MXTools fileExtensionFromContentType:mimetype]; + NSString *filenameTemplate = [NSString stringWithFormat:@"%@.XXXXXX%@", kMXKAttachmentFileNameBase, ext]; + NSString *template = [NSTemporaryDirectory() stringByAppendingPathComponent:filenameTemplate]; + + const char *templateCstr = [template fileSystemRepresentation]; + char *tempPathCstr = (char *)malloc(strlen(templateCstr) + 1); + strcpy(tempPathCstr, templateCstr); + + int fd = mkstemps(tempPathCstr, (int)ext.length); + if (!fd) + { + return nil; + } + close(fd); + + NSString *tempPath = [[NSFileManager defaultManager] stringWithFileSystemRepresentation:tempPathCstr + length:strlen(tempPathCstr)]; + free(tempPathCstr); + return tempPath; +} + ++ (void)clearCache +{ + NSString *temporaryDirectoryPath = NSTemporaryDirectory(); + NSDirectoryEnumerator *enumerator = [NSFileManager.defaultManager enumeratorAtPath:temporaryDirectoryPath]; + + NSString *filePath; + while (filePath = [enumerator nextObject]) { + if(![filePath containsString:kMXKAttachmentFileNameBase]) { + continue; + } + + NSError *error; + BOOL result = [NSFileManager.defaultManager removeItemAtPath:[temporaryDirectoryPath stringByAppendingPathComponent:filePath] error:&error]; + if (!result && error) { + MXLogError(@"[MXKAttachment] Failed deleting temporary file with error: %@", error); + } + } +} + +- (void)prepare:(void (^)(void))onAttachmentReady failure:(void (^)(NSError *error))onFailure +{ + if ([[NSFileManager defaultManager] fileExistsAtPath:_cacheFilePath]) + { + // Done + if (onAttachmentReady) + { + onAttachmentReady(); + } + } + else + { + // Trigger download if it is not already in progress + MXMediaLoader* loader = [MXMediaManager existingDownloaderWithIdentifier:_downloadId]; + if (!loader) + { + if (_isEncrypted) + { + loader = [_mediaManager downloadEncryptedMediaFromMatrixContentFile:contentFile + mimeType:mimetype + inFolder:_eventRoomId]; + } + else + { + loader = [_mediaManager downloadMediaFromMatrixContentURI:_contentURL + withType:mimetype + inFolder:_eventRoomId]; + } + } + + if (loader) + { + MXWeakify(self); + + // Add observers + onAttachmentDownloadObs = [[NSNotificationCenter defaultCenter] addObserverForName:kMXMediaLoaderStateDidChangeNotification object:loader queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXStrongifyAndReturnIfNil(self); + MXMediaLoader *loader = (MXMediaLoader*)notif.object; + switch (loader.state) { + case MXMediaLoaderStateDownloadCompleted: + [[NSNotificationCenter defaultCenter] removeObserver:self->onAttachmentDownloadObs]; + self->onAttachmentDownloadObs = nil; + if (onAttachmentReady) + { + onAttachmentReady (); + } + break; + case MXMediaLoaderStateDownloadFailed: + case MXMediaLoaderStateCancelled: + [[NSNotificationCenter defaultCenter] removeObserver:self->onAttachmentDownloadObs]; + self->onAttachmentDownloadObs = nil; + if (onFailure) + { + onFailure (loader.error); + } + break; + default: + break; + } + }]; + } + else if (onFailure) + { + onFailure (nil); + } + } +} + +- (void)save:(void (^)(void))onSuccess failure:(void (^)(NSError *error))onFailure +{ + if (_type == MXKAttachmentTypeImage || _type == MXKAttachmentTypeVideo) + { + MXWeakify(self); + if (self.isEncrypted) { + [self decryptToTempFile:^(NSString *path) { + MXStrongifyAndReturnIfNil(self); + NSURL* url = [NSURL fileURLWithPath:path]; + + [MXMediaManager saveMediaToPhotosLibrary:url + isImage:(self.type == MXKAttachmentTypeImage) + success:^(NSURL *assetURL){ + if (onSuccess) + { + [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; + onSuccess(); + } + } + failure:onFailure]; + } failure:onFailure]; + } + else + { + [self prepare:^{ + MXStrongifyAndReturnIfNil(self); + NSURL* url = [NSURL fileURLWithPath:self.cacheFilePath]; + + [MXMediaManager saveMediaToPhotosLibrary:url + isImage:(self.type == MXKAttachmentTypeImage) + success:^(NSURL *assetURL){ + if (onSuccess) + { + onSuccess(); + } + } + failure:onFailure]; + } failure:onFailure]; + } + } + else + { + // Not supported + if (onFailure) + { + onFailure(nil); + } + } +} + +- (void)copy:(void (^)(void))onSuccess failure:(void (^)(NSError *error))onFailure +{ + MXWeakify(self); + [self prepare:^{ + MXStrongifyAndReturnIfNil(self); + if (self.type == MXKAttachmentTypeImage) + { + [self getImage:^(MXKAttachment *attachment, UIImage *img) { + MXKPasteboardManager.shared.pasteboard.image = img; + if (onSuccess) + { + onSuccess(); + } + } failure:^(MXKAttachment *attachment, NSError *error) { + if (onFailure) onFailure(error); + }]; + } + else + { + MXWeakify(self); + [self getAttachmentData:^(NSData *data) { + if (data) + { + MXStrongifyAndReturnIfNil(self); + NSString* UTI = (__bridge_transfer NSString *) UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, (__bridge CFStringRef)[self.cacheFilePath pathExtension] , NULL); + + if (UTI) + { + [MXKPasteboardManager.shared.pasteboard setData:data forPasteboardType:UTI]; + if (onSuccess) + { + onSuccess(); + } + } + } + } failure:onFailure]; + } + + // Unexpected error + if (onFailure) + { + onFailure(nil); + } + + } failure:onFailure]; +} + +- (MXKUTI *)uti +{ + return [[MXKUTI alloc] initWithMimeType:mimetype]; +} + +- (void)prepareShare:(void (^)(NSURL *fileURL))onReadyToShare failure:(void (^)(NSError *error))onFailure +{ + MXWeakify(self); + void (^haveFile)(NSString *) = ^(NSString *path) { + // Prepare the file URL by considering the original file name (if any) + NSURL *fileUrl; + MXStrongifyAndReturnIfNil(self); + // Check whether the original name retrieved from event body has extension + if (self.originalFileName && [self.originalFileName pathExtension].length) + { + // Copy the cached file to restore its original name + // Note: We used previously symbolic link (instead of copy) but UIDocumentInteractionController failed to open Office documents (.docx, .pptx...). + self->documentCopyPath = [[MXMediaManager getCachePath] stringByAppendingPathComponent:self.originalFileName]; + + [[NSFileManager defaultManager] removeItemAtPath:self->documentCopyPath error:nil]; + if ([[NSFileManager defaultManager] copyItemAtPath:path toPath:self->documentCopyPath error:nil]) + { + fileUrl = [NSURL fileURLWithPath:self->documentCopyPath]; + [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; + } + } + + if (!fileUrl) + { + // Use the cached file by default + fileUrl = [NSURL fileURLWithPath:path]; + self->documentCopyPath = path; + } + + onReadyToShare (fileUrl); + }; + + if (self.isEncrypted) + { + [self decryptToTempFile:^(NSString *path) { + haveFile(path); + } failure:onFailure]; + } + else + { + // First download data if it is not already done + [self prepare:^{ + haveFile(self.cacheFilePath); + } failure:onFailure]; + } +} + +- (void)onShareEnded +{ + // Remove the temporary file created to prepare attachment sharing + if (documentCopyPath) + { + [[NSFileManager defaultManager] removeItemAtPath:documentCopyPath error:nil]; + documentCopyPath = nil; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKQueuedEvent.h b/Riot/Modules/MatrixKit/Models/Room/MXKQueuedEvent.h new file mode 100644 index 000000000..6639eb15c --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKQueuedEvent.h @@ -0,0 +1,52 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +/** + `MXKQueuedEvent` represents an event waiting to be processed. + */ +@interface MXKQueuedEvent : NSObject + +/** + The event. + */ +@property (nonatomic, readonly) MXEvent *event; + +/** + The state of the room when the event has been received. + */ +@property (nonatomic, readonly) MXRoomState *state; + +/** + The direction of reception. Is it a live event or an event from the history? + */ +@property (nonatomic, readonly) MXTimelineDirection direction; + +/** + Tells whether the event is queued during server sync or not. + */ +@property (nonatomic) BOOL serverSyncEvent; + +/** + Date of the `event`. If event has a valid `originServerTs`, it's converted to a date object, otherwise current date. + */ +@property (nonatomic, readonly) NSDate *eventDate; + +- (instancetype)initWithEvent:(MXEvent*)event andRoomState:(MXRoomState*)state direction:(MXTimelineDirection)direction; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKQueuedEvent.m b/Riot/Modules/MatrixKit/Models/Room/MXKQueuedEvent.m new file mode 100644 index 000000000..d06d475bf --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKQueuedEvent.m @@ -0,0 +1,43 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKQueuedEvent.h" + +@implementation MXKQueuedEvent + +- (instancetype)initWithEvent:(MXEvent *)event andRoomState:(MXRoomState *)state direction:(MXTimelineDirection)direction +{ + self = [super init]; + if (self) + { + _event = event; + _state = state; + _direction = direction; + } + return self; +} + +- (NSDate *)eventDate +{ + if (_event.originServerTs != kMXUndefinedTimestamp) + { + return [NSDate dateWithTimeIntervalSince1970:(double)_event.originServerTs/1000]; + } + + return [NSDate date]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.h new file mode 100644 index 000000000..691f092e6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.h @@ -0,0 +1,166 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKCellData.h" +#import "MXKRoomBubbleCellDataStoring.h" + +#import "MXKRoomBubbleComponent.h" + +#define MXKROOMBUBBLECELLDATA_TEXTVIEW_DEFAULT_VERTICAL_INSET 8 + +/** + `MXKRoomBubbleCellData` instances compose data for `MXKRoomBubbleTableViewCell` cells. + + This is the basic implementation which considers only one component (event) by bubble. + `MXKRoomBubbleCellDataWithAppendingMode` extends this class to merge consecutive messages from the same sender into one bubble. + */ +@interface MXKRoomBubbleCellData : MXKCellData +{ +@protected + /** + The data source owner of this instance. + */ + __weak MXKRoomDataSource *roomDataSource; + + /** + Array of bubble components. Each bubble is supposed to have at least one component. + */ + NSMutableArray *bubbleComponents; + + /** + The body of the message with sets of attributes, or kind of content description in case of attachment (e.g. "image attachment") + */ + NSAttributedString *attributedTextMessage; + + /** + The optional text pattern to be highlighted in the body of the message. + */ + NSString *highlightedPattern; + UIColor *highlightedPatternColor; + UIFont *highlightedPatternFont; +} + +/** + The matrix session. + */ +@property (nonatomic, readonly) MXSession *mxSession; + +/** + Returns bubble components list (`MXKRoomBubbleComponent` instances). + */ +@property (nonatomic, readonly) NSArray *bubbleComponents; + +/** + Read receipts per event. + */ +@property(nonatomic) NSMutableDictionary *> *readReceipts; + +/** + Aggregated reactions per event. + */ +@property(nonatomic) NSMutableDictionary *reactions; + +/** + Whether there is a link to preview in the components. + */ +@property (nonatomic, readonly) BOOL hasLink; + +/** + Event formatter + */ +@property (nonatomic) MXKEventFormatter *eventFormatter; + +/** + The max width of the text view used to display the text message (relevant only for text message or attached file). + */ +@property (nonatomic) CGFloat maxTextViewWidth; + +/** + The bubble content size depends on its type: + - Text: returns suitable content size of a text view to display the whole text message (respecting maxTextViewWidth). + - Attached image or video: returns suitable content size for an image view in order to display + attachment thumbnail or icon. + - Attached file: returns suitable content size of a text view to display the file name (no icon is used presently). + */ +@property (nonatomic) CGSize contentSize; + +/** + Set of flags indicating fixes that need to be applied at display time. + */ +@property (nonatomic, readonly) MXKRoomBubbleComponentDisplayFix displayFix; + +/** + Attachment upload + */ +@property (nonatomic) NSString *uploadId; +@property (nonatomic) CGFloat uploadProgress; + +/** + Indicate a bubble component needs to show encryption badge. + */ +@property (nonatomic, readonly) BOOL containsBubbleComponentWithEncryptionBadge; + +/** + Indicate that the current text message layout is no longer valid and should be recomputed + before presentation in a bubble cell. This could be due to the content changing, or the + available space for the cell has been updated. + + This will clear the current `attributedTextMessage` allowing it to be + rebuilt on demand when requested. + */ +- (void)invalidateTextLayout; + +/** + Check and refresh the position of each component. + */ +- (void)prepareBubbleComponentsPosition; + +/** + Return the raw height of the provided text by removing any vertical margin/inset. + + @param attributedText the attributed text to measure + @return the computed height + */ +- (CGFloat)rawTextHeight:(NSAttributedString*)attributedText; + +/** + Return the content size of a text view initialized with the provided attributed text. + CAUTION: This method runs only on main thread. + + @param attributedText the attributed text to measure + @param removeVerticalInset tell whether the computation should remove vertical inset in text container. + @return the computed size content + */ +- (CGSize)textContentSize:(NSAttributedString*)attributedText removeVerticalInset:(BOOL)removeVerticalInset; + +/** + Get bubble component index from event id. + + @param eventId Event id of bubble component. + @return Index of bubble component associated to event id or NSNotFound + */ +- (NSInteger)bubbleComponentIndexForEventId:(NSString *)eventId; + +/** + Get the first visible component. + + @return First visible component or nil. + */ +- (MXKRoomBubbleComponent*)getFirstBubbleComponentWithDisplay; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m new file mode 100644 index 000000000..b20bcb668 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m @@ -0,0 +1,923 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#define MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH 192 + +#define MXKROOMBUBBLECELLDATA_DEFAULT_MAX_TEXTVIEW_WIDTH 200 + +@import MatrixSDK; + +#import "MXKRoomBubbleCellData.h" + +#import "MXKTools.h" + +@implementation MXKRoomBubbleCellData +@synthesize senderId, targetId, roomId, senderDisplayName, senderAvatarUrl, senderAvatarPlaceholder, targetDisplayName, targetAvatarUrl, targetAvatarPlaceholder, isEncryptedRoom, isPaginationFirstBubble, shouldHideSenderInformation, date, isIncoming, isAttachmentWithThumbnail, isAttachmentWithIcon, attachment, senderFlair; +@synthesize textMessage, attributedTextMessage; +@synthesize shouldHideSenderName, isTyping, showBubbleDateTime, showBubbleReceipts, useCustomDateTimeLabel, useCustomReceipts, useCustomUnsentButton, hasNoDisplay; +@synthesize tag; +@synthesize collapsable, collapsed, collapsedAttributedTextMessage, prevCollapsableCellData, nextCollapsableCellData, collapseState; + +#pragma mark - MXKRoomBubbleCellDataStoring + +- (instancetype)initWithEvent:(MXEvent *)event andRoomState:(MXRoomState *)roomState andRoomDataSource:(MXKRoomDataSource *)roomDataSource2 +{ + self = [self init]; + if (self) + { + roomDataSource = roomDataSource2; + + // Initialize read receipts + self.readReceipts = [NSMutableDictionary dictionary]; + + // Create the bubble component based on matrix event + MXKRoomBubbleComponent *firstComponent = [[MXKRoomBubbleComponent alloc] initWithEvent:event roomState:roomState eventFormatter:roomDataSource.eventFormatter session:roomDataSource.mxSession]; + if (firstComponent) + { + bubbleComponents = [NSMutableArray array]; + [bubbleComponents addObject:firstComponent]; + + senderId = event.sender; + targetId = [event.type isEqualToString:kMXEventTypeStringRoomMember] ? event.stateKey : nil; + roomId = roomDataSource.roomId; + senderDisplayName = [roomDataSource.eventFormatter senderDisplayNameForEvent:event withRoomState:roomState]; + senderAvatarUrl = [roomDataSource.eventFormatter senderAvatarUrlForEvent:event withRoomState:roomState]; + senderAvatarPlaceholder = nil; + targetDisplayName = [roomDataSource.eventFormatter targetDisplayNameForEvent:event withRoomState:roomState]; + targetAvatarUrl = [roomDataSource.eventFormatter targetAvatarUrlForEvent:event withRoomState:roomState]; + targetAvatarPlaceholder = nil; + isEncryptedRoom = roomState.isEncrypted; + isIncoming = ([event.sender isEqualToString:roomDataSource.mxSession.myUser.userId] == NO); + + // Check attachment if any + if ([roomDataSource.eventFormatter isSupportedAttachment:event]) + { + // Note: event.eventType is equal here to MXEventTypeRoomMessage or MXEventTypeSticker + attachment = [[MXKAttachment alloc] initWithEvent:event andMediaManager:roomDataSource.mxSession.mediaManager]; + if (attachment && attachment.type == MXKAttachmentTypeImage) + { + // Check the current thumbnail orientation. Rotate the current content size (if need) + if (attachment.thumbnailOrientation == UIImageOrientationLeft || attachment.thumbnailOrientation == UIImageOrientationRight) + { + _contentSize = CGSizeMake(_contentSize.height, _contentSize.width); + } + } + } + + // Report the attributed string (This will initialize _contentSize attribute) + self.attributedTextMessage = firstComponent.attributedTextMessage; + + // Initialize rendering attributes + _maxTextViewWidth = MXKROOMBUBBLECELLDATA_DEFAULT_MAX_TEXTVIEW_WIDTH; + } + else + { + // Ignore this event + self = nil; + } + } + return self; +} + +- (void)dealloc +{ + // Reset any observer on publicised groups by user. + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidUpdatePublicisedGroupsForUsersNotification object:self.mxSession]; + + roomDataSource = nil; + bubbleComponents = nil; +} + +- (NSUInteger)updateEvent:(NSString *)eventId withEvent:(MXEvent *)event +{ + NSUInteger count = 0; + + @synchronized(bubbleComponents) + { + // Retrieve the component storing the event and update it + for (NSUInteger index = 0; index < bubbleComponents.count; index++) + { + MXKRoomBubbleComponent *roomBubbleComponent = [bubbleComponents objectAtIndex:index]; + if ([roomBubbleComponent.event.eventId isEqualToString:eventId]) + { + [roomBubbleComponent updateWithEvent:event roomState:roomDataSource.roomState session:self.mxSession]; + if (!roomBubbleComponent.textMessage.length) + { + [bubbleComponents removeObjectAtIndex:index]; + } + // Indicate that the text message layout should be recomputed. + [self invalidateTextLayout]; + + // Handle here attachment update. + // For example: the case of update of attachment event happens when an echo is replaced by its true event + // received back by the events stream. + if (attachment) + { + // Check the current content url, to update it with the actual one + // Retrieve content url/info + NSString *eventContentURL = event.content[@"url"]; + if (event.content[@"file"][@"url"]) + { + eventContentURL = event.content[@"file"][@"url"]; + } + + if (!eventContentURL.length) + { + // The attachment has been redacted. + attachment = nil; + _contentSize = CGSizeZero; + } + else if (![attachment.eventId isEqualToString:event.eventId] || ![attachment.contentURL isEqualToString:eventContentURL]) + { + MXKAttachment *updatedAttachment = [[MXKAttachment alloc] initWithEvent:event andMediaManager:roomDataSource.mxSession.mediaManager]; + + // Sanity check on attachment type + if (updatedAttachment && attachment.type == updatedAttachment.type) + { + // Re-use the current image as preview to prevent the cell from flashing + updatedAttachment.previewImage = [attachment getCachedThumbnail]; + if (!updatedAttachment.previewImage && attachment.type == MXKAttachmentTypeImage) + { + updatedAttachment.previewImage = [MXMediaManager loadPictureFromFilePath:attachment.cacheFilePath]; + } + + // Clean the cache by removing the useless data + if (![updatedAttachment.cacheFilePath isEqualToString:attachment.cacheFilePath]) + { + [[NSFileManager defaultManager] removeItemAtPath:attachment.cacheFilePath error:nil]; + } + if (![updatedAttachment.thumbnailCachePath isEqualToString:attachment.thumbnailCachePath]) + { + [[NSFileManager defaultManager] removeItemAtPath:attachment.thumbnailCachePath error:nil]; + } + + // Update the current attachment description + attachment = updatedAttachment; + + if (attachment.type == MXKAttachmentTypeImage) + { + // Reset content size + _contentSize = CGSizeZero; + + // Check the current thumbnail orientation. Rotate the current content size (if need) + if (attachment.thumbnailOrientation == UIImageOrientationLeft || attachment.thumbnailOrientation == UIImageOrientationRight) + { + _contentSize = CGSizeMake(_contentSize.height, _contentSize.width); + } + } + } + else + { + MXLogDebug(@"[MXKRoomBubbleCellData] updateEvent: Warning: Does not support change of attachment type"); + } + } + } + else if ([roomDataSource.eventFormatter isSupportedAttachment:event]) + { + // The event is updated to an event with attachement + attachment = [[MXKAttachment alloc] initWithEvent:event andMediaManager:roomDataSource.mxSession.mediaManager]; + if (attachment && attachment.type == MXKAttachmentTypeImage) + { + // Check the current thumbnail orientation. Rotate the current content size (if need) + if (attachment.thumbnailOrientation == UIImageOrientationLeft || attachment.thumbnailOrientation == UIImageOrientationRight) + { + _contentSize = CGSizeMake(_contentSize.height, _contentSize.width); + } + } + } + + break; + } + } + + count = bubbleComponents.count; + } + + return count; +} + +- (NSUInteger)removeEvent:(NSString *)eventId +{ + NSUInteger count = 0; + + @synchronized(bubbleComponents) + { + for (MXKRoomBubbleComponent *roomBubbleComponent in bubbleComponents) + { + if ([roomBubbleComponent.event.eventId isEqualToString:eventId]) + { + [bubbleComponents removeObject:roomBubbleComponent]; + + // Indicate that the text message layout should be recomputed. + [self invalidateTextLayout]; + + break; + } + } + + count = bubbleComponents.count; + } + + return count; +} + +- (NSUInteger)removeEventsFromEvent:(NSString*)eventId removedEvents:(NSArray**)removedEvents; +{ + NSMutableArray *cuttedEvents = [NSMutableArray array]; + + @synchronized(bubbleComponents) + { + NSInteger componentIndex = [self bubbleComponentIndexForEventId:eventId]; + + if (NSNotFound != componentIndex) + { + NSArray *newBubbleComponents = [bubbleComponents subarrayWithRange:NSMakeRange(0, componentIndex)]; + + for (NSUInteger i = componentIndex; i < bubbleComponents.count; i++) + { + MXKRoomBubbleComponent *roomBubbleComponent = bubbleComponents[i]; + [cuttedEvents addObject:roomBubbleComponent.event]; + } + + bubbleComponents = [NSMutableArray arrayWithArray:newBubbleComponents]; + + // Indicate that the text message layout should be recomputed. + [self invalidateTextLayout]; + } + } + + *removedEvents = cuttedEvents; + return bubbleComponents.count; +} + +- (BOOL)hasSameSenderAsBubbleCellData:(id)bubbleCellData +{ + // Sanity check: accept only object of MXKRoomBubbleCellData classes or sub-classes + NSParameterAssert([bubbleCellData isKindOfClass:[MXKRoomBubbleCellData class]]); + + // NOTE: Same sender means here same id, same display name and same avatar + + // Check first user id + if ([senderId isEqualToString:bubbleCellData.senderId] == NO) + { + return NO; + } + // Check sender name + if ((senderDisplayName.length || bubbleCellData.senderDisplayName.length) && ([senderDisplayName isEqualToString:bubbleCellData.senderDisplayName] == NO)) + { + return NO; + } + // Check avatar url + if ((senderAvatarUrl.length || bubbleCellData.senderAvatarUrl.length) && ([senderAvatarUrl isEqualToString:bubbleCellData.senderAvatarUrl] == NO)) + { + return NO; + } + + return YES; +} + +- (MXKRoomBubbleComponent*) getFirstBubbleComponent +{ + MXKRoomBubbleComponent* first = nil; + + @synchronized(bubbleComponents) + { + if (bubbleComponents.count) + { + first = [bubbleComponents firstObject]; + } + } + + return first; +} + +- (MXKRoomBubbleComponent*) getFirstBubbleComponentWithDisplay +{ + // Look for the first component which is actually displayed (some event are ignored in room history display). + MXKRoomBubbleComponent* first = nil; + + @synchronized(bubbleComponents) + { + for (NSInteger index = 0; index < bubbleComponents.count; index++) + { + MXKRoomBubbleComponent *component = bubbleComponents[index]; + if (component.attributedTextMessage) + { + first = component; + break; + } + } + } + + return first; +} + +- (NSAttributedString*)attributedTextMessageWithHighlightedEvent:(NSString*)eventId tintColor:(UIColor*)tintColor +{ + NSAttributedString *customAttributedTextMsg; + + // By default only one component is supported, consider here the first component + MXKRoomBubbleComponent *firstComponent = [self getFirstBubbleComponent]; + + if (firstComponent) + { + customAttributedTextMsg = firstComponent.attributedTextMessage; + + // Sanity check + if (customAttributedTextMsg && [firstComponent.event.eventId isEqualToString:eventId]) + { + NSMutableAttributedString *customComponentString = [[NSMutableAttributedString alloc] initWithAttributedString:customAttributedTextMsg]; + UIColor *color = tintColor ? tintColor : [UIColor lightGrayColor]; + [customComponentString addAttribute:NSBackgroundColorAttributeName value:color range:NSMakeRange(0, customComponentString.length)]; + customAttributedTextMsg = customComponentString; + } + } + + return customAttributedTextMsg; +} + +- (void)highlightPatternInTextMessage:(NSString*)pattern withForegroundColor:(UIColor*)patternColor andFont:(UIFont*)patternFont +{ + highlightedPattern = pattern; + highlightedPatternColor = patternColor; + highlightedPatternFont = patternFont; + + // Indicate that the text message layout should be recomputed. + [self invalidateTextLayout]; +} + +- (void)setShouldHideSenderInformation:(BOOL)inShouldHideSenderInformation +{ + shouldHideSenderInformation = inShouldHideSenderInformation; + + if (!shouldHideSenderInformation) + { + // Refresh the flair + [self refreshSenderFlair]; + } +} + +- (void)refreshSenderFlair +{ + // Reset by default any observer on publicised groups by user. + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidUpdatePublicisedGroupsForUsersNotification object:self.mxSession]; + + // Check first whether the room enabled the flair for some groups + NSArray *roomRelatedGroups = roomDataSource.roomState.relatedGroups; + if (roomRelatedGroups.count && senderId) + { + NSArray *senderPublicisedGroups; + + senderPublicisedGroups = [self.mxSession publicisedGroupsForUser:senderId]; + + if (senderPublicisedGroups.count) + { + // Cross the 2 arrays to keep only the common group ids + NSMutableArray *flair = [NSMutableArray arrayWithCapacity:roomRelatedGroups.count]; + + for (NSString *groupId in roomRelatedGroups) + { + if ([senderPublicisedGroups indexOfObject:groupId] != NSNotFound) + { + MXGroup *group = [roomDataSource groupWithGroupId:groupId]; + [flair addObject:group]; + } + } + + if (flair.count) + { + self.senderFlair = flair; + } + else + { + self.senderFlair = nil; + } + } + else + { + self.senderFlair = nil; + } + + // Observe any change on publicised groups for the message sender + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXSessionUpdatePublicisedGroupsForUsers:) name:kMXSessionDidUpdatePublicisedGroupsForUsersNotification object:self.mxSession]; + } +} + +#pragma mark - + +- (void)invalidateTextLayout +{ + self.attributedTextMessage = nil; +} + +- (void)prepareBubbleComponentsPosition +{ + // Consider here only the first component if any + MXKRoomBubbleComponent *firstComponent = [self getFirstBubbleComponent]; + + if (firstComponent) + { + CGFloat positionY = (attachment == nil || attachment.type == MXKAttachmentTypeFile || attachment.type == MXKAttachmentTypeAudio || attachment.type == MXKAttachmentTypeVoiceMessage) ? MXKROOMBUBBLECELLDATA_TEXTVIEW_DEFAULT_VERTICAL_INSET : 0; + firstComponent.position = CGPointMake(0, positionY); + } +} + +- (NSInteger)bubbleComponentIndexForEventId:(NSString *)eventId +{ + return [self.bubbleComponents indexOfObjectPassingTest:^BOOL(MXKRoomBubbleComponent * _Nonnull bubbleComponent, NSUInteger idx, BOOL * _Nonnull stop) { + if ([bubbleComponent.event.eventId isEqualToString:eventId]) + { + *stop = YES; + return YES; + } + return NO; + }]; +} + +#pragma mark - Text measuring + +// Return the raw height of the provided text by removing any margin +- (CGFloat)rawTextHeight: (NSAttributedString*)attributedText +{ + __block CGSize textSize; + if ([NSThread currentThread] != [NSThread mainThread]) + { + dispatch_sync(dispatch_get_main_queue(), ^{ + textSize = [self textContentSize:attributedText removeVerticalInset:YES]; + }); + } + else + { + textSize = [self textContentSize:attributedText removeVerticalInset:YES]; + } + + return textSize.height; +} + +- (CGSize)textContentSize:(NSAttributedString*)attributedText removeVerticalInset:(BOOL)removeVerticalInset +{ + static UITextView* measurementTextView = nil; + static UITextView* measurementTextViewWithoutInset = nil; + + if (attributedText.length) + { + if (!measurementTextView) + { + measurementTextView = [[UITextView alloc] init]; + + measurementTextViewWithoutInset = [[UITextView alloc] init]; + // Remove the container inset: this operation impacts only the vertical margin. + // Note: consider textContainer.lineFragmentPadding to remove horizontal margin + measurementTextViewWithoutInset.textContainerInset = UIEdgeInsetsZero; + } + + // Select the right text view for measurement + UITextView *selectedTextView = (removeVerticalInset ? measurementTextViewWithoutInset : measurementTextView); + + selectedTextView.frame = CGRectMake(0, 0, _maxTextViewWidth, MAXFLOAT); + selectedTextView.attributedText = attributedText; + + CGSize size = [selectedTextView sizeThatFits:selectedTextView.frame.size]; + + // Manage the case where a string attribute has a single paragraph with a left indent + // In this case, [UITextView sizeThatFits] ignores the indent and return the width + // of the text only. + // So, add this indent afterwards + NSRange textRange = NSMakeRange(0, attributedText.length); + NSRange longestEffectiveRange; + NSParagraphStyle *paragraphStyle = [attributedText attribute:NSParagraphStyleAttributeName atIndex:0 longestEffectiveRange:&longestEffectiveRange inRange:textRange]; + + if (NSEqualRanges(textRange, longestEffectiveRange)) + { + size.width = size.width + paragraphStyle.headIndent; + } + + return size; + } + + return CGSizeZero; +} + +#pragma mark - Properties + +- (MXSession*)mxSession +{ + return roomDataSource.mxSession; +} + +- (NSArray*)bubbleComponents +{ + NSArray* copy; + + @synchronized(bubbleComponents) + { + copy = [bubbleComponents copy]; + } + + return copy; +} + +- (NSString*)textMessage +{ + return self.attributedTextMessage.string; +} + +- (void)setAttributedTextMessage:(NSAttributedString *)inAttributedTextMessage +{ + attributedTextMessage = inAttributedTextMessage; + + if (attributedTextMessage.length && highlightedPattern) + { + [self highlightPattern]; + } + + // Reset content size + _contentSize = CGSizeZero; +} + +- (NSAttributedString*)attributedTextMessage +{ + if (self.hasAttributedTextMessage && !attributedTextMessage.length) + { + // By default only one component is supported, consider here the first component + MXKRoomBubbleComponent *firstComponent = [self getFirstBubbleComponent]; + + if (firstComponent) + { + attributedTextMessage = firstComponent.attributedTextMessage; + + if (attributedTextMessage.length && highlightedPattern) + { + [self highlightPattern]; + } + } + } + + return attributedTextMessage; +} + +- (BOOL)hasAttributedTextMessage +{ + // Determine if the event formatter will return at least one string for the events in this cell. + // No string means that the event formatter has been configured so that it did not accept all events + // of the cell. + BOOL hasAttributedTextMessage = NO; + + @synchronized(bubbleComponents) + { + for (MXKRoomBubbleComponent *roomBubbleComponent in bubbleComponents) + { + if (roomBubbleComponent.attributedTextMessage) + { + hasAttributedTextMessage = YES; + break; + } + } + } + return hasAttributedTextMessage; +} + +- (BOOL)hasLink +{ + @synchronized (bubbleComponents) { + for (MXKRoomBubbleComponent *component in bubbleComponents) + { + if (component.link) + { + return YES; + } + } + } + + return NO; +} + +- (MXKRoomBubbleComponentDisplayFix)displayFix +{ + MXKRoomBubbleComponentDisplayFix displayFix = MXKRoomBubbleComponentDisplayFixNone; + + @synchronized(bubbleComponents) + { + for (MXKRoomBubbleComponent *component in self.bubbleComponents) + { + displayFix |= component.displayFix; + } + } + return displayFix; +} + +- (BOOL)shouldHideSenderName +{ + BOOL res = NO; + + MXKRoomBubbleComponent *firstDisplayedComponent = [self getFirstBubbleComponentWithDisplay]; + NSString *senderDisplayName = self.senderDisplayName; + + if (firstDisplayedComponent) + { + res = (firstDisplayedComponent.event.isEmote || (firstDisplayedComponent.event.isState && senderDisplayName && [firstDisplayedComponent.textMessage hasPrefix:senderDisplayName])); + } + + return res; +} + +- (NSArray*)events +{ + NSMutableArray* eventsArray; + + @synchronized(bubbleComponents) + { + eventsArray = [NSMutableArray arrayWithCapacity:bubbleComponents.count]; + for (MXKRoomBubbleComponent *roomBubbleComponent in bubbleComponents) + { + if (roomBubbleComponent.event) + { + [eventsArray addObject:roomBubbleComponent.event]; + } + } + } + return eventsArray; +} + +- (NSDate*)date +{ + MXKRoomBubbleComponent *firstDisplayedComponent = [self getFirstBubbleComponentWithDisplay]; + + if (firstDisplayedComponent) + { + return firstDisplayedComponent.date; + } + + return nil; +} + +- (BOOL)hasNoDisplay +{ + BOOL noDisplay = YES; + + // Check whether at least one component has a string description. + @synchronized(bubbleComponents) + { + if (self.collapsed) + { + // Collapsed cells have no display except their cell header + noDisplay = !self.collapsedAttributedTextMessage; + } + else + { + for (MXKRoomBubbleComponent *roomBubbleComponent in bubbleComponents) + { + if (roomBubbleComponent.attributedTextMessage) + { + noDisplay = NO; + break; + } + } + } + } + + return (noDisplay && !attachment); +} + +- (BOOL)isAttachmentWithThumbnail +{ + return (attachment && (attachment.type == MXKAttachmentTypeImage || attachment.type == MXKAttachmentTypeVideo || attachment.type == MXKAttachmentTypeSticker)); +} + +- (BOOL)isAttachmentWithIcon +{ + // Not supported yet (TODO for audio, file). + return NO; +} + +- (void)setMaxTextViewWidth:(CGFloat)inMaxTextViewWidth +{ + // Check change + if (inMaxTextViewWidth != _maxTextViewWidth) + { + _maxTextViewWidth = inMaxTextViewWidth; + // Reset content size + _contentSize = CGSizeZero; + } +} + +- (CGSize)contentSize +{ + if (CGSizeEqualToSize(_contentSize, CGSizeZero)) + { + if (attachment == nil) + { + // Here the bubble is a text message + if ([NSThread currentThread] != [NSThread mainThread]) + { + dispatch_sync(dispatch_get_main_queue(), ^{ + self->_contentSize = [self textContentSize:self.attributedTextMessage removeVerticalInset:NO]; + }); + } + else + { + _contentSize = [self textContentSize:self.attributedTextMessage removeVerticalInset:NO]; + } + } + else if (self.isAttachmentWithThumbnail) + { + CGFloat width, height; + + // Set default content size + width = height = MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH; + + if (attachment.thumbnailInfo || attachment.contentInfo) + { + if (attachment.thumbnailInfo && attachment.thumbnailInfo[@"w"] && attachment.thumbnailInfo[@"h"]) + { + width = [attachment.thumbnailInfo[@"w"] integerValue]; + height = [attachment.thumbnailInfo[@"h"] integerValue]; + } + else if (attachment.contentInfo[@"w"] && attachment.contentInfo[@"h"]) + { + width = [attachment.contentInfo[@"w"] integerValue]; + height = [attachment.contentInfo[@"h"] integerValue]; + } + + if (width > MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH || height > MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH) + { + if (width > height) + { + height = (height * MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH) / width; + height = floorf(height / 2) * 2; + width = MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH; + } + else + { + width = (width * MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH) / height; + width = floorf(width / 2) * 2; + height = MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH; + } + } + } + + // Check here thumbnail orientation + if (attachment.thumbnailOrientation == UIImageOrientationLeft || attachment.thumbnailOrientation == UIImageOrientationRight) + { + _contentSize = CGSizeMake(height, width); + } + else + { + _contentSize = CGSizeMake(width, height); + } + } + else if (attachment.type == MXKAttachmentTypeFile || attachment.type == MXKAttachmentTypeAudio) + { + // Presently we displayed only the file name for attached file (no icon yet) + // Return suitable content size of a text view to display the file name (available in text message). + if ([NSThread currentThread] != [NSThread mainThread]) + { + dispatch_sync(dispatch_get_main_queue(), ^{ + self->_contentSize = [self textContentSize:self.attributedTextMessage removeVerticalInset:NO]; + }); + } + else + { + _contentSize = [self textContentSize:self.attributedTextMessage removeVerticalInset:NO]; + } + } + else + { + _contentSize = CGSizeMake(40, 40); + } + } + return _contentSize; +} + +- (MXKEventFormatter *)eventFormatter +{ + MXKRoomBubbleComponent *firstComponent = [bubbleComponents firstObject]; + + // Retrieve event formatter from the first component + if (firstComponent) + { + return firstComponent.eventFormatter; + } + + return nil; +} + +- (BOOL)showAntivirusScanStatus +{ + MXKRoomBubbleComponent *firstBubbleComponent = self.bubbleComponents.firstObject; + + if (self.attachment == nil || firstBubbleComponent == nil) + { + return NO; + } + + MXEventScan *eventScan = firstBubbleComponent.eventScan; + + return eventScan != nil && eventScan.antivirusScanStatus != MXAntivirusScanStatusTrusted; +} + +- (BOOL)containsBubbleComponentWithEncryptionBadge +{ + BOOL containsBubbleComponentWithEncryptionBadge = NO; + + @synchronized(bubbleComponents) + { + for (MXKRoomBubbleComponent *component in bubbleComponents) + { + if (component.showEncryptionBadge) + { + containsBubbleComponentWithEncryptionBadge = YES; + break; + } + } + } + + return containsBubbleComponentWithEncryptionBadge; +} + +#pragma mark - Bubble collapsing + +- (BOOL)collapseWith:(id)cellData +{ + // NO by default + return NO; +} + +#pragma mark - Internals + +- (void)highlightPattern +{ + NSMutableAttributedString *customAttributedTextMsg = nil; + + NSString *currentTextMessage = self.textMessage; + NSRange range = [currentTextMessage rangeOfString:highlightedPattern options:NSCaseInsensitiveSearch]; + + if (range.location != NSNotFound) + { + customAttributedTextMsg = [[NSMutableAttributedString alloc] initWithAttributedString:self.attributedTextMessage]; + + while (range.location != NSNotFound) + { + if (highlightedPatternColor) + { + // Update text color + [customAttributedTextMsg addAttribute:NSForegroundColorAttributeName value:highlightedPatternColor range:range]; + } + + if (highlightedPatternFont) + { + // Update text font + [customAttributedTextMsg addAttribute:NSFontAttributeName value:highlightedPatternFont range:range]; + } + + // Look for the next pattern occurrence + range.location += range.length; + if (range.location < currentTextMessage.length) + { + range.length = currentTextMessage.length - range.location; + range = [currentTextMessage rangeOfString:highlightedPattern options:NSCaseInsensitiveSearch range:range]; + } + else + { + range.location = NSNotFound; + } + } + } + + if (customAttributedTextMsg) + { + // Update resulting message body + attributedTextMessage = customAttributedTextMsg; + } +} + +- (void)didMXSessionUpdatePublicisedGroupsForUsers:(NSNotification *)notif +{ + // Retrieved the list of the concerned users + NSArray *userIds = notif.userInfo[kMXSessionNotificationUserIdsArrayKey]; + if (userIds.count && self.senderId) + { + // Check whether the current sender is concerned. + if ([userIds indexOfObject:self.senderId] != NSNotFound) + { + [self refreshSenderFlair]; + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataStoring.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataStoring.h new file mode 100644 index 000000000..06b1584c4 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataStoring.h @@ -0,0 +1,348 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import "MXKRoomDataSource.h" + +#import "MXKAttachment.h" + +#import "MXEvent+MatrixKit.h" + +@class MXKRoomDataSource; +/** + `MXKRoomBubbleCellDataStoring` defines a protocol a class must conform in order to store MXKRoomBubble cell data + managed by `MXKRoomDataSource`. + */ +@protocol MXKRoomBubbleCellDataStoring + +#pragma mark - Data displayed by a room bubble cell + +/** + The sender Id + */ +@property (nonatomic) NSString *senderId; + +/** + The target Id (may be nil) + + @discussion "target" refers to the room member who is the target of this event (if any), e.g. + the invitee, the person being banned, etc. + */ +@property (nonatomic) NSString *targetId; + +/** + The room id + */ +@property (nonatomic) NSString *roomId; + +/** + The sender display name composed when event occured + */ +@property (nonatomic) NSString *senderDisplayName; + +/** + The sender avatar url retrieved when event occured + */ +@property (nonatomic) NSString *senderAvatarUrl; + +/** + The sender avatar placeholder (may be nil) - Used when url is nil, or during avatar download. + */ +@property (nonatomic) UIImage *senderAvatarPlaceholder; + +/** + The target display name composed when event occured (may be nil) + + @discussion "target" refers to the room member who is the target of this event (if any), e.g. + the invitee, the person being banned, etc. + */ +@property (nonatomic) NSString *targetDisplayName; + +/** + The target avatar url retrieved when event occured (may be nil) + + @discussion "target" refers to the room member who is the target of this event (if any), e.g. + the invitee, the person being banned, etc. + */ +@property (nonatomic) NSString *targetAvatarUrl; + +/** + The target avatar placeholder (may be nil) - Used when url is nil, or during avatar download. + + @discussion "target" refers to the room member who is the target of this event (if any), e.g. + the invitee, the person being banned, etc. + */ +@property (nonatomic) UIImage *targetAvatarPlaceholder; + +/** + The current sender flair (list of the publicised groups in the sender profile which matches the room flair settings) + */ +@property (nonatomic) NSArray *senderFlair; + +/** + Tell whether the room is encrypted. + */ +@property (nonatomic) BOOL isEncryptedRoom; + +/** + Tell whether a new pagination starts with this bubble. + */ +@property (nonatomic) BOOL isPaginationFirstBubble; + +/** + Tell whether the sender information is relevant for this bubble + (For example this information should be hidden in case of 2 consecutive bubbles from the same sender). + */ +@property (nonatomic) BOOL shouldHideSenderInformation; + +/** + Tell whether this bubble has nothing to display (neither a message nor an attachment). + */ +@property (nonatomic, readonly) BOOL hasNoDisplay; + +/** + The list of events (`MXEvent` instances) handled by this bubble. + */ +@property (nonatomic, readonly) NSArray *events; + +/** + The bubble attachment (if any). + */ +@property (nonatomic) MXKAttachment *attachment; + +/** + The bubble date + */ +@property (nonatomic) NSDate *date; + +/** + YES when the bubble is composed by incoming event(s). + */ +@property (nonatomic) BOOL isIncoming; + +/** + YES when the bubble correspond to an attachment displayed with a thumbnail (see image, video). + */ +@property (nonatomic) BOOL isAttachmentWithThumbnail; + +/** + YES when the bubble correspond to an attachment displayed with an icon (audio, file...). + */ +@property (nonatomic) BOOL isAttachmentWithIcon; + +/** + Flag that indicates that self.attributedTextMessage will be not nil. + This avoids the computation of self.attributedTextMessage that can take time. + */ +@property (nonatomic, readonly) BOOL hasAttributedTextMessage; + +/** + The body of the message with sets of attributes, or kind of content description in case of attachment (e.g. "image attachment") + */ +@property (nonatomic) NSAttributedString *attributedTextMessage; + +/** + The raw text message (without attributes) + */ +@property (nonatomic) NSString *textMessage; + +/** + Tell whether the sender's name is relevant or not for this bubble. + Return YES if the first component of the bubble message corresponds to an emote, or a state event in which + the sender's name appears at the beginning of the message text (for example membership events). + */ +@property (nonatomic) BOOL shouldHideSenderName; + +/** + YES if the sender is currently typing in the current room + */ +@property (nonatomic) BOOL isTyping; + +/** + Show the date time label in rendered bubble cell. NO by default. + */ +@property (nonatomic) BOOL showBubbleDateTime; + +/** + A Boolean value that determines whether the date time labels are customized (By default date time display is handled by MatrixKit). NO by default. + */ +@property (nonatomic) BOOL useCustomDateTimeLabel; + +/** + Show the receipts in rendered bubble cell. YES by default. + */ +@property (nonatomic) BOOL showBubbleReceipts; + +/** + A Boolean value that determines whether the read receipts are customized (By default read receipts display is handled by MatrixKit). NO by default. + */ +@property (nonatomic) BOOL useCustomReceipts; + +/** + A Boolean value that determines whether the unsent button is customized (By default an 'Unsent' button is displayed by MatrixKit in front of unsent events). NO by default. + */ +@property (nonatomic) BOOL useCustomUnsentButton; + +/** + An integer that you can use to identify cell data in your application. + The default value is 0. You can set the value of this tag and use that value to identify the cell data later. + */ +@property (nonatomic) NSInteger tag; + +/** + Indicate if antivirus scan status should be shown. + */ +@property (nonatomic, readonly) BOOL showAntivirusScanStatus; + +#pragma mark - Public methods +/** + Create a new `MXKRoomBubbleCellDataStoring` object for a new bubble cell. + + @param event the event to be displayed in the cell. + @param roomState the room state when the event occured. + @param roomDataSource the `MXKRoomDataSource` object that will use this instance. + @return the newly created instance. + */ +- (instancetype)initWithEvent:(MXEvent*)event andRoomState:(MXRoomState*)roomState andRoomDataSource:(MXKRoomDataSource*)roomDataSource; + +/** +Update the event because its sent state changed or it is has been redacted. + + @param eventId the id of the event to change. + @param event the new event data + @return the number of events hosting by the object after the update. + */ +- (NSUInteger)updateEvent:(NSString*)eventId withEvent:(MXEvent*)event; + +/** + Remove the event from the `MXKRoomBubbleCellDataStoring` object. + + @param eventId the id of the event to remove. + @return the number of events still hosting by the object after the removal + */ +- (NSUInteger)removeEvent:(NSString*)eventId; + +/** + Remove the passed event and all events after it. + + @param eventId the id of the event where to start removing. + @param removedEvents removedEvents will contain the list of removed events. + @return the number of events still hosting by the object after the removal. + */ +- (NSUInteger)removeEventsFromEvent:(NSString*)eventId removedEvents:(NSArray**)removedEvents; + +/** + Check if the receiver has the same sender as another bubble. + + @param bubbleCellData an object conforms to `MXKRoomBubbleCellDataStoring` protocol. + @return YES if the receiver has the same sender as the provided bubble + */ +- (BOOL)hasSameSenderAsBubbleCellData:(id)bubbleCellData; + +/** + Highlight text message of an event in the resulting message body. + + @param eventId the id of the event to highlight. + @param tintColor optional tint color + @return The body of the message by highlighting the content related to the provided event id + */ +- (NSAttributedString*)attributedTextMessageWithHighlightedEvent:(NSString*)eventId tintColor:(UIColor*)tintColor; + +/** + Highlight all the occurrences of a pattern in the resulting message body 'attributedTextMessage'. + + @param pattern the text pattern to highlight. + @param patternColor optional text color (the pattern text color is unchanged if nil). + @param patternFont optional text font (the pattern font is unchanged if nil). + */ +- (void)highlightPatternInTextMessage:(NSString*)pattern withForegroundColor:(UIColor*)patternColor andFont:(UIFont*)patternFont; + +/** + Refresh the sender flair information + */ +- (void)refreshSenderFlair; + +/** + Indicate that the current text message layout is no longer valid and should be recomputed + before presentation in a bubble cell. This could be due to the content changing, or the + available space for the cell has been updated. + */ +- (void)invalidateTextLayout; + +#pragma mark - Bubble collapsing + +/** + A Boolean value that indicates if the cell is collapsable. + */ +@property (nonatomic) BOOL collapsable; + +/** + A Boolean value that indicates if the cell and its series is collapsed. + */ +@property (nonatomic) BOOL collapsed; + +/** + The attributed string to display when the collapsable cells series is collapsed. + It is not nil only for the start cell of the cells series. + */ +@property (nonatomic) NSAttributedString *collapsedAttributedTextMessage; + +/** + Bidirectional linked list of cells that can be collapsed together. + If prevCollapsableCellData is nil, this cell data instance is the data of the start + cell of the collapsable cells series. + */ +@property (nonatomic) id prevCollapsableCellData; +@property (nonatomic) id nextCollapsableCellData; + +/** + The room state to use for computing or updating the data to display for the series when it is + collapsed. + It is not nil only for the start cell of the cells series. + */ +@property (nonatomic) MXRoomState *collapseState; + +/** + Check whether the two cells can be collapsable together. + + @return YES if YES. + */ +- (BOOL)collapseWith:(id)cellData; + +@optional +/** + Attempt to add a new event to the bubble. + + @param event the event to be displayed in the cell. + @param roomState the room state when the event occured. + @return YES if the model accepts that the event can concatenated to events already in the bubble. + */ +- (BOOL)addEvent:(MXEvent*)event andRoomState:(MXRoomState*)roomState; + +/** + The receiver appends to its content the provided bubble cell data, if both have the same sender. + + @param bubbleCellData an object conforms to `MXKRoomBubbleCellDataStoring` protocol. + @return YES if the provided cell data has been merged into receiver. + */ +- (BOOL)mergeWithBubbleCellData:(id)bubbleCellData; + + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithAppendingMode.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithAppendingMode.h new file mode 100644 index 000000000..8fc2cc2de --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithAppendingMode.h @@ -0,0 +1,45 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomBubbleCellData.h" + +/** + `MXKRoomBubbleCellDataWithAppendingMode` class inherits from `MXKRoomBubbleCellData`, it merges + consecutive events from the same sender into one bubble. + Each concatenated event is represented by a bubble component. + */ +@interface MXKRoomBubbleCellDataWithAppendingMode : MXKRoomBubbleCellData +{ +@protected + /** + YES if position of each component must be refreshed + */ + BOOL shouldUpdateComponentsPosition; +} + +/** + The string appended to the current message before adding a new component text. + */ ++ (NSAttributedString *)messageSeparator; + +/** + The maximum number of components in each bubble. Default is 10. + We limit the number of components to reduce the computation time required during bubble handling. + Indeed some process like [prepareBubbleComponentsPosition] is time consuming. + */ +@property (nonatomic) NSUInteger maxComponentCount; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithAppendingMode.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithAppendingMode.m new file mode 100644 index 000000000..680086ddd --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithAppendingMode.m @@ -0,0 +1,356 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomBubbleCellDataWithAppendingMode.h" + +static NSAttributedString *messageSeparator = nil; + +@implementation MXKRoomBubbleCellDataWithAppendingMode + +#pragma mark - MXKRoomBubbleCellDataStoring + +- (instancetype)initWithEvent:(MXEvent *)event andRoomState:(MXRoomState *)roomState andRoomDataSource:(MXKRoomDataSource *)roomDataSource2 +{ + self = [super initWithEvent:event andRoomState:roomState andRoomDataSource:roomDataSource2]; + if (self) + { + // Set default settings + self.maxComponentCount = 10; + } + + return self; +} + +- (BOOL)addEvent:(MXEvent*)event andRoomState:(MXRoomState*)roomState +{ + // We group together text messages from the same user (attachments are not merged). + if ([event.sender isEqualToString:self.senderId] && (self.attachment == nil) && (self.bubbleComponents.count < self.maxComponentCount)) + { + // Attachments (image, video, sticker ...) cannot be added here + if ([roomDataSource.eventFormatter isSupportedAttachment:event]) + { + return NO; + } + + // Check sender information + NSString *eventSenderName = [roomDataSource.eventFormatter senderDisplayNameForEvent:event withRoomState:roomState]; + NSString *eventSenderAvatar = [roomDataSource.eventFormatter senderAvatarUrlForEvent:event withRoomState:roomState]; + if ((self.senderDisplayName || eventSenderName) && + ([self.senderDisplayName isEqualToString:eventSenderName] == NO)) + { + return NO; + } + if ((self.senderAvatarUrl || eventSenderAvatar) && + ([self.senderAvatarUrl isEqualToString:eventSenderAvatar] == NO)) + { + return NO; + } + + // Take into account here the rendered bubbles pagination + if (roomDataSource.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay) + { + // Event must be sent the same day than the existing bubble. + NSString *bubbleDateString = [roomDataSource.eventFormatter dateStringFromDate:self.date withTime:NO]; + NSString *eventDateString = [roomDataSource.eventFormatter dateStringFromEvent:event withTime:NO]; + if (bubbleDateString && eventDateString && ![bubbleDateString isEqualToString:eventDateString]) + { + return NO; + } + } + + // Create new message component + MXKRoomBubbleComponent *addedComponent = [[MXKRoomBubbleComponent alloc] initWithEvent:event roomState:roomState eventFormatter:roomDataSource.eventFormatter session:self.mxSession]; + if (addedComponent) + { + [self addComponent:addedComponent]; + } + // else the event is ignored, we consider it as handled + return YES; + } + return NO; +} + +- (BOOL)mergeWithBubbleCellData:(id)bubbleCellData +{ + if ([self hasSameSenderAsBubbleCellData:bubbleCellData]) + { + MXKRoomBubbleCellData *cellData = (MXKRoomBubbleCellData*)bubbleCellData; + // Only text messages are merged (Attachments are not merged). + if ((self.attachment == nil) && (cellData.attachment == nil)) + { + // Take into account here the rendered bubbles pagination + if (roomDataSource.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay) + { + // bubble components must be sent the same day than self. + NSString *selfDateString = [roomDataSource.eventFormatter dateStringFromDate:self.date withTime:NO]; + NSString *bubbleDateString = [roomDataSource.eventFormatter dateStringFromDate:bubbleCellData.date withTime:NO]; + if (![bubbleDateString isEqualToString:selfDateString]) + { + return NO; + } + } + + // Add all components of the provided message + for (MXKRoomBubbleComponent* component in cellData.bubbleComponents) + { + [self addComponent:component]; + } + return YES; + } + } + return NO; +} + +- (NSAttributedString*)attributedTextMessageWithHighlightedEvent:(NSString*)eventId tintColor:(UIColor*)tintColor +{ + // Create attributed string + NSMutableAttributedString *customAttributedTextMsg; + NSAttributedString *componentString; + + @synchronized(bubbleComponents) + { + for (MXKRoomBubbleComponent* component in bubbleComponents) + { + componentString = component.attributedTextMessage; + + if (componentString) + { + if ([component.event.eventId isEqualToString:eventId]) + { + NSMutableAttributedString *customComponentString = [[NSMutableAttributedString alloc] initWithAttributedString:componentString]; + UIColor *color = tintColor ? tintColor : [UIColor lightGrayColor]; + [customComponentString addAttribute:NSBackgroundColorAttributeName value:color range:NSMakeRange(0, customComponentString.length)]; + componentString = customComponentString; + } + + if (!customAttributedTextMsg) + { + customAttributedTextMsg = [[NSMutableAttributedString alloc] initWithAttributedString:componentString]; + } + else + { + // Append attributed text + [customAttributedTextMsg appendAttributedString:[MXKRoomBubbleCellDataWithAppendingMode messageSeparator]]; + [customAttributedTextMsg appendAttributedString:componentString]; + } + } + } + } + + return customAttributedTextMsg; +} + +#pragma mark - + +- (void)prepareBubbleComponentsPosition +{ + // Set position of the first component + [super prepareBubbleComponentsPosition]; + + @synchronized(bubbleComponents) + { + // Check whether the position of other components need to be refreshed + if (!self.attachment && shouldUpdateComponentsPosition && bubbleComponents.count > 1) + { + // Init attributed string with the first text component not nil. + MXKRoomBubbleComponent *component = bubbleComponents.firstObject; + CGFloat positionY = component.position.y; + NSMutableAttributedString *attributedString; + NSUInteger index = 0; + + for (; index < bubbleComponents.count; index++) + { + component = [bubbleComponents objectAtIndex:index]; + + component.position = CGPointMake(0, positionY); + + if (component.attributedTextMessage) + { + attributedString = [[NSMutableAttributedString alloc] initWithAttributedString:component.attributedTextMessage]; + [attributedString appendAttributedString:[MXKRoomBubbleCellDataWithAppendingMode messageSeparator]]; + break; + } + } + + for (index++; index < bubbleComponents.count; index++) + { + // Append the next text component + component = [bubbleComponents objectAtIndex:index]; + + if (component.attributedTextMessage) + { + [attributedString appendAttributedString:component.attributedTextMessage]; + + // Compute the height of the resulting string + CGFloat cumulatedHeight = [self rawTextHeight:attributedString]; + + // Deduce the position of the beginning of this component + CGFloat positionY = MXKROOMBUBBLECELLDATA_TEXTVIEW_DEFAULT_VERTICAL_INSET + (cumulatedHeight - [self rawTextHeight:component.attributedTextMessage]); + + component.position = CGPointMake(0, positionY); + + [attributedString appendAttributedString:[MXKRoomBubbleCellDataWithAppendingMode messageSeparator]]; + } + else + { + // Apply the current vertical position on this empty component. + component.position = CGPointMake(0, positionY); + } + } + } + } + + shouldUpdateComponentsPosition = NO; +} + +#pragma mark - + +- (NSString*)textMessage +{ + NSString *rawText = nil; + + if (self.attributedTextMessage) + { + // Append all components text message + NSMutableString *currentTextMsg; + @synchronized(bubbleComponents) + { + for (MXKRoomBubbleComponent* component in bubbleComponents) + { + if (component.textMessage == nil) + { + continue; + } + if (!currentTextMsg) + { + currentTextMsg = [NSMutableString stringWithString:component.textMessage]; + } + else + { + // Append text message + [currentTextMsg appendString:@"\n"]; + [currentTextMsg appendString:component.textMessage]; + } + } + } + rawText = currentTextMsg; + } + + return rawText; +} + +- (void)setAttributedTextMessage:(NSAttributedString *)inAttributedTextMessage +{ + super.attributedTextMessage = inAttributedTextMessage; + + // Position of each components should be computed again + shouldUpdateComponentsPosition = YES; +} + +- (NSAttributedString*)attributedTextMessage +{ + @synchronized(bubbleComponents) + { + if (self.hasAttributedTextMessage && !attributedTextMessage.length) + { + // Create attributed string + NSMutableAttributedString *currentAttributedTextMsg; + + for (MXKRoomBubbleComponent* component in bubbleComponents) + { + if (component.attributedTextMessage) + { + if (!currentAttributedTextMsg) + { + currentAttributedTextMsg = [[NSMutableAttributedString alloc] initWithAttributedString:component.attributedTextMessage]; + } + else + { + // Append attributed text + [currentAttributedTextMsg appendAttributedString:[MXKRoomBubbleCellDataWithAppendingMode messageSeparator]]; + [currentAttributedTextMsg appendAttributedString:component.attributedTextMessage]; + } + } + } + self.attributedTextMessage = currentAttributedTextMsg; + } + } + + return attributedTextMessage; +} + +- (void)setMaxTextViewWidth:(CGFloat)inMaxTextViewWidth +{ + CGFloat previousMaxWidth = self.maxTextViewWidth; + + [super setMaxTextViewWidth:inMaxTextViewWidth]; + + // Check change + if (previousMaxWidth != self.maxTextViewWidth) + { + // Position of each components should be computed again + shouldUpdateComponentsPosition = YES; + } +} + +#pragma mark - + ++ (NSAttributedString *)messageSeparator +{ + @synchronized(self) + { + if(messageSeparator == nil) + { + messageSeparator = [[NSAttributedString alloc] initWithString:@"\n\n" attributes:@{NSForegroundColorAttributeName : [UIColor blackColor], + NSFontAttributeName: [UIFont systemFontOfSize:4]}]; + } + } + return messageSeparator; +} + +#pragma mark - Privates + +- (void)addComponent:(MXKRoomBubbleComponent*)addedComponent +{ + @synchronized(bubbleComponents) + { + // Check date of existing components to insert this new one + NSUInteger index = bubbleComponents.count; + + // Component without date is added at the end by default + if (addedComponent.date) + { + while (index) + { + MXKRoomBubbleComponent *msgComponent = [bubbleComponents objectAtIndex:(--index)]; + if (msgComponent.date && [msgComponent.date compare:addedComponent.date] != NSOrderedDescending) + { + // New component will be inserted here + index ++; + break; + } + } + } + + // Insert new component + [bubbleComponents insertObject:addedComponent atIndex:index]; + + // Indicate that the data's text message layout should be recomputed. + [self invalidateTextLayout]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithIncomingAppendingMode.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithIncomingAppendingMode.h new file mode 100644 index 000000000..49b050595 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithIncomingAppendingMode.h @@ -0,0 +1,27 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomBubbleCellDataWithAppendingMode.h" + +/** + `MXKRoomBubbleCellDataWithIncomingAppendingMode` class inherits from `MXKRoomBubbleCellDataWithAppendingMode`, + only the incoming message cells are merged. + */ +@interface MXKRoomBubbleCellDataWithIncomingAppendingMode : MXKRoomBubbleCellDataWithAppendingMode +{ +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithIncomingAppendingMode.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithIncomingAppendingMode.m new file mode 100644 index 000000000..ebe30784f --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithIncomingAppendingMode.m @@ -0,0 +1,45 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomBubbleCellDataWithIncomingAppendingMode.h" + +@implementation MXKRoomBubbleCellDataWithIncomingAppendingMode + +#pragma mark - MXKRoomBubbleCellDataStoring + +- (BOOL)addEvent:(MXEvent*)event andRoomState:(MXRoomState*)roomState +{ + // Do not merge outgoing events + if ([event.sender isEqualToString:roomDataSource.mxSession.myUser.userId]) + { + return NO; + } + + return [super addEvent:event andRoomState:roomState]; +} + +- (BOOL)mergeWithBubbleCellData:(id)bubbleCellData +{ + // Do not merge outgoing events + if ([bubbleCellData.senderId isEqualToString:roomDataSource.mxSession.myUser.userId]) + { + return NO; + } + + return [super mergeWithBubbleCellData:bubbleCellData]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.h new file mode 100644 index 000000000..3912932c8 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.h @@ -0,0 +1,127 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKEventFormatter.h" +#import "MXKURLPreviewDataProtocol.h" + +/** + Flags to indicate if a fix is required at the display time. + */ +typedef enum : NSUInteger { + + /** + No fix required. + */ + MXKRoomBubbleComponentDisplayFixNone = 0, + + /** + Borders for HTML blockquotes need to be fixed. + */ + MXKRoomBubbleComponentDisplayFixHtmlBlockquote = 0x1 + +} MXKRoomBubbleComponentDisplayFix; + +/** + `MXKRoomBubbleComponent` class compose data related to one `MXEvent` instance. + */ +@interface MXKRoomBubbleComponent : NSObject + +/** + The body of the message, or kind of content description in case of attachment (e.g. "image attachment"). + */ +@property (nonatomic) NSString *textMessage; + +/** + The `textMessage` with sets of attributes. + */ +@property (nonatomic) NSAttributedString *attributedTextMessage; + +/** + The event date + */ +@property (nonatomic) NSDate *date; + +/** + Event formatter + */ +@property (nonatomic) MXKEventFormatter *eventFormatter; + +/** + The event on which the component is based (used in case of redaction) + */ +@property (nonatomic, readonly) MXEvent *event; + +// The following properties are defined to store information on component. +// They must be handled by the object which creates the MXKRoomBubbleComponent instance. +//@property (nonatomic) CGFloat height; +@property (nonatomic) CGPoint position; + +/** + Set of flags indicating fixes that need to be applied at display time. + */ +@property (nonatomic) MXKRoomBubbleComponentDisplayFix displayFix; + +/** + The first link detected in the event's content, otherwise nil. + */ +@property (nonatomic) NSURL *link; + +/** + Any data necessary to show a URL preview. + Note: MatrixKit is unable to display this data by itself. + */ +@property (nonatomic) id urlPreviewData; + +/** + Whether a URL preview should be displayed for this cell. + Note: MatrixKit is unable to display URL previews by itself. + */ +@property (nonatomic) BOOL showURLPreview; + +/** + Event antivirus scan. Present only if antivirus is enabled and event contains media. + */ +@property (nonatomic) MXEventScan *eventScan; + +/** + Indicate if an encryption badge should be shown. + */ +@property (nonatomic, readonly) BOOL showEncryptionBadge; + +/** + Create a new `MXKRoomBubbleComponent` object based on a `MXEvent` instance. + + @param event the event used to compose the bubble component. + @param roomState the room state when the event occured. + @param eventFormatter object used to format event into displayable string. + @param session the related matrix session. + @return the newly created instance. + */ +- (instancetype)initWithEvent:(MXEvent*)event roomState:(MXRoomState*)roomState eventFormatter:(MXKEventFormatter*)eventFormatter session:(MXSession*)session; + +/** + Update the event because its sent state changed or it is has been redacted. + + @param event the new event data. + @param roomState the up-to-date state of the room. + @param session the related matrix session. + */ +- (void)updateWithEvent:(MXEvent*)event roomState:(MXRoomState*)roomState session:(MXSession*)session; + +@end + diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.m new file mode 100644 index 000000000..565519ba9 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.m @@ -0,0 +1,189 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomBubbleComponent.h" + +#import "MXEvent+MatrixKit.h" +#import "MXKSwiftHeader.h" + +@implementation MXKRoomBubbleComponent + +- (instancetype)initWithEvent:(MXEvent*)event roomState:(MXRoomState*)roomState eventFormatter:(MXKEventFormatter*)eventFormatter session:(MXSession*)session; +{ + if (self = [super init]) + { + // Build text component related to this event + _eventFormatter = eventFormatter; + MXKEventFormatterError error; + + NSAttributedString *eventString = [_eventFormatter attributedStringFromEvent:event withRoomState:roomState error:&error]; + + // Store the potential error + event.mxkEventFormatterError = error; + + _textMessage = nil; + _attributedTextMessage = eventString; + + // Set date time + if (event.originServerTs != kMXUndefinedTimestamp) + { + _date = [NSDate dateWithTimeIntervalSince1970:(double)event.originServerTs/1000]; + } + else + { + _date = nil; + } + + // Keep ref on event (used to handle the read marker, or a potential event redaction). + _event = event; + + _displayFix = MXKRoomBubbleComponentDisplayFixNone; + if ([event.content[@"format"] isEqualToString:kMXRoomMessageFormatHTML]) + { + if ([((NSString*)event.content[@"formatted_body"]) containsString:@" +#import + +#import + +@class MXSession; + +/** + `MXKRoomCreationInputs` objects lists all the fields considered for a new room creation. + */ +@interface MXKRoomCreationInputs : NSObject + +/** + The selected matrix session in which the new room should be created. + */ +@property (nonatomic) MXSession* mxSession; + +/** + The room name. + */ +@property (nonatomic) NSString* roomName; + +/** + The room alias. + */ +@property (nonatomic) NSString* roomAlias; + +/** + The room topic. + */ +@property (nonatomic) NSString* roomTopic; + +/** + The room picture. + */ +@property (nonatomic) UIImage *roomPicture; + +/** + The room visibility (kMXRoomVisibilityPrivate by default). + */ +@property (nonatomic) MXRoomDirectoryVisibility roomVisibility; + +/** + The room participants (nil by default). + */ +@property (nonatomic) NSArray *roomParticipants; + +/** + Add a participant. + + @param participantId The matrix user id of the participant. + */ +- (void)addParticipant:(NSString *)participantId; + +/** + Remove a participant. + + @param participantId The matrix user id of the participant. + */ +- (void)removeParticipant:(NSString *)participantId; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomCreationInputs.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomCreationInputs.m new file mode 100644 index 000000000..f5cda5bb8 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomCreationInputs.m @@ -0,0 +1,74 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomCreationInputs.h" + +#import + +@interface MXKRoomCreationInputs () +{ + NSMutableArray *participants; +} +@end + +@implementation MXKRoomCreationInputs + +- (instancetype)init +{ + self = [super init]; + if (self) + { + _roomVisibility = kMXRoomDirectoryVisibilityPrivate; + } + return self; +} + +- (void)setRoomParticipants:(NSArray *)roomParticipants +{ + participants = [NSMutableArray arrayWithArray:roomParticipants]; +} + +- (NSArray*)roomParticipants +{ + return participants; +} + +- (void)addParticipant:(NSString *)participantId +{ + if (participantId.length) + { + if (!participants) + { + participants = [NSMutableArray array]; + } + [participants addObject:participantId]; + } +} + +- (void)removeParticipant:(NSString *)participantId +{ + if (participantId.length) + { + [participants removeObject:participantId]; + + if (!participants.count) + { + participants = nil; + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.h new file mode 100644 index 000000000..8559fba2d --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.h @@ -0,0 +1,779 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKDataSource.h" +#import "MXKRoomBubbleCellDataStoring.h" +#import "MXKEventFormatter.h" + +@class MXKQueuedEvent; + +/** + Define the threshold which triggers a bubbles count flush. + */ +#define MXKROOMDATASOURCE_CACHED_BUBBLES_COUNT_THRESHOLD 30 + +/** + Define the number of messages to preload around the initial event. + */ +#define MXKROOMDATASOURCE_PAGINATION_LIMIT_AROUND_INITIAL_EVENT 30 + +/** + List the supported pagination of the rendered room bubble cells + */ +typedef enum : NSUInteger +{ + /** + No pagination + */ + MXKRoomDataSourceBubblesPaginationNone, + /** + The rendered room bubble cells are paginated per day + */ + MXKRoomDataSourceBubblesPaginationPerDay + +} MXKRoomDataSourceBubblesPagination; + + +#pragma mark - Cells identifiers + +/** + String identifying the object used to store and prepare room bubble data. + */ +extern NSString *const kMXKRoomBubbleCellDataIdentifier; + + +#pragma mark - Notifications + +/** + Posted when a server sync starts or ends (depend on 'serverSyncEventCount'). + The notification object is the `MXKRoomDataSource` instance. + */ +extern NSString *const kMXKRoomDataSourceSyncStatusChanged; + +/** + Posted when the data source has failed to paginate around an event. + The notification object is the `MXKRoomDataSource` instance. The `userInfo` dictionary contains the following key: + - kMXKRoomDataTimelineErrorErrorKey: The NSError. + */ +extern NSString *const kMXKRoomDataSourceTimelineError; + +/** + Notifications `userInfo` keys + */ +extern NSString *const kMXKRoomDataSourceTimelineErrorErrorKey; + +#pragma mark - MXKRoomDataSource +@protocol MXKRoomBubbleCellDataStoring; +@class MXKRoomBubbleCellData; + +/** + The data source for `MXKRoomViewController`. + */ +@interface MXKRoomDataSource : MXKDataSource +{ +@protected + + /** + The data for the cells served by `MXKRoomDataSource`. + */ + NSMutableArray> *bubbles; + + /** + The queue of events that need to be processed in order to compute their display. + */ + NSMutableArray *eventsToProcess; + + /** + The dictionary of the related groups that the current user did not join. + */ + NSMutableDictionary *externalRelatedGroups; +} + +/** + The id of the room managed by the data source. + */ +@property (nonatomic, readonly) NSString *roomId; + +/** + The id of the secondary room managed by the data source. Events with specified types from the secondary room will be provided from the data source. + @see `secondaryRoomEventTypes`. + Can be nil. + */ +@property (nonatomic, copy) NSString *secondaryRoomId; + +/** + Types of events to include from the secondary room. Default is all call events. + */ +@property (nonatomic, copy) NSArray *secondaryRoomEventTypes; + +/** + The room the data comes from. + The object is defined when the MXSession has data for the room + */ +@property (nonatomic, readonly) MXRoom *room; + +/** + The preloaded room.state. + */ +@property (nonatomic, readonly) MXRoomState *roomState; + +/** + The timeline being managed. It can be the live timeline of the room + or a timeline from a past event, initialEventId. + */ +@property (nonatomic, readonly) MXEventTimeline *timeline; + +/** + Flag indicating if the data source manages, or will manage, a live timeline. + */ +@property (nonatomic, readonly) BOOL isLive; + +/** + Flag indicating if the data source is used to peek into a room, ie it gets data from + a room the user has not joined yet. + */ +@property (nonatomic, readonly) BOOL isPeeking; + +/** + The list of the attachments with thumbnail in the current available bubbles (MXKAttachment instances). + Note: the stickers are excluded from the returned list. + Note2: the attachments for which the antivirus scan status is not available are excluded too. + */ +@property (nonatomic, readonly) NSArray *attachmentsWithThumbnail; + +/** + The events are processed asynchronously. This property counts the number of queued events + during server sync for which the process is pending. + */ +@property (nonatomic, readonly) NSInteger serverSyncEventCount; + +/** + The current text message partially typed in text input (use nil to reset it). + */ +@property (nonatomic) NSString *partialTextMessage; + +#pragma mark - Configuration +/** + The text formatter applied on the events. + By default, the events are filtered according to the value stored in the shared application settings (see [MXKAppSettings standardAppSettings].eventsFilterForMessages). + The events whose the type doesn't belong to the this list are not displayed. + `MXKRoomBubbleCellDataStoring` instances can use it to format text. + */ +@property (nonatomic) MXKEventFormatter *eventFormatter; + +/** + Show the date time label in rendered room bubble cells. NO by default. + */ +@property (nonatomic) BOOL showBubblesDateTime; + +/** + A Boolean value that determines whether the date time labels are customized (By default date time display is handled by MatrixKit). NO by default. + */ +@property (nonatomic) BOOL useCustomDateTimeLabel; + +/** + Show the read marker (if any) in the rendered room bubble cells. YES by default. + */ +@property (nonatomic) BOOL showReadMarker; + +/** + Show the receipts in rendered bubble cell. YES by default. + */ +@property (nonatomic) BOOL showBubbleReceipts; + +/** + A Boolean value that determines whether the read receipts are customized (By default read receipts display is handled by MatrixKit). NO by default. + */ +@property (nonatomic) BOOL useCustomReceipts; + +/** + Show the reactions in rendered bubble cell. NO by default. + */ +@property (nonatomic) BOOL showReactions; + +/** + Show only reactions with single Emoji. NO by default. + */ +@property (nonatomic) BOOL showOnlySingleEmojiReactions; + +/** + A Boolean value that determines whether the unsent button is customized (By default an 'Unsent' button is displayed by MatrixKit in front of unsent events). NO by default. + */ +@property (nonatomic) BOOL useCustomUnsentButton; + +/** + Show the typing notifications of other room members in the chat history (NO by default). + */ +@property (nonatomic) BOOL showTypingNotifications; + +/** + The pagination applied on the rendered room bubble cells (MXKRoomDataSourceBubblesPaginationNone by default). + */ +@property (nonatomic) MXKRoomDataSourceBubblesPagination bubblesPagination; + +/** + Max nbr of cached bubbles when there is no delegate. + The default value is 30. + */ +@property (nonatomic) unsigned long maxBackgroundCachedBubblesCount; + +/** + The number of messages to preload around the initial event. + The default value is 30. + */ +@property (nonatomic) NSUInteger paginationLimitAroundInitialEvent; + +/** + Tell whether only the message events with an url key in their content must be handled. NO by default. + Note: The stickers are not retained by this filter. + */ +@property (nonatomic) BOOL filterMessagesWithURL; + +#pragma mark - Life cycle + +/** + Asynchronously create a data source to serve data corresponding to the passed room. + + This method preloads room data, like the room state, to make it available once + the room data source is created. + + @param roomId the id of the room to get data from. + @param mxSession the Matrix session to get data from. + @param onComplete a block providing the newly created instance. + */ ++ (void)loadRoomDataSourceWithRoomId:(NSString*)roomId andMatrixSession:(MXSession*)mxSession onComplete:(void (^)(id roomDataSource))onComplete; + +/** + Asynchronously create adata source to serve data corresponding to an event in the + past of a room. + + This method preloads room data, like the room state, to make it available once + the room data source is created. + + @param roomId the id of the room to get data from. + @param initialEventId the id of the event where to start the timeline. + @param mxSession the Matrix session to get data from. + @param onComplete a block providing the newly created instance. + */ ++ (void)loadRoomDataSourceWithRoomId:(NSString*)roomId initialEventId:(NSString*)initialEventId andMatrixSession:(MXSession*)mxSession onComplete:(void (^)(id roomDataSource))onComplete; + +/** + Asynchronously create a data source to peek into a room. + + The data source will close the `peekingRoom` instance on [self destroy]. + + This method preloads room data, like the room state, to make it available once + the room data source is created. + + @param peekingRoom the room to peek. + @param initialEventId the id of the event where to start the timeline. nil means the live + timeline. + @param onComplete a block providing the newly created instance. + */ ++ (void)loadRoomDataSourceWithPeekingRoom:(MXPeekingRoom*)peekingRoom andInitialEventId:(NSString*)initialEventId onComplete:(void (^)(id roomDataSource))onComplete; + +#pragma mark - Constructors (Should not be called directly) + +/** + Initialise the data source to serve data corresponding to the passed room. + + @param roomId the id of the room to get data from. + @param mxSession the Matrix session to get data from. + @return the newly created instance. + */ +- (instancetype)initWithRoomId:(NSString*)roomId andMatrixSession:(MXSession*)mxSession; + +/** + Initialise the data source to serve data corresponding to an event in the + past of a room. + + @param roomId the id of the room to get data from. + @param initialEventId the id of the event where to start the timeline. + @param mxSession the Matrix session to get data from. + @return the newly created instance. + */ +- (instancetype)initWithRoomId:(NSString*)roomId initialEventId:(NSString*)initialEventId andMatrixSession:(MXSession*)mxSession; + +/** + Initialise the data source to peek into a room. + + The data source will close the `peekingRoom` instance on [self destroy]. + + @param peekingRoom the room to peek. + @param initialEventId the id of the event where to start the timeline. nil means the live + timeline. + @return the newly created instance. + */ +- (instancetype)initWithPeekingRoom:(MXPeekingRoom*)peekingRoom andInitialEventId:(NSString*)initialEventId; + +/** + Mark all messages as read in the room. + */ +- (void)markAllAsRead; + +/** + Reduce memory usage by releasing room data if the number of bubbles is over the provided limit 'maxBubbleNb'. + + This operation is ignored if some local echoes are pending or if unread messages counter is not nil. + + @param maxBubbleNb The room bubble data are released only if the number of bubbles is over this limit. + */ +- (void)limitMemoryUsage:(NSInteger)maxBubbleNb; + +/** + Force data reload. + */ +- (void)reload; + +/** + Called when room property changed. Designed to be used by subclasses. + */ +- (void)roomDidSet; + +#pragma mark - Public methods +/** + Get the data for the cell at the given index. + + @param index the index of the cell in the array + @return the cell data + */ +- (id)cellDataAtIndex:(NSInteger)index; + +/** + Get the data for the cell which contains the event with the provided event id. + + @param eventId the event identifier + @return the cell data + */ +- (id)cellDataOfEventWithEventId:(NSString*)eventId; + +/** + Get the index of the cell which contains the event with the provided event id. + + @param eventId the event identifier + @return the index of the concerned cell (NSNotFound if none). + */ +- (NSInteger)indexOfCellDataWithEventId:(NSString *)eventId; + +/** + Get height of the cell at the given index. + + @param index the index of the cell in the array. + @param maxWidth the maximum available width. + @return the cell height (0 if no data is available for this cell, or if the delegate is undefined). + */ +- (CGFloat)cellHeightAtIndex:(NSInteger)index withMaximumWidth:(CGFloat)maxWidth; + + +/** + Force bubbles cell data message recalculation. + */ +- (void)invalidateBubblesCellDataCache; + +#pragma mark - Pagination +/** + Load more messages. + This method fails (with nil error) if the data source is not ready (see `MXKDataSourceStateReady`). + + @param numItems the number of items to get. + @param direction backwards or forwards. + @param onlyFromStore if YES, return available events from the store, do not make a pagination request to the homeserver. + @param success a block called when the operation succeeds. This block returns the number of added cells. + (Note this count may be 0 if paginated messages have been concatenated to the current first cell). + @param failure a block called when the operation fails. + */ +- (void)paginate:(NSUInteger)numItems direction:(MXTimelineDirection)direction onlyFromStore:(BOOL)onlyFromStore success:(void (^)(NSUInteger addedCellNumber))success failure:(void (^)(NSError *error))failure; + +/** + Load enough messages to fill the rect. + This method fails (with nil error) if the data source is not ready (see `MXKDataSourceStateReady`), + or if the delegate is undefined (this delegate is required to compute the actual size of the cells). + + @param rect the rect to fill. + @param direction backwards or forwards. + @param minRequestMessagesCount if messages are not available in the store, a request to the homeserver + is required. minRequestMessagesCount indicates the minimum messages count to retrieve from the hs. + @param success a block called when the operation succeeds. + @param failure a block called when the operation fails. + */ +- (void)paginateToFillRect:(CGRect)rect direction:(MXTimelineDirection)direction withMinRequestMessagesCount:(NSUInteger)minRequestMessagesCount success:(void (^)(void))success failure:(void (^)(NSError *error))failure; + + +#pragma mark - Sending +/** + Send a text message to the room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param text the text to send. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendTextMessage:(NSString*)text + success:(void (^)(NSString *eventId))success + failure:(void (^)(NSError *error))failure; + +/** + Send a reply to an event with text message to the room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param eventIdToReply the id of event to reply. + @param text the text to send. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendReplyToEventWithId:(NSString*)eventIdToReply + withTextMessage:(NSString *)text + success:(void (^)(NSString *))success + failure:(void (^)(NSError *))failure; + +/** + Indicates if replying to the provided event is supported. + Only event of type 'MXEventTypeRoomMessage' are supported for the moment, and for certain msgtype. + + @param eventId The id of the event. + @return YES if it is possible to reply to this event. + */ +- (BOOL)canReplyToEventWithId:(NSString*)eventId; + +/** + Send an image to the room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param image the UIImage containing the image to send. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendImage:(UIImage*)image + success:(void (^)(NSString *eventId))success + failure:(void (^)(NSError *error))failure; + +/** + Send an image to the room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param imageData the full-sized image data of the image to send. + @param mimetype the mime type of the image + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendImage:(NSData*)imageData mimeType:(NSString*)mimetype success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure; + +/** + Send a video to the room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param videoLocalURL the local filesystem path of the video to send. + @param videoThumbnail the UIImage hosting a video thumbnail. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendVideo:(NSURL*)videoLocalURL + withThumbnail:(UIImage*)videoThumbnail + success:(void (^)(NSString *eventId))success + failure:(void (^)(NSError *error))failure; + +/** + Send a video to the room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param videoAsset the AVAsset that represents the video to send. + @param videoThumbnail the UIImage hosting a video thumbnail. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendVideoAsset:(AVAsset*)videoAsset + withThumbnail:(UIImage*)videoThumbnail + success:(void (^)(NSString *eventId))success + failure:(void (^)(NSError *error))failure; + +/** + Send an audio file to the room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param audioFileLocalURL the local filesystem path of the audio file to send. + @param mimeType the mime type of the file. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendAudioFile:(NSURL *)audioFileLocalURL + mimeType:mimeType + success:(void (^)(NSString *))success + failure:(void (^)(NSError *))failure; + +/** + Send a voice message to the room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param audioFileLocalURL the local filesystem path of the audio file to send. + @param mimeType (optional) the mime type of the file. Defaults to `audio/ogg` + @param duration the length of the voice message in milliseconds + @param samples an array of floating point values normalized to [0, 1], boxed within NSNumbers + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendVoiceMessage:(NSURL *)audioFileLocalURL + mimeType:mimeType + duration:(NSUInteger)duration + samples:(NSArray *)samples + success:(void (^)(NSString *))success + failure:(void (^)(NSError *))failure; + +/** + Send a file to the room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param fileLocalURL the local filesystem path of the file to send. + @param mimeType the mime type of the file. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendFile:(NSURL*)fileLocalURL + mimeType:(NSString*)mimeType + success:(void (^)(NSString *eventId))success + failure:(void (^)(NSError *error))failure; + +/** + Send a room message to a room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param content the message content that will be sent to the server as a JSON object. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendMessageWithContent:(NSDictionary*)content + success:(void (^)(NSString *eventId))success + failure:(void (^)(NSError *error))failure; + +/** + Send a generic non state event to a room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param eventTypeString the type of the event. @see MXEventType. + @param content the content that will be sent to the server as a JSON object. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendEventOfType:(MXEventTypeString)eventTypeString + content:(NSDictionary*)content + success:(void (^)(NSString *eventId))success + failure:(void (^)(NSError *error))failure; + +/** + Resend a room message event. + + The echo message corresponding to the event will be removed and a new echo message + will be added at the end of the room history. + + @param eventId of the event to resend. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)resendEventWithEventId:(NSString*)eventId + success:(void (^)(NSString *eventId))success + failure:(void (^)(NSError *error))failure; + + +#pragma mark - Events management +/** + Get an event loaded in this room datasource. + + @param eventId of the event to retrieve. + @return the MXEvent object or nil if not found. + */ +- (MXEvent *)eventWithEventId:(NSString *)eventId; + +/** + Remove an event from the events loaded by room datasource. + + @param eventId of the event to remove. + */ +- (void)removeEventWithEventId:(NSString *)eventId; + +/** + This method is called for each read receipt event received in forward mode. + + By default, it tells the delegate that some cell data/views have been changed. + You may override this method to handle the receipt event according to the application needs. + + You should not call this method directly. + You may override it in inherited 'MXKRoomDataSource' class. + + @param receiptEvent an event with 'm.receipt' type. + @param roomState the room state right before the event + */ +- (void)didReceiveReceiptEvent:(MXEvent *)receiptEvent roomState:(MXRoomState *)roomState; + +/** + Update read receipts for an event in a bubble cell data. + + @param cellData The cell data to update. + @param readReceipts The new read receipts. + @param eventId The id of the event. + */ +- (void)updateCellData:(MXKRoomBubbleCellData*)cellData withReadReceipts:(NSArray*)readReceipts forEventId:(NSString*)eventId; + +/** + Overridable method to customise the way how unsent messages are managed. + By default, they are added to the end of the timeline. + */ +- (void)handleUnsentMessages; + +#pragma mark - Asynchronous events processing +/** + The dispatch queue to process room messages. + + This processing can consume time. Handling it on a separated thread avoids to block the main thread. + All MXKRoomDataSource instances share the same dispatch queue. + */ ++ (dispatch_queue_t)processingQueue; + +#pragma mark - Bubble collapsing + +/** + Collapse or expand a series of collapsable bubbles. + + @param bubbleData the first bubble of the series. + @param collapsed YES to collapse. NO to expand. + */ +- (void)collapseRoomBubble:(id)bubbleData collapsed:(BOOL)collapsed; + +#pragma mark - Groups + +/** + Get a MXGroup instance for a group. + This method is used by the bubble to retrieve a related groups of the room. + + @param groupId The identifier to the group. + @return the MXGroup instance. + */ +- (MXGroup *)groupWithGroupId:(NSString*)groupId; + +#pragma mark - Reactions + +/** + Indicates if it's possible to react on the event. + + @param eventId The id of the event. + @return True to indicates reaction possibility for this event. + */ +- (BOOL)canReactToEventWithId:(NSString*)eventId; + +/** + Send a reaction to an event. + + @param reaction Reaction to add. + @param eventId The id of the event. + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)addReaction:(NSString *)reaction + forEventId:(NSString *)eventId + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + Unreact a reaction to an event. + + @param reaction Reaction to unreact. + @param eventId The id of the event. + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)removeReaction:(NSString *)reaction + forEventId:(NSString *)eventId + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +#pragma mark - Editions + +/** + Indicates if it's possible to edit the event content. + + @param eventId The id of the event. + @return True to indicates edition possibility for this event. + */ +- (BOOL)canEditEventWithId:(NSString*)eventId; + +/** + Replace a text in an event. + + @param eventId The eventId of event to replace. + @param text The new message text. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver. + @param failure A block object called when the operation fails. + */ +- (void)replaceTextMessageForEventWithId:(NSString *)eventId + withTextMessage:(NSString *)text + success:(void (^)(NSString *eventId))success + failure:(void (^)(NSError *error))failure; + + +/** + Update reactions for an event in a bubble cell data. + + @param cellData The cell data to update. + @param eventId The id of the event. + */ +- (void)updateCellDataReactions:(id)cellData forEventId:(NSString*)eventId; + +/** + Retrieve editable text message from an event. + + @param event An event. + @return Event text editable by user. + */ +- (NSString*)editableTextMessageForEvent:(MXEvent*)event; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m new file mode 100644 index 000000000..cfe7e1a31 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m @@ -0,0 +1,4127 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomDataSource.h" + +@import MatrixSDK; + +#import "MXKQueuedEvent.h" +#import "MXKRoomBubbleTableViewCell.h" + +#import "MXKRoomBubbleCellData.h" + +#import "MXKTools.h" +#import "MXAggregatedReactions+MatrixKit.h" + +#import "MXKAppSettings.h" + +#import "MXKSendReplyEventStringLocalizer.h" +#import "MXKSlashCommands.h" + + +#pragma mark - Constant definitions + +NSString *const kMXKRoomBubbleCellDataIdentifier = @"kMXKRoomBubbleCellDataIdentifier"; + +NSString *const kMXKRoomDataSourceSyncStatusChanged = @"kMXKRoomDataSourceSyncStatusChanged"; +NSString *const kMXKRoomDataSourceFailToLoadTimelinePosition = @"kMXKRoomDataSourceFailToLoadTimelinePosition"; +NSString *const kMXKRoomDataSourceTimelineError = @"kMXKRoomDataSourceTimelineError"; +NSString *const kMXKRoomDataSourceTimelineErrorErrorKey = @"kMXKRoomDataSourceTimelineErrorErrorKey"; + +NSString * const MXKRoomDataSourceErrorDomain = @"kMXKRoomDataSourceErrorDomain"; + +typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { + MXKRoomDataSourceErrorResendGeneric = 10001, + MXKRoomDataSourceErrorResendInvalidMessageType = 10002, + MXKRoomDataSourceErrorResendInvalidLocalFilePath = 10003, +}; + + +@interface MXKRoomDataSource () +{ + /** + If the data is not from a live timeline, `initialEventId` is the event in the past + where the timeline starts. + */ + NSString *initialEventId; + + /** + Current pagination request (if any) + */ + MXHTTPOperation *paginationRequest; + + /** + The actual listener related to the current pagination in the timeline. + */ + id paginationListener; + + /** + The listener to incoming events in the room. + */ + id liveEventsListener; + + /** + The listener to redaction events in the room. + */ + id redactionListener; + + /** + The listener to receipts events in the room. + */ + id receiptsListener; + + /** + The listener to the related groups state events in the room. + */ + id relatedGroupsListener; + + /** + The listener to reactions changed in the room. + */ + id reactionsChangeListener; + + /** + The listener to edits in the room. + */ + id eventEditsListener; + + /** + Current secondary pagination request (if any) + */ + MXHTTPOperation *secondaryPaginationRequest; + + /** + The listener to incoming events in the secondary room. + */ + id secondaryLiveEventsListener; + + /** + The listener to redaction events in the secondary room. + */ + id secondaryRedactionListener; + + /** + The actual listener related to the current pagination in the secondary timeline. + */ + id secondaryPaginationListener; + + /** + Mapping between events ids and bubbles. + */ + NSMutableDictionary *eventIdToBubbleMap; + + /** + Typing notifications listener. + */ + id typingNotifListener; + + /** + List of members who are typing in the room. + */ + NSArray *currentTypingUsers; + + /** + Snapshot of the queued events. + */ + NSMutableArray *eventsToProcessSnapshot; + + /** + Snapshot of the bubbles used during events processing. + */ + NSMutableArray> *bubblesSnapshot; + + /** + The room being peeked, if any. + */ + MXPeekingRoom *peekingRoom; + + /** + If any, the non terminated series of collapsable events at the start of self.bubbles. + (Such series is determined by the cell data of its oldest event). + */ + id collapsableSeriesAtStart; + + /** + If any, the non terminated series of collapsable events at the end of self.bubbles. + (Such series is determined by the cell data of its oldest event). + */ + id collapsableSeriesAtEnd; + + /** + Observe UIApplicationSignificantTimeChangeNotification to trigger cell change on time formatting change. + */ + id UIApplicationSignificantTimeChangeNotificationObserver; + + /** + Observe NSCurrentLocaleDidChangeNotification to trigger cell change on time formatting change. + */ + id NSCurrentLocaleDidChangeNotificationObserver; + + /** + Observe kMXRoomDidFlushDataNotification to trigger cell change when existing room history has been flushed during server sync. + */ + id roomDidFlushDataNotificationObserver; + + /** + Observe kMXRoomDidUpdateUnreadNotification to refresh unread counters. + */ + id roomDidUpdateUnreadNotificationObserver; + + /** + Emote slash command prefix @"/me " + */ + NSString *emoteMessageSlashCommandPrefix; +} + +/** + Indicate to stop back-paginating when finding an un-decryptable event as previous event. + It is used to hide pre join UTD events before joining the room. + */ +@property (nonatomic, assign) BOOL shouldPreventBackPaginationOnPreviousUTDEvent; + +/** + Indicate to stop back-paginating. + */ +@property (nonatomic, assign) BOOL shouldStopBackPagination; + +@property (nonatomic, readwrite) MXRoom *room; + +@property (nonatomic, readwrite) MXRoom *secondaryRoom; +@property (nonatomic, strong) MXEventTimeline *secondaryTimeline; + +@end + +@implementation MXKRoomDataSource + ++ (void)loadRoomDataSourceWithRoomId:(NSString*)roomId andMatrixSession:(MXSession*)mxSession onComplete:(void (^)(id roomDataSource))onComplete +{ + MXKRoomDataSource *roomDataSource = [[self alloc] initWithRoomId:roomId andMatrixSession:mxSession]; + [self ensureSessionStateForDataSource:roomDataSource initialEventId:nil andMatrixSession:mxSession onComplete:onComplete]; +} + ++ (void)loadRoomDataSourceWithRoomId:(NSString*)roomId initialEventId:(NSString*)initialEventId andMatrixSession:(MXSession*)mxSession onComplete:(void (^)(id roomDataSource))onComplete +{ + MXKRoomDataSource *roomDataSource = [[self alloc] initWithRoomId:roomId initialEventId:initialEventId andMatrixSession:mxSession]; + [self ensureSessionStateForDataSource:roomDataSource initialEventId:initialEventId andMatrixSession:mxSession onComplete:onComplete]; +} + ++ (void)loadRoomDataSourceWithPeekingRoom:(MXPeekingRoom*)peekingRoom andInitialEventId:(NSString*)initialEventId onComplete:(void (^)(id roomDataSource))onComplete +{ + MXKRoomDataSource *roomDataSource = [[self alloc] initWithPeekingRoom:peekingRoom andInitialEventId:initialEventId]; + [self finalizeRoomDataSource:roomDataSource onComplete:onComplete]; +} + +/// Ensure session state to be store data ready for the roomDataSource. ++ (void)ensureSessionStateForDataSource:(MXKRoomDataSource*)roomDataSource initialEventId:(NSString*)initialEventId andMatrixSession:(MXSession*)mxSession onComplete:(void (^)(id roomDataSource))onComplete +{ + // if store is not ready, roomDataSource.room will be nil. So onComplete block will never be called. + // In order to successfully fetch the room, we should wait for store to be ready. + if (mxSession.state >= MXSessionStateStoreDataReady) + { + [self finalizeRoomDataSource:roomDataSource onComplete:onComplete]; + } + else + { + // wait for session state to be store data ready + __block id sessionStateObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXSessionStateDidChangeNotification object:mxSession queue:nil usingBlock:^(NSNotification * _Nonnull note) { + if (mxSession.state >= MXSessionStateStoreDataReady) + { + [[NSNotificationCenter defaultCenter] removeObserver:sessionStateObserver]; + [self finalizeRoomDataSource:roomDataSource onComplete:onComplete]; + } + }]; + } +} + ++ (void)finalizeRoomDataSource:(MXKRoomDataSource*)roomDataSource onComplete:(void (^)(id roomDataSource))onComplete +{ + if (roomDataSource) + { + [roomDataSource finalizeInitialization]; + + // Asynchronously preload data here so that the data will be ready later + // to synchronously respond to that request + [roomDataSource.room liveTimeline:^(MXEventTimeline *liveTimeline) { + onComplete(roomDataSource); + }]; + } +} + +- (instancetype)initWithRoomId:(NSString *)roomId andMatrixSession:(MXSession *)matrixSession +{ + self = [super initWithMatrixSession:matrixSession]; + if (self) + { + MXLogVerbose(@"[MXKRoomDataSource][%p] initWithRoomId: %@", self, roomId); + + _roomId = roomId; + _secondaryRoomEventTypes = @[ + kMXEventTypeStringCallInvite, + kMXEventTypeStringCallCandidates, + kMXEventTypeStringCallAnswer, + kMXEventTypeStringCallSelectAnswer, + kMXEventTypeStringCallHangup, + kMXEventTypeStringCallReject, + kMXEventTypeStringCallNegotiate, + kMXEventTypeStringCallReplaces, + kMXEventTypeStringCallRejectReplacement + ]; + NSString *virtualRoomId = [matrixSession virtualRoomOf:_roomId]; + if (virtualRoomId) + { + _secondaryRoomId = virtualRoomId; + } + _isLive = YES; + bubbles = [NSMutableArray array]; + eventsToProcess = [NSMutableArray array]; + eventIdToBubbleMap = [NSMutableDictionary dictionary]; + + externalRelatedGroups = [NSMutableDictionary dictionary]; + + _filterMessagesWithURL = NO; + + emoteMessageSlashCommandPrefix = [NSString stringWithFormat:@"%@ ", kMXKSlashCmdEmote]; + + // Set default data and view classes + // Cell data + [self registerCellDataClass:MXKRoomBubbleCellData.class forCellIdentifier:kMXKRoomBubbleCellDataIdentifier]; + + // Set default MXEvent -> NSString formatter + self.eventFormatter = [[MXKEventFormatter alloc] initWithMatrixSession:self.mxSession]; + // Apply here the event types filter to display only the wanted event types. + self.eventFormatter.eventTypesFilterForMessages = [MXKAppSettings standardAppSettings].eventsFilterForMessages; + + // display the read receips by default + self.showBubbleReceipts = YES; + + // show the read marker by default + self.showReadMarker = YES; + + // Disable typing notification in cells by default. + self.showTypingNotifications = NO; + + self.useCustomDateTimeLabel = NO; + self.useCustomReceipts = NO; + self.useCustomUnsentButton = NO; + + _maxBackgroundCachedBubblesCount = MXKROOMDATASOURCE_CACHED_BUBBLES_COUNT_THRESHOLD; + _paginationLimitAroundInitialEvent = MXKROOMDATASOURCE_PAGINATION_LIMIT_AROUND_INITIAL_EVENT; + + // Observe UIApplicationSignificantTimeChangeNotification to refresh bubbles if date/time are shown. + // UIApplicationSignificantTimeChangeNotification is posted if DST is updated, carrier time is updated + UIApplicationSignificantTimeChangeNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationSignificantTimeChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + [self onDateTimeFormatUpdate]; + }]; + + // Observe NSCurrentLocaleDidChangeNotification to refresh bubbles if date/time are shown. + // NSCurrentLocaleDidChangeNotification is triggered when the time swicthes to AM/PM to 24h time format + NSCurrentLocaleDidChangeNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:NSCurrentLocaleDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + [self onDateTimeFormatUpdate]; + + }]; + + // Listen to the event sent state changes + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(eventDidChangeSentState:) name:kMXEventDidChangeSentStateNotification object:nil]; + // Listen to events decrypted + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(eventDidDecrypt:) name:kMXEventDidDecryptNotification object:nil]; + // Listen to virtual rooms change + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(virtualRoomsDidChange:) name:kMXSessionVirtualRoomsDidChangeNotification object:matrixSession]; + } + return self; +} + +- (instancetype)initWithRoomId:(NSString*)roomId initialEventId:(NSString*)initialEventId2 andMatrixSession:(MXSession*)mxSession +{ + self = [self initWithRoomId:roomId andMatrixSession:mxSession]; + if (self) + { + if (initialEventId2) + { + initialEventId = initialEventId2; + _isLive = NO; + } + } + + return self; +} + +- (instancetype)initWithPeekingRoom:(MXPeekingRoom*)peekingRoom2 andInitialEventId:(NSString*)theInitialEventId +{ + self = [self initWithRoomId:peekingRoom2.roomId initialEventId:theInitialEventId andMatrixSession:peekingRoom2.mxSession]; + if (self) + { + peekingRoom = peekingRoom2; + _isPeeking = YES; + } + return self; +} + +- (void)dealloc +{ + [self unregisterEventEditsListener]; + [self unregisterScanManagerNotifications]; + [self unregisterReactionsChangeListener]; +} + +- (MXRoomState *)roomState +{ + // @TODO(async-state): Just here for dev + NSAssert(_timeline.state, @"[MXKRoomDataSource] Room state must be preloaded before accessing to MXKRoomDataSource.roomState"); + return _timeline.state; +} + +- (void)onDateTimeFormatUpdate +{ + // update the date and the time formatters + [self.eventFormatter initDateTimeFormatters]; + + // refresh the UI if it is required + if (self.showBubblesDateTime && self.delegate) + { + // Reload all the table + [self.delegate dataSource:self didCellChange:nil]; + } +} + +- (void)markAllAsRead +{ + [_room.summary markAllAsRead]; +} + +- (void)limitMemoryUsage:(NSInteger)maxBubbleNb +{ + NSInteger bubbleCount; + @synchronized(bubbles) + { + bubbleCount = bubbles.count; + } + + if (bubbleCount > maxBubbleNb) + { + // Do nothing if some local echoes are in progress. + NSArray* outgoingMessages = _room.outgoingMessages; + + for (NSInteger index = 0; index < outgoingMessages.count; index++) + { + MXEvent *outgoingMessage = [outgoingMessages objectAtIndex:index]; + + if (outgoingMessage.sentState == MXEventSentStateSending || + outgoingMessage.sentState == MXEventSentStatePreparing || + outgoingMessage.sentState == MXEventSentStateEncrypting || + outgoingMessage.sentState == MXEventSentStateUploading) + { + MXLogDebug(@"[MXKRoomDataSource][%p] cancel limitMemoryUsage because some messages are being sent", self); + return; + } + } + + // Reset the room data source (return in initial state: minimum memory usage). + [self reload]; + } +} + +- (void)reset +{ + [self resetNotifying:YES]; +} + +- (void)resetNotifying:(BOOL)notify +{ + [externalRelatedGroups removeAllObjects]; + + if (roomDidFlushDataNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:roomDidFlushDataNotificationObserver]; + roomDidFlushDataNotificationObserver = nil; + } + + if (roomDidUpdateUnreadNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:roomDidUpdateUnreadNotificationObserver]; + roomDidUpdateUnreadNotificationObserver = nil; + } + + if (paginationRequest) + { + // We have to remove here the listener. A new pagination request may be triggered whereas the cancellation of this one is in progress + [_timeline removeListener:paginationListener]; + paginationListener = nil; + + [paginationRequest cancel]; + paginationRequest = nil; + } + + if (secondaryPaginationRequest) + { + // We have to remove here the listener. A new pagination request may be triggered whereas the cancellation of this one is in progress + [_secondaryTimeline removeListener:secondaryPaginationListener]; + secondaryPaginationListener = nil; + + [secondaryPaginationRequest cancel]; + secondaryPaginationRequest = nil; + } + + if (_room && liveEventsListener) + { + [_timeline removeListener:liveEventsListener]; + liveEventsListener = nil; + + [_timeline removeListener:redactionListener]; + redactionListener = nil; + + [_timeline removeListener:receiptsListener]; + receiptsListener = nil; + + [_timeline removeListener:relatedGroupsListener]; + relatedGroupsListener = nil; + } + + if (_secondaryRoom && secondaryLiveEventsListener) + { + [_secondaryTimeline removeListener:secondaryLiveEventsListener]; + secondaryLiveEventsListener = nil; + + [_secondaryTimeline removeListener:secondaryRedactionListener]; + secondaryRedactionListener = nil; + } + + if (_room && typingNotifListener) + { + [_timeline removeListener:typingNotifListener]; + typingNotifListener = nil; + } + currentTypingUsers = nil; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXRoomInitialSyncNotification object:nil]; + + @synchronized(eventsToProcess) + { + MXLogVerbose(@"[MXKRoomDataSource][%p] Reset eventsToProcess", self); + [eventsToProcess removeAllObjects]; + } + + // Suspend the reset operation if some events is under processing + @synchronized(eventsToProcessSnapshot) + { + eventsToProcessSnapshot = nil; + bubblesSnapshot = nil; + + @synchronized(bubbles) + { + for (id bubble in bubbles) { + bubble.prevCollapsableCellData = nil; + bubble.nextCollapsableCellData = nil; + } + [bubbles removeAllObjects]; + } + + @synchronized(eventIdToBubbleMap) + { + [eventIdToBubbleMap removeAllObjects]; + } + + self.room = nil; + self.secondaryRoom = nil; + } + + _serverSyncEventCount = 0; + + // Notify the delegate to reload its tableview + if (notify && self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } +} + +- (void)reload +{ + [self reloadNotifying:YES]; +} + +- (void)reloadNotifying:(BOOL)notify +{ + MXLogVerbose(@"[MXKRoomDataSource][%p] Reload - room id: %@", self, _roomId); + + [self setState:MXKDataSourceStatePreparing]; + + [self resetNotifying:notify]; + + // Reload + [self didMXSessionStateChange]; +} + +- (void)destroy +{ + MXLogDebug(@"[MXKRoomDataSource][%p] Destroy - room id: %@", self, _roomId); + + [self unregisterScanManagerNotifications]; + [self unregisterReactionsChangeListener]; + [self unregisterEventEditsListener]; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidUpdatePublicisedGroupsForUsersNotification object:self.mxSession]; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidChangeSentStateNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidDecryptNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidChangeIdentifierNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionVirtualRoomsDidChangeNotification object:nil]; + + if (NSCurrentLocaleDidChangeNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:NSCurrentLocaleDidChangeNotificationObserver]; + NSCurrentLocaleDidChangeNotificationObserver = nil; + } + + if (UIApplicationSignificantTimeChangeNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:UIApplicationSignificantTimeChangeNotificationObserver]; + UIApplicationSignificantTimeChangeNotificationObserver = nil; + } + + // If the room data source was used to peek into a room, stop the events stream on this room + if (peekingRoom) + { + [_room.mxSession stopPeeking:peekingRoom]; + } + + [self reset]; + + self.eventFormatter = nil; + + eventsToProcess = nil; + bubbles = nil; + eventIdToBubbleMap = nil; + + [_timeline destroy]; + [_secondaryTimeline destroy]; + + externalRelatedGroups = nil; + + [super destroy]; +} + +- (void)didMXSessionStateChange +{ + if (MXSessionStateStoreDataReady <= self.mxSession.state) + { + // Check whether the room is not already set + if (!_room) + { + // Are we peeking into a random room or displaying a room the user is part of? + if (peekingRoom) + { + self.room = peekingRoom; + } + else + { + self.room = [self.mxSession roomWithRoomId:_roomId]; + } + + if (_room) + { + // This is the time to set up the timeline according to the called init method + if (_isLive) + { + // LIVE + MXWeakify(self); + [_room liveTimeline:^(MXEventTimeline *liveTimeline) { + MXStrongifyAndReturnIfNil(self); + + self->_timeline = liveTimeline; + + // Only one pagination process can be done at a time by an MXRoom object. + // This assumption is satisfied by MatrixKit. Only MXRoomDataSource does it. + [self.timeline resetPagination]; + + // Observe room history flush (sync with limited timeline, or state event redaction) + self->roomDidFlushDataNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXRoomDidFlushDataNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXRoom *room = notif.object; + if (self.mxSession == room.mxSession && ([self.roomId isEqualToString:room.roomId] || + ([self.secondaryRoomId isEqualToString:room.roomId]))) + { + // The existing room history has been flushed during server sync because a gap has been observed between local and server storage. + [self reload]; + } + + }]; + + // Add the event listeners, by considering all the event types (the event filtering is applying by the event formatter), + // except if only the events with a url key in their content must be handled. + [self refreshEventListeners:(self.filterMessagesWithURL ? @[kMXEventTypeStringRoomMessage] : [MXKAppSettings standardAppSettings].allEventTypesForMessages)]; + + // display typing notifications is optional + // the inherited class can manage them by its own. + if (self.showTypingNotifications) + { + // Register on typing notif + [self listenTypingNotifications]; + } + + // Manage unsent messages + [self handleUnsentMessages]; + + // Update here data source state if it is not already ready + if (!self->_secondaryRoomId) + { + [self setState:MXKDataSourceStateReady]; + } + + // Check user membership in this room + MXMembership membership = self.room.summary.membership; + if (membership == MXMembershipUnknown || membership == MXMembershipInvite) + { + // Here the initial sync is not ended or the room is a pending invitation. + // Note: In case of invitation, a full sync will be triggered if the user joins this room. + + // We have to observe here 'kMXRoomInitialSyncNotification' to reload room data when room sync is done. + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXRoomInitialSynced:) name:kMXRoomInitialSyncNotification object:self.room]; + } + }]; + + if (!_secondaryRoom && _secondaryRoomId) + { + _secondaryRoom = [self.mxSession roomWithRoomId:_secondaryRoomId]; + + if (_secondaryRoom) + { + MXWeakify(self); + [_secondaryRoom liveTimeline:^(MXEventTimeline *liveTimeline) { + MXStrongifyAndReturnIfNil(self); + + self->_secondaryTimeline = liveTimeline; + + // Only one pagination process can be done at a time by an MXRoom object. + // This assumption is satisfied by MatrixKit. Only MXRoomDataSource does it. + [self.secondaryTimeline resetPagination]; + + // Add the secondary event listeners, by considering the event types in self.secondaryRoomEventTypes + [self refreshSecondaryEventListeners:self.secondaryRoomEventTypes]; + + // Update here data source state if it is not already ready + [self setState:MXKDataSourceStateReady]; + + // Check user membership in the secondary room + MXMembership membership = self.secondaryRoom.summary.membership; + if (membership == MXMembershipUnknown || membership == MXMembershipInvite) + { + // Here the initial sync is not ended or the room is a pending invitation. + // Note: In case of invitation, a full sync will be triggered if the user joins this room. + + // We have to observe here 'kMXRoomInitialSyncNotification' to reload room data when room sync is done. + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXRoomInitialSynced:) name:kMXRoomInitialSyncNotification object:self.secondaryRoom]; + } + }]; + } + } + } + else + { + // Past timeline + // Less things need to configured + _timeline = [_room timelineOnEvent:initialEventId]; + + // Refresh the event listeners. Note: events for past timelines come only from pagination request + [self refreshEventListeners:nil]; + + MXWeakify(self); + + // Preload the state and some messages around the initial event + [_timeline resetPaginationAroundInitialEventWithLimit:_paginationLimitAroundInitialEvent success:^{ + + MXStrongifyAndReturnIfNil(self); + + // Do a "classic" reset. The room view controller will paginate + // from the events stored in the timeline store + [self.timeline resetPagination]; + + // Update here data source state if it is not already ready + [self setState:MXKDataSourceStateReady]; + + } failure:^(NSError *error) { + + MXStrongifyAndReturnIfNil(self); + + MXLogDebug(@"[MXKRoomDataSource][%p] Failed to resetPaginationAroundInitialEventWithLimit", self); + + // Notify the error + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKRoomDataSourceTimelineError + object:self + userInfo:@{ + kMXKRoomDataSourceTimelineErrorErrorKey: error + }]; + }]; + } + } + else + { + MXLogDebug(@"[MXKRoomDataSource][%p] Warning: The user does not know the room %@", self, _roomId); + + // Update here data source state if it is not already ready + [self setState:MXKDataSourceStateFailed]; + } + } + + if (_room && MXSessionStateRunning == self.mxSession.state) + { + // Flair handling: observe the update in the publicised groups by users when the flair is enabled in the room. + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidUpdatePublicisedGroupsForUsersNotification object:self.mxSession]; + [self.room state:^(MXRoomState *roomState) { + if (roomState.relatedGroups.count) + { + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXSessionUpdatePublicisedGroupsForUsers:) name:kMXSessionDidUpdatePublicisedGroupsForUsersNotification object:self.mxSession]; + + // Get a fresh profile for all the related groups. Trigger a table refresh when all requests are done. + __block NSUInteger count = roomState.relatedGroups.count; + for (NSString *groupId in roomState.relatedGroups) + { + MXGroup *group = [self.mxSession groupWithGroupId:groupId]; + if (!group) + { + // Create a group instance for the groups that the current user did not join. + group = [[MXGroup alloc] initWithGroupId:groupId]; + [self->externalRelatedGroups setObject:group forKey:groupId]; + } + + // Refresh the group profile from server. + [self.mxSession updateGroupProfile:group success:^{ + + if (self.delegate && !(--count)) + { + // All the requests have been done. + [self.delegate dataSource:self didCellChange:nil]; + } + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomDataSource][%p] group profile update failed %@", self, groupId); + + if (self.delegate && !(--count)) + { + // All the requests have been done. + [self.delegate dataSource:self didCellChange:nil]; + } + + }]; + } + } + }]; + } + } +} + +- (NSArray *)attachmentsWithThumbnail +{ + NSMutableArray *attachments = [NSMutableArray array]; + + @synchronized(bubbles) + { + for (id bubbleData in bubbles) + { + if (bubbleData.isAttachmentWithThumbnail && bubbleData.attachment.type != MXKAttachmentTypeSticker && !bubbleData.showAntivirusScanStatus) + { + [attachments addObject:bubbleData.attachment]; + } + } + } + + return attachments; +} + +- (NSString *)partialTextMessage +{ + return _room.partialTextMessage; +} + +- (void)setPartialTextMessage:(NSString *)partialTextMessage +{ + _room.partialTextMessage = partialTextMessage; +} + +- (void)refreshEventListeners:(NSArray *)liveEventTypesFilterForMessages +{ + // Remove the existing listeners + if (liveEventsListener) + { + [_timeline removeListener:liveEventsListener]; + [_timeline removeListener:redactionListener]; + [_timeline removeListener:receiptsListener]; + [_timeline removeListener:relatedGroupsListener]; + } + + // Listen to live events only for live timeline + // Events for past timelines come only from pagination request + if (_isLive) + { + // Register a new one with the requested filter + MXWeakify(self); + liveEventsListener = [_timeline listenToEventsOfTypes:liveEventTypesFilterForMessages onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + + MXStrongifyAndReturnIfNil(self); + + if (MXTimelineDirectionForwards == direction) + { + // Check for local echo suppression + MXEvent *localEcho; + if (self.room.outgoingMessages.count && [event.sender isEqualToString:self.mxSession.myUser.userId]) + { + localEcho = [self.room pendingLocalEchoRelatedToEvent:event]; + if (localEcho) + { + // Check whether the local echo has a timestamp (in this case, it is replaced with the actual event). + if (localEcho.originServerTs != kMXUndefinedTimestamp) + { + // Replace the local echo by the true event sent by the homeserver + [self replaceEvent:localEcho withEvent:event]; + } + else + { + // Remove the local echo, and process independently the true event. + [self replaceEvent:localEcho withEvent:nil]; + localEcho = nil; + } + } + } + + if (self.secondaryRoom) + { + [self reloadNotifying:NO]; + } + else if (nil == localEcho) + { + // Process here incoming events, and outgoing events sent from another device. + [self queueEventForProcessing:event withRoomState:roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } + } + }]; + + receiptsListener = [_timeline listenToEventsOfTypes:@[kMXEventTypeStringReceipt] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + + if (MXTimelineDirectionForwards == direction) + { + // Handle this read receipt + [self didReceiveReceiptEvent:event roomState:roomState]; + } + }]; + + // Flair handling: register a listener for the related groups state event in this room. + relatedGroupsListener = [_timeline listenToEventsOfTypes:@[kMXEventTypeStringRoomRelatedGroups] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + + if (MXTimelineDirectionForwards == direction) + { + // The flair settings have been updated: flush the current bubble data and rebuild them. + [self reload]; + } + }]; + } + + // Register a listener to handle redaction which can affect live and past timelines + redactionListener = [_timeline listenToEventsOfTypes:@[kMXEventTypeStringRoomRedaction] onEvent:^(MXEvent *redactionEvent, MXTimelineDirection direction, MXRoomState *roomState) { + + // Consider only live redaction events + if (direction == MXTimelineDirectionForwards) + { + // Do the processing on the processing queue + dispatch_async(MXKRoomDataSource.processingQueue, ^{ + + // Check whether a message contains the redacted event + id bubbleData = [self cellDataOfEventWithEventId:redactionEvent.redacts]; + if (bubbleData) + { + BOOL shouldRemoveBubbleData = NO; + BOOL hasChanged = NO; + MXEvent *redactedEvent = nil; + + @synchronized (bubbleData) + { + // Retrieve the original event to redact it + NSArray *events = bubbleData.events; + + for (MXEvent *event in events) + { + if ([event.eventId isEqualToString:redactionEvent.redacts]) + { + // Check whether the event was not already redacted (Redaction may be handled by event timeline too). + if (!event.isRedactedEvent) + { + redactedEvent = [event prune]; + redactedEvent.redactedBecause = redactionEvent.JSONDictionary; + } + + break; + } + } + + if (redactedEvent) + { + // Update bubble data + NSUInteger remainingEvents = [bubbleData updateEvent:redactionEvent.redacts withEvent:redactedEvent]; + + hasChanged = YES; + + // Remove the bubble if there is no more events + shouldRemoveBubbleData = (remainingEvents == 0); + } + } + + // Check whether the bubble should be removed + if (shouldRemoveBubbleData) + { + [self removeCellData:bubbleData]; + } + + if (hasChanged) + { + // Update the delegate on main thread + dispatch_async(dispatch_get_main_queue(), ^{ + + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } + + }); + } + } + + }); + } + }]; +} + +- (void)refreshSecondaryEventListeners:(NSArray *)liveEventTypesFilterForMessages +{ + // Remove the existing listeners + if (secondaryLiveEventsListener) + { + [_secondaryTimeline removeListener:secondaryLiveEventsListener]; + [_secondaryTimeline removeListener:secondaryRedactionListener]; + } + + // Listen to live events only for live timeline + // Events for past timelines come only from pagination request + if (_isLive) + { + // Register a new one with the requested filter + MXWeakify(self); + secondaryLiveEventsListener = [_secondaryTimeline listenToEventsOfTypes:liveEventTypesFilterForMessages onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + + MXStrongifyAndReturnIfNil(self); + + if (MXTimelineDirectionForwards == direction) + { + // Check for local echo suppression + MXEvent *localEcho; + if (self.secondaryRoom.outgoingMessages.count && [event.sender isEqualToString:self.mxSession.myUserId]) + { + localEcho = [self.secondaryRoom pendingLocalEchoRelatedToEvent:event]; + if (localEcho) + { + // Check whether the local echo has a timestamp (in this case, it is replaced with the actual event). + if (localEcho.originServerTs != kMXUndefinedTimestamp) + { + // Replace the local echo by the true event sent by the homeserver + [self replaceEvent:localEcho withEvent:event]; + } + else + { + // Remove the local echo, and process independently the true event. + [self replaceEvent:localEcho withEvent:nil]; + localEcho = nil; + } + } + } + + if (nil == localEcho) + { + // Process here incoming events, and outgoing events sent from another device. + [self queueEventForProcessing:event withRoomState:roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } + } + }]; + + } + + // Register a listener to handle redaction which can affect live and past timelines + secondaryRedactionListener = [_secondaryTimeline listenToEventsOfTypes:@[kMXEventTypeStringRoomRedaction] onEvent:^(MXEvent *redactionEvent, MXTimelineDirection direction, MXRoomState *roomState) { + + // Consider only live redaction events + if (direction == MXTimelineDirectionForwards) + { + // Do the processing on the processing queue + dispatch_async(MXKRoomDataSource.processingQueue, ^{ + + // Check whether a message contains the redacted event + id bubbleData = [self cellDataOfEventWithEventId:redactionEvent.redacts]; + if (bubbleData) + { + BOOL shouldRemoveBubbleData = NO; + BOOL hasChanged = NO; + MXEvent *redactedEvent = nil; + + @synchronized (bubbleData) + { + // Retrieve the original event to redact it + NSArray *events = bubbleData.events; + + for (MXEvent *event in events) + { + if ([event.eventId isEqualToString:redactionEvent.redacts]) + { + // Check whether the event was not already redacted (Redaction may be handled by event timeline too). + if (!event.isRedactedEvent) + { + redactedEvent = [event prune]; + redactedEvent.redactedBecause = redactionEvent.JSONDictionary; + } + + break; + } + } + + if (redactedEvent) + { + // Update bubble data + NSUInteger remainingEvents = [bubbleData updateEvent:redactionEvent.redacts withEvent:redactedEvent]; + + hasChanged = YES; + + // Remove the bubble if there is no more events + shouldRemoveBubbleData = (remainingEvents == 0); + } + } + + // Check whether the bubble should be removed + if (shouldRemoveBubbleData) + { + [self removeCellData:bubbleData]; + } + + if (hasChanged) + { + // Update the delegate on main thread + dispatch_async(dispatch_get_main_queue(), ^{ + + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } + + }); + } + } + + }); + } + }]; +} + +- (void)setFilterMessagesWithURL:(BOOL)filterMessagesWithURL +{ + _filterMessagesWithURL = filterMessagesWithURL; + + if (_isLive && _room) + { + // Update the event listeners by considering the right types for the live events. + [self refreshEventListeners:(_filterMessagesWithURL ? @[kMXEventTypeStringRoomMessage] : [MXKAppSettings standardAppSettings].allEventTypesForMessages)]; + } +} + +- (void)setEventFormatter:(MXKEventFormatter *)eventFormatter +{ + if (_eventFormatter) + { + // Remove observers on previous event formatter settings + [_eventFormatter.settings removeObserver:self forKeyPath:@"showRedactionsInRoomHistory"]; + [_eventFormatter.settings removeObserver:self forKeyPath:@"showUnsupportedEventsInRoomHistory"]; + } + + _eventFormatter = eventFormatter; + + if (_eventFormatter) + { + // Add observer to flush stored data on settings changes + [_eventFormatter.settings addObserver:self forKeyPath:@"showRedactionsInRoomHistory" options:0 context:nil]; + [_eventFormatter.settings addObserver:self forKeyPath:@"showUnsupportedEventsInRoomHistory" options:0 context:nil]; + } +} + +- (void)setShowBubblesDateTime:(BOOL)showBubblesDateTime +{ + _showBubblesDateTime = showBubblesDateTime; + + if (self.delegate) + { + // Reload all the table + [self.delegate dataSource:self didCellChange:nil]; + } +} + +- (void)setShowTypingNotifications:(BOOL)shouldShowTypingNotifications +{ + _showTypingNotifications = shouldShowTypingNotifications; + + if (shouldShowTypingNotifications) + { + // Register on typing notif + [self listenTypingNotifications]; + } + else + { + // Remove the live listener + if (typingNotifListener) + { + [_timeline removeListener:typingNotifListener]; + currentTypingUsers = nil; + typingNotifListener = nil; + } + } +} + +- (void)listenTypingNotifications +{ + // Remove the previous live listener + if (typingNotifListener) + { + [_timeline removeListener:typingNotifListener]; + currentTypingUsers = nil; + } + + // Add typing notification listener + MXWeakify(self); + + typingNotifListener = [_timeline listenToEventsOfTypes:@[kMXEventTypeStringTypingNotification] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) + { + MXStrongifyAndReturnIfNil(self); + + // Handle only live events + if (direction == MXTimelineDirectionForwards) + { + // Retrieve typing users list + NSMutableArray *typingUsers = [NSMutableArray arrayWithArray:self.room.typingUsers]; + + // Remove typing info for the current user + NSUInteger index = [typingUsers indexOfObject:self.mxSession.myUser.userId]; + if (index != NSNotFound) + { + [typingUsers removeObjectAtIndex:index]; + } + // Ignore this notification if both arrays are empty + if (self->currentTypingUsers.count || typingUsers.count) + { + self->currentTypingUsers = typingUsers; + + if (self.delegate) + { + // refresh all the table + [self.delegate dataSource:self didCellChange:nil]; + } + } + } + }]; + + currentTypingUsers = _room.typingUsers; +} + +- (void)cancelAllRequests +{ + if (paginationRequest) + { + // We have to remove here the listener. A new pagination request may be triggered whereas the cancellation of this one is in progress + [_timeline removeListener:paginationListener]; + paginationListener = nil; + + [paginationRequest cancel]; + paginationRequest = nil; + } + + [super cancelAllRequests]; +} + +- (void)setDelegate:(id)delegate +{ + super.delegate = delegate; + + // Register to MXScanManager notification only when a delegate is set + if (delegate && self.mxSession.scanManager) + { + [self registerScanManagerNotifications]; + } + + // Register to reaction notification only when a delegate is set + if (delegate) + { + [self registerReactionsChangeListener]; + [self registerEventEditsListener]; + } +} + +- (void)setRoom:(MXRoom *)room +{ + if (![_room isEqual:room]) + { + _room = room; + + [self roomDidSet]; + } +} + +- (void)roomDidSet +{ + +} + +#pragma mark - KVO + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context +{ + if ([@"showRedactionsInRoomHistory" isEqualToString:keyPath] || [@"showUnsupportedEventsInRoomHistory" isEqualToString:keyPath]) + { + // Flush the current bubble data and rebuild them + [self reload]; + } +} + +#pragma mark - Public methods +- (id)cellDataAtIndex:(NSInteger)index +{ + id bubbleData; + @synchronized(bubbles) + { + if (index < bubbles.count) + { + bubbleData = bubbles[index]; + } + } + return bubbleData; +} + +- (id)cellDataOfEventWithEventId:(NSString *)eventId +{ + id bubbleData; + @synchronized(eventIdToBubbleMap) + { + bubbleData = eventIdToBubbleMap[eventId]; + } + return bubbleData; +} + +- (NSInteger)indexOfCellDataWithEventId:(NSString *)eventId +{ + NSInteger index = NSNotFound; + + id bubbleData; + @synchronized(eventIdToBubbleMap) + { + bubbleData = eventIdToBubbleMap[eventId]; + } + + if (bubbleData) + { + @synchronized(bubbles) + { + index = [bubbles indexOfObject:bubbleData]; + } + } + + return index; +} + +- (CGFloat)cellHeightAtIndex:(NSInteger)index withMaximumWidth:(CGFloat)maxWidth +{ + id bubbleData = [self cellDataAtIndex:index]; + + // Sanity check + if (bubbleData && self.delegate) + { + // Compute here height of bubble cell + Class cellViewClass = [self.delegate cellViewClassForCellData:bubbleData]; + return [cellViewClass heightForCellData:bubbleData withMaximumWidth:maxWidth]; + } + + return 0; +} + +- (void)invalidateBubblesCellDataCache +{ + @synchronized(bubbles) + { + for (id bubble in bubbles) + { + [bubble invalidateTextLayout]; + } + } +} + +#pragma mark - Pagination +- (void)paginate:(NSUInteger)numItems direction:(MXTimelineDirection)direction onlyFromStore:(BOOL)onlyFromStore success:(void (^)(NSUInteger addedCellNumber))success failure:(void (^)(NSError *error))failure +{ + // Check the current data source state, and the actual user membership for this room. + if (state != MXKDataSourceStateReady || ((self.room.summary.membership == MXMembershipUnknown || self.room.summary.membership == MXMembershipInvite) && ![self.roomState.historyVisibility isEqualToString:kMXRoomHistoryVisibilityWorldReadable])) + { + // Back pagination is not available here. + if (failure) + { + failure(nil); + } + return; + } + + if (paginationRequest || secondaryPaginationRequest) + { + MXLogDebug(@"[MXKRoomDataSource][%p] paginate: a pagination is already in progress", self); + if (failure) + { + failure(nil); + } + return; + } + + if (NO == [self canPaginate:direction]) + { + MXLogDebug(@"[MXKRoomDataSource][%p] paginate: No more events to paginate", self); + if (success) + { + success(0); + } + } + + __block NSUInteger addedCellNb = 0; + __block NSMutableArray *operationErrors = [NSMutableArray arrayWithCapacity:2]; + dispatch_group_t dispatchGroup = dispatch_group_create(); + + // Define a new listener for this pagination + paginationListener = [_timeline listenToEventsOfTypes:(_filterMessagesWithURL ? @[kMXEventTypeStringRoomMessage] : [MXKAppSettings standardAppSettings].allEventTypesForMessages) onEvent:^(MXEvent *event, MXTimelineDirection direction2, MXRoomState *roomState) { + + if (direction2 == direction) + { + [self queueEventForProcessing:event withRoomState:roomState direction:direction]; + } + + }]; + + // Keep a local reference to this listener. + id localPaginationListenerRef = paginationListener; + + dispatch_group_enter(dispatchGroup); + // Launch the pagination + + MXWeakify(self); + paginationRequest = [_timeline paginate:numItems direction:direction onlyFromStore:onlyFromStore complete:^{ + + MXStrongifyAndReturnIfNil(self); + + // Everything went well, remove the listener + self->paginationRequest = nil; + [self.timeline removeListener:self->paginationListener]; + self->paginationListener = nil; + + // Once done, process retrieved events + [self processQueuedEvents:^(NSUInteger addedHistoryCellNb, NSUInteger addedLiveCellNb) { + + addedCellNb += (direction == MXTimelineDirectionBackwards) ? addedHistoryCellNb : addedLiveCellNb; + dispatch_group_leave(dispatchGroup); + + }]; + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomDataSource][%p] paginateBackMessages fails", self); + + MXStrongifyAndReturnIfNil(self); + + // Something wrong happened or the request was cancelled. + // Check whether the request is the actual one before removing listener and handling the retrieved events. + if (localPaginationListenerRef == self->paginationListener) + { + self->paginationRequest = nil; + [self.timeline removeListener:self->paginationListener]; + self->paginationListener = nil; + + // Process at least events retrieved from store + [self processQueuedEvents:^(NSUInteger addedHistoryCellNb, NSUInteger addedLiveCellNb) { + + [operationErrors addObject:error]; + if (addedHistoryCellNb) + { + addedCellNb += addedHistoryCellNb; + } + dispatch_group_leave(dispatchGroup); + + }]; + } + + }]; + + if (_secondaryTimeline) + { + // Define a new listener for this pagination + secondaryPaginationListener = [_secondaryTimeline listenToEventsOfTypes:_secondaryRoomEventTypes onEvent:^(MXEvent *event, MXTimelineDirection direction2, MXRoomState *roomState) { + + if (direction2 == direction) + { + [self queueEventForProcessing:event withRoomState:roomState direction:direction]; + } + + }]; + + // Keep a local reference to this listener. + id localPaginationListenerRef = secondaryPaginationListener; + + dispatch_group_enter(dispatchGroup); + // Launch the pagination + MXWeakify(self); + secondaryPaginationRequest = [_secondaryTimeline paginate:numItems direction:direction onlyFromStore:onlyFromStore complete:^{ + + MXStrongifyAndReturnIfNil(self); + + // Everything went well, remove the listener + self->secondaryPaginationRequest = nil; + [self.secondaryTimeline removeListener:self->secondaryPaginationListener]; + self->secondaryPaginationListener = nil; + + // Once done, process retrieved events + [self processQueuedEvents:^(NSUInteger addedHistoryCellNb, NSUInteger addedLiveCellNb) { + + addedCellNb += (direction == MXTimelineDirectionBackwards) ? addedHistoryCellNb : addedLiveCellNb; + dispatch_group_leave(dispatchGroup); + + }]; + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomDataSource][%p] paginateBackMessages fails", self); + + MXStrongifyAndReturnIfNil(self); + + // Something wrong happened or the request was cancelled. + // Check whether the request is the actual one before removing listener and handling the retrieved events. + if (localPaginationListenerRef == self->secondaryPaginationListener) + { + self->secondaryPaginationRequest = nil; + [self.secondaryTimeline removeListener:self->secondaryPaginationListener]; + self->secondaryPaginationListener = nil; + + // Process at least events retrieved from store + [self processQueuedEvents:^(NSUInteger addedHistoryCellNb, NSUInteger addedLiveCellNb) { + + [operationErrors addObject:error]; + if (addedHistoryCellNb) + { + addedCellNb += addedHistoryCellNb; + } + dispatch_group_leave(dispatchGroup); + + }]; + } + + }]; + } + + dispatch_group_notify(dispatchGroup, dispatch_get_main_queue(), ^{ + if (operationErrors.count) + { + if (failure) + { + failure(operationErrors.firstObject); + } + } + else + { + if (success) + { + success(addedCellNb); + } + } + }); +} + +- (void)paginateToFillRect:(CGRect)rect direction:(MXTimelineDirection)direction withMinRequestMessagesCount:(NSUInteger)minRequestMessagesCount success:(void (^)(void))success failure:(void (^)(NSError *error))failure +{ + MXLogDebug(@"[MXKRoomDataSource][%p] paginateToFillRect: %@", self, NSStringFromCGRect(rect)); + + // During the first call of this method, the delegate is supposed defined. + // This delegate may be removed whereas this method is called by itself after a pagination request. + // The delegate is required here to be able to compute cell height (and prevent infinite loop in case of reentrancy). + if (!self.delegate) + { + MXLogDebug(@"[MXKRoomDataSource][%p] paginateToFillRect ignored (delegate is undefined)", self); + if (failure) + { + failure(nil); + } + return; + } + + // Get the total height of cells already loaded in memory + CGFloat minMessageHeight = CGFLOAT_MAX; + CGFloat bubblesTotalHeight = 0; + + @synchronized(bubbles) + { + // Check whether data has been aldready loaded + if (bubbles.count) + { + NSUInteger eventsCount = 0; + for (NSInteger i = bubbles.count - 1; i >= 0; i--) + { + id bubbleData = bubbles[i]; + eventsCount += bubbleData.events.count; + + CGFloat bubbleHeight = [self cellHeightAtIndex:i withMaximumWidth:rect.size.width]; + // Sanity check + if (bubbleHeight) + { + bubblesTotalHeight += bubbleHeight; + + if (bubblesTotalHeight > rect.size.height) + { + // No need to compute more cells heights, there are enough to fill the rect + MXLogDebug(@"[MXKRoomDataSource][%p] -> %tu already loaded bubbles (%tu events) are enough to fill the screen", self, bubbles.count - i, eventsCount); + break; + } + + // Compute the minimal height an event takes + minMessageHeight = MIN(minMessageHeight, bubbleHeight / bubbleData.events.count); + } + } + } + else if (minRequestMessagesCount && [self canPaginate:direction]) + { + MXLogDebug(@"[MXKRoomDataSource][%p] paginateToFillRect: Prefill with data from the store", self); + // Give a chance to load data from the store before doing homeserver requests + // Reuse minRequestMessagesCount because we need to provide a number. + [self paginate:minRequestMessagesCount direction:direction onlyFromStore:YES success:^(NSUInteger addedCellNumber) { + + // Then retry + [self paginateToFillRect:rect direction:direction withMinRequestMessagesCount:minRequestMessagesCount success:success failure:failure]; + + } failure:failure]; + return; + } + } + + // Is there enough cells to cover all the requested height? + if (bubblesTotalHeight < rect.size.height) + { + // No. Paginate to get more messages + if ([self canPaginate:direction]) + { + // Bound the minimal height to 44 + minMessageHeight = MIN(minMessageHeight, 44); + + // Load messages to cover the remaining height + // Use an extra of 50% to manage unsupported/unexpected/redated events + NSUInteger messagesToLoad = ceil((rect.size.height - bubblesTotalHeight) / minMessageHeight * 1.5); + + // It does not worth to make a pagination request for only 1 message. + // So, use minRequestMessagesCount + messagesToLoad = MAX(messagesToLoad, minRequestMessagesCount); + + MXLogDebug(@"[MXKRoomDataSource][%p] paginateToFillRect: need to paginate %tu events to cover %fpx", self, messagesToLoad, rect.size.height - bubblesTotalHeight); + [self paginate:messagesToLoad direction:direction onlyFromStore:NO success:^(NSUInteger addedCellNumber) { + + [self paginateToFillRect:rect direction:direction withMinRequestMessagesCount:minRequestMessagesCount success:success failure:failure]; + + } failure:failure]; + } + else + { + + MXLogDebug(@"[MXKRoomDataSource][%p] paginateToFillRect: No more events to paginate", self); + if (success) + { + success(); + } + } + } + else + { + // Yes. Nothing to do + if (success) + { + success(); + } + } +} + + +#pragma mark - Sending +- (void)sendTextMessage:(NSString *)text success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure +{ + __block MXEvent *localEchoEvent = nil; + + BOOL isEmote = [self isMessageAnEmote:text]; + NSString *sanitizedText = [self sanitizedMessageText:text]; + NSString *html = [self htmlMessageFromSanitizedText:sanitizedText]; + + // Make the request to the homeserver + if (isEmote) + { + [_room sendEmote:sanitizedText formattedText:html localEcho:&localEchoEvent success:success failure:failure]; + } + else + { + [_room sendTextMessage:sanitizedText formattedText:html localEcho:&localEchoEvent success:success failure:failure]; + } + + if (localEchoEvent) + { + // Make the data source digest this fake local echo message + [self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } +} + +- (void)sendReplyToEventWithId:(NSString*)eventIdToReply + withTextMessage:(NSString *)text + success:(void (^)(NSString *))success + failure:(void (^)(NSError *))failure +{ + MXEvent *eventToReply = [self eventWithEventId:eventIdToReply]; + + __block MXEvent *localEchoEvent = nil; + + NSString *sanitizedText = [self sanitizedMessageText:text]; + NSString *html = [self htmlMessageFromSanitizedText:sanitizedText]; + + id stringLocalizer = [MXKSendReplyEventStringLocalizer new]; + + [_room sendReplyToEvent:eventToReply withTextMessage:sanitizedText formattedTextMessage:html stringLocalizer:stringLocalizer localEcho:&localEchoEvent success:success failure:failure]; + + if (localEchoEvent) + { + // Make the data source digest this fake local echo message + [self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } +} + +- (BOOL)isMessageAnEmote:(NSString*)text +{ + return [text hasPrefix:emoteMessageSlashCommandPrefix]; +} + +- (NSString*)sanitizedMessageText:(NSString*)rawText +{ + NSString *text; + + //Remove NULL bytes from the string, as they are likely to trip up many things later, + //including our own C-based Markdown-to-HTML convertor. + // + //Normally, we don't expect people to be entering NULL bytes in messages, + //but because of a bug in iOS 11, it's easy to have it happen. + // + //iOS 11's Smart Punctuation feature "conveniently" converts double hyphens (`--`) to longer en-dashes (`—`). + //However, when adding any kind of dash/hyphen after such an en-dash, + //iOS would also insert a NULL byte inbetween the dashes (`NULL`). + // + //Even if a future iOS update fixes this, + //we'd better be defensive and always remove occurrences of NULL bytes from text messages. + text = [rawText stringByReplacingOccurrencesOfString:[NSString stringWithFormat:@"%C", 0x00000000] withString:@""]; + + // Check whether the message is an emote + if ([self isMessageAnEmote:text]) + { + // Remove "/me " string + text = [text substringFromIndex:emoteMessageSlashCommandPrefix.length]; + } + + return text; +} + +- (NSString*)htmlMessageFromSanitizedText:(NSString*)sanitizedText +{ + NSString *html; + + // Did user use Markdown text? + NSString *htmlStringFromMarkdown = [_eventFormatter htmlStringFromMarkdownString:sanitizedText]; + + if ([htmlStringFromMarkdown isEqualToString:sanitizedText]) + { + // No formatted string + html = nil; + } + else + { + html = htmlStringFromMarkdown; + } + + return html; +} + +- (void)sendImage:(UIImage *)image success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure +{ + // Make sure the uploaded image orientation is up + image = [MXKTools forceImageOrientationUp:image]; + + // Only jpeg image is supported here + NSString *mimetype = @"image/jpeg"; + NSData *imageData = UIImageJPEGRepresentation(image, 0.9); + + // Shall we need to consider a thumbnail? + UIImage *thumbnail = nil; + if (_room.summary.isEncrypted) + { + // Thumbnail is useful only in case of encrypted room + thumbnail = [MXKTools reduceImage:image toFitInSize:CGSizeMake(800, 600)]; + if (thumbnail == image) + { + thumbnail = nil; + } + } + + [self sendImageData:imageData withImageSize:image.size mimeType:mimetype andThumbnail:thumbnail success:success failure:failure]; +} + +- (BOOL)canReplyToEventWithId:(NSString*)eventIdToReply +{ + MXEvent *eventToReply = [self eventWithEventId:eventIdToReply]; + return [self.room canReplyToEvent:eventToReply]; +} + +- (void)sendImage:(NSData *)imageData mimeType:(NSString *)mimetype success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure +{ + UIImage *image = [UIImage imageWithData:imageData]; + + // Shall we need to consider a thumbnail? + UIImage *thumbnail = nil; + if (_room.summary.isEncrypted) + { + // Thumbnail is useful only in case of encrypted room + thumbnail = [MXKTools reduceImage:image toFitInSize:CGSizeMake(800, 600)]; + if (thumbnail == image) + { + thumbnail = nil; + } + } + + [self sendImageData:imageData withImageSize:image.size mimeType:mimetype andThumbnail:thumbnail success:success failure:failure]; +} + +- (void)sendImageData:(NSData*)imageData withImageSize:(CGSize)imageSize mimeType:(NSString*)mimetype andThumbnail:(UIImage*)thumbnail success:(void (^)(NSString *eventId))success failure:(void (^)(NSError *error))failure +{ + __block MXEvent *localEchoEvent = nil; + + [_room sendImage:imageData withImageSize:imageSize mimeType:mimetype andThumbnail:thumbnail localEcho:&localEchoEvent success:success failure:failure]; + + if (localEchoEvent) + { + // Make the data source digest this fake local echo message + [self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } +} + +- (void)sendVideo:(NSURL *)videoLocalURL withThumbnail:(UIImage *)videoThumbnail success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure +{ + AVURLAsset *videoAsset = [AVURLAsset assetWithURL:videoLocalURL]; + [self sendVideoAsset:videoAsset withThumbnail:videoThumbnail success:success failure:failure]; +} + +- (void)sendVideoAsset:(AVAsset *)videoAsset withThumbnail:(UIImage *)videoThumbnail success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure +{ + __block MXEvent *localEchoEvent = nil; + + [_room sendVideoAsset:videoAsset withThumbnail:videoThumbnail localEcho:&localEchoEvent success:success failure:failure]; + + if (localEchoEvent) + { + // Make the data source digest this fake local echo message + [self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } +} + +- (void)sendAudioFile:(NSURL *)audioFileLocalURL mimeType:mimeType success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure +{ + __block MXEvent *localEchoEvent = nil; + + [_room sendAudioFile:audioFileLocalURL mimeType:mimeType localEcho:&localEchoEvent success:success failure:failure keepActualFilename:YES]; + + if (localEchoEvent) + { + // Make the data source digest this fake local echo message + [self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } +} + +- (void)sendVoiceMessage:(NSURL *)audioFileLocalURL + mimeType:mimeType + duration:(NSUInteger)duration + samples:(NSArray *)samples + success:(void (^)(NSString *))success + failure:(void (^)(NSError *))failure +{ + __block MXEvent *localEchoEvent = nil; + + [_room sendVoiceMessage:audioFileLocalURL mimeType:mimeType duration:duration samples:samples localEcho:&localEchoEvent success:success failure:failure keepActualFilename:YES]; + + if (localEchoEvent) + { + // Make the data source digest this fake local echo message + [self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } +} + + +- (void)sendFile:(NSURL *)fileLocalURL mimeType:(NSString*)mimeType success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure +{ + __block MXEvent *localEchoEvent = nil; + + [_room sendFile:fileLocalURL mimeType:mimeType localEcho:&localEchoEvent success:success failure:failure]; + + if (localEchoEvent) + { + // Make the data source digest this fake local echo message + [self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } +} + +- (void)sendMessageWithContent:(NSDictionary *)msgContent success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure +{ + __block MXEvent *localEchoEvent = nil; + + // Make the request to the homeserver + [_room sendMessageWithContent:msgContent localEcho:&localEchoEvent success:success failure:failure]; + + if (localEchoEvent) + { + // Make the data source digest this fake local echo message + [self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } +} + +- (void)sendEventOfType:(MXEventTypeString)eventTypeString content:(NSDictionary*)msgContent success:(void (^)(NSString *eventId))success failure:(void (^)(NSError *error))failure +{ + __block MXEvent *localEchoEvent = nil; + + // Make the request to the homeserver + [_room sendEventOfType:eventTypeString content:msgContent localEcho:&localEchoEvent success:success failure:failure]; + + if (localEchoEvent) + { + // Make the data source digest this fake local echo message + [self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } +} + +- (void)resendEventWithEventId:(NSString *)eventId success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure +{ + MXEvent *event = [self eventWithEventId:eventId]; + + // Sanity check + if (!event) + { + return; + } + + MXLogInfo(@"[MXKRoomDataSource][%p] resendEventWithEventId. EventId: %@", self, event.eventId); + + // Check first whether the event is encrypted + if ([event.wireType isEqualToString:kMXEventTypeStringRoomEncrypted]) + { + // We try here to resent an encrypted event + // Note: we keep the existing local echo. + [_room sendEventOfType:kMXEventTypeStringRoomEncrypted content:event.wireContent localEcho:&event success:success failure:failure]; + } + else if ([event.type isEqualToString:kMXEventTypeStringRoomMessage]) + { + // And retry the send the message according to its type + NSString *msgType = event.content[@"msgtype"]; + if ([msgType isEqualToString:kMXMessageTypeText] || [msgType isEqualToString:kMXMessageTypeEmote]) + { + // Resend the Matrix event by reusing the existing echo + [_room sendMessageWithContent:event.content localEcho:&event success:success failure:failure]; + } + else if ([msgType isEqualToString:kMXMessageTypeImage]) + { + // Check whether the sending failed while uploading the data. + // If the content url corresponds to a upload id, the upload was not complete. + NSString *contentURL = event.content[@"url"]; + if (contentURL && [contentURL hasPrefix:kMXMediaUploadIdPrefix]) + { + NSString *mimetype = nil; + if (event.content[@"info"]) + { + mimetype = event.content[@"info"][@"mimetype"]; + } + + NSString *localImagePath = [MXMediaManager cachePathForMatrixContentURI:contentURL andType:mimetype inFolder:_roomId]; + UIImage* image = [MXMediaManager loadPictureFromFilePath:localImagePath]; + if (image) + { + // Restart sending the image from the beginning. + + // Remove the local echo. + [self removeEventWithEventId:eventId]; + + if (mimetype) + { + NSData *imageData = [NSData dataWithContentsOfFile:localImagePath]; + [self sendImage:imageData mimeType:mimetype success:success failure:failure]; + } + else + { + [self sendImage:image success:success failure:failure]; + } + } + else + { + failure([NSError errorWithDomain:MXKRoomDataSourceErrorDomain code:MXKRoomDataSourceErrorResendGeneric userInfo:nil]); + MXLogWarning(@"[MXKRoomDataSource][%p] resendEventWithEventId: Warning - Unable to resend room message of type: %@", self, msgType); + } + } + else + { + // Resend the Matrix event by reusing the existing echo + [_room sendMessageWithContent:event.content localEcho:&event success:success failure:failure]; + } + } + else if ([msgType isEqualToString:kMXMessageTypeAudio]) + { + // Check whether the sending failed while uploading the data. + // If the content url corresponds to a upload id, the upload was not complete. + NSString *contentURL = event.content[@"url"]; + if (!contentURL || ![contentURL hasPrefix:kMXMediaUploadIdPrefix]) + { + // Resend the Matrix event by reusing the existing echo + [_room sendMessageWithContent:event.content localEcho:&event success:success failure:failure]; + return; + } + + NSString *mimetype = event.content[@"info"][@"mimetype"]; + NSString *localFilePath = [MXMediaManager cachePathForMatrixContentURI:contentURL andType:mimetype inFolder:_roomId]; + NSURL *localFileURL = [NSURL URLWithString:localFilePath]; + + if (![NSFileManager.defaultManager fileExistsAtPath:localFilePath]) { + failure([NSError errorWithDomain:MXKRoomDataSourceErrorDomain code:MXKRoomDataSourceErrorResendInvalidLocalFilePath userInfo:nil]); + MXLogWarning(@"[MXKRoomDataSource][%p] resendEventWithEventId: Warning - Unable to resend voice message, invalid file path.", self); + return; + } + + // Remove the local echo. + [self removeEventWithEventId:eventId]; + + if (event.isVoiceMessage) { + NSNumber *duration = event.content[kMXMessageContentKeyExtensibleAudio][kMXMessageContentKeyExtensibleAudioDuration]; + NSArray *samples = event.content[kMXMessageContentKeyExtensibleAudio][kMXMessageContentKeyExtensibleAudioWaveform]; + + [self sendVoiceMessage:localFileURL mimeType:mimetype duration:duration.doubleValue samples:samples success:success failure:failure]; + } else { + [self sendAudioFile:localFileURL mimeType:mimetype success:success failure:failure]; + } + } + else if ([msgType isEqualToString:kMXMessageTypeVideo]) + { + // Check whether the sending failed while uploading the data. + // If the content url corresponds to a upload id, the upload was not complete. + NSString *contentURL = event.content[@"url"]; + if (contentURL && [contentURL hasPrefix:kMXMediaUploadIdPrefix]) + { + // TODO: Support resend on attached video when upload has been failed. + MXLogDebug(@"[MXKRoomDataSource][%p] resendEventWithEventId: Warning - Unable to resend attached video (upload was not complete)", self); + failure([NSError errorWithDomain:MXKRoomDataSourceErrorDomain code:MXKRoomDataSourceErrorResendInvalidMessageType userInfo:nil]); + } + else + { + // Resend the Matrix event by reusing the existing echo + [_room sendMessageWithContent:event.content localEcho:&event success:success failure:failure]; + } + } + else if ([msgType isEqualToString:kMXMessageTypeFile]) + { + // Check whether the sending failed while uploading the data. + // If the content url corresponds to a upload id, the upload was not complete. + NSString *contentURL = event.content[@"url"]; + if (contentURL && [contentURL hasPrefix:kMXMediaUploadIdPrefix]) + { + NSString *mimetype = nil; + if (event.content[@"info"]) + { + mimetype = event.content[@"info"][@"mimetype"]; + } + + if (mimetype) + { + // Restart sending the image from the beginning. + + // Remove the local echo + [self removeEventWithEventId:eventId]; + + NSString *localFilePath = [MXMediaManager cachePathForMatrixContentURI:contentURL andType:mimetype inFolder:_roomId]; + + [self sendFile:[NSURL fileURLWithPath:localFilePath isDirectory:NO] mimeType:mimetype success:success failure:failure]; + } + else + { + failure([NSError errorWithDomain:MXKRoomDataSourceErrorDomain code:MXKRoomDataSourceErrorResendGeneric userInfo:nil]); + MXLogWarning(@"[MXKRoomDataSource][%p] resendEventWithEventId: Warning - Unable to resend room message of type: %@", self, msgType); + } + } + else + { + // Resend the Matrix event by reusing the existing echo + [_room sendMessageWithContent:event.content localEcho:&event success:success failure:failure]; + } + } + else + { + failure([NSError errorWithDomain:MXKRoomDataSourceErrorDomain code:MXKRoomDataSourceErrorResendInvalidMessageType userInfo:nil]); + MXLogWarning(@"[MXKRoomDataSource][%p] resendEventWithEventId: Warning - Unable to resend room message of type: %@", self, msgType); + } + } + else + { + failure([NSError errorWithDomain:MXKRoomDataSourceErrorDomain code:MXKRoomDataSourceErrorResendInvalidMessageType userInfo:nil]); + MXLogWarning(@"[MXKRoomDataSource][%p] MXKRoomDataSource: Warning - Only resend of MXEventTypeRoomMessage is allowed. Event.type: %@", self, event.type); + } +} + + +#pragma mark - Events management +- (MXEvent *)eventWithEventId:(NSString *)eventId +{ + MXEvent *theEvent; + + // First, retrieve the cell data hosting the event + id bubbleData = [self cellDataOfEventWithEventId:eventId]; + if (bubbleData) + { + // Then look into the events in this cell + for (MXEvent *event in bubbleData.events) + { + if ([event.eventId isEqualToString:eventId]) + { + theEvent = event; + break; + } + } + } + return theEvent; +} + +- (void)removeEventWithEventId:(NSString *)eventId +{ + MXLogVerbose(@"[MXKRoomDataSource][%p] removeEventWithEventId: %@", self, eventId); + + // First, retrieve the cell data hosting the event + id bubbleData = [self cellDataOfEventWithEventId:eventId]; + if (bubbleData) + { + NSUInteger remainingEvents; + @synchronized (bubbleData) + { + remainingEvents = [bubbleData removeEvent:eventId]; + } + + // If there is no more events in the bubble, remove it + if (0 == remainingEvents) + { + [self removeCellData:bubbleData]; + } + + // Remove the event from the outgoing messages storage + [_room removeOutgoingMessage:eventId]; + + // Update the delegate + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } + } +} + +- (void)didReceiveReceiptEvent:(MXEvent *)receiptEvent roomState:(MXRoomState *)roomState +{ + // Do the processing on the same processing queue + MXWeakify(self); + dispatch_async(MXKRoomDataSource.processingQueue, ^{ + MXStrongifyAndReturnIfNil(self); + + // Remove the previous displayed read receipt for each user who sent a + // new read receipt. + // To implement it, we need to find the sender id of each new read receipt + // among the read receipts array of all events in all bubbles. + NSArray *readReceiptSenders = receiptEvent.readReceiptSenders; + + @synchronized(self->bubbles) + { + for (MXKRoomBubbleCellData *cellData in self->bubbles) + { + NSMutableDictionary *> *updatedCellDataReadReceipts = [NSMutableDictionary dictionary]; + + for (NSString *eventId in cellData.readReceipts) + { + for (MXReceiptData *receiptData in cellData.readReceipts[eventId]) + { + for (NSString *senderId in readReceiptSenders) + { + if ([receiptData.userId isEqualToString:senderId]) + { + if (!updatedCellDataReadReceipts[eventId]) + { + updatedCellDataReadReceipts[eventId] = cellData.readReceipts[eventId]; + } + + NSPredicate *predicate = [NSPredicate predicateWithFormat:@"userId!=%@", receiptData.userId]; + updatedCellDataReadReceipts[eventId] = [updatedCellDataReadReceipts[eventId] filteredArrayUsingPredicate:predicate]; + break; + } + } + + } + } + + // Flush found changed to the cell data + for (NSString *eventId in updatedCellDataReadReceipts) + { + if (updatedCellDataReadReceipts[eventId].count) + { + [self updateCellData:cellData withReadReceipts:updatedCellDataReadReceipts[eventId] forEventId:eventId]; + } + else + { + [self updateCellData:cellData withReadReceipts:nil forEventId:eventId]; + } + } + } + } + + dispatch_group_t dispatchGroup = dispatch_group_create(); + + // Update cell data we have received a read receipt for + NSArray *readEventIds = receiptEvent.readReceiptEventIds; + for (NSString* eventId in readEventIds) + { + MXKRoomBubbleCellData *cellData = [self cellDataOfEventWithEventId:eventId]; + if (cellData) + { + @synchronized(self->bubbles) + { + dispatch_group_enter(dispatchGroup); + [self addReadReceiptsForEvent:eventId inCellDatas:self->bubbles startingAtCellData:cellData completion:^{ + dispatch_group_leave(dispatchGroup); + }]; + } + } + } + + dispatch_group_notify(dispatchGroup, dispatch_get_main_queue(), ^{ + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } + }); + }); +} + +- (void)updateCellData:(MXKRoomBubbleCellData*)cellData withReadReceipts:(NSArray*)readReceipts forEventId:(NSString*)eventId +{ + cellData.readReceipts[eventId] = readReceipts; + + // Indicate that the text message layout should be recomputed. + [cellData invalidateTextLayout]; +} + +- (void)handleUnsentMessages +{ + // Add the unsent messages at the end of the conversation + NSArray* outgoingMessages = _room.outgoingMessages; + + [self.mxSession decryptEvents:outgoingMessages inTimeline:nil onComplete:^(NSArray *failedEvents) { + + for (MXEvent *outgoingMessage in outgoingMessages) + { + [self queueEventForProcessing:outgoingMessage withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + } + + MXLogVerbose(@"[MXKRoomDataSource][%p] handleUnsentMessages: queued %tu events", self, outgoingMessages.count); + + [self processQueuedEvents:nil]; + }]; +} + +#pragma mark - Bubble collapsing + +- (void)collapseRoomBubble:(id)bubbleData collapsed:(BOOL)collapsed +{ + if (bubbleData.collapsed != collapsed) + { + id nextBubbleData = bubbleData; + do + { + nextBubbleData.collapsed = collapsed; + } + while ((nextBubbleData = nextBubbleData.nextCollapsableCellData)); + + if (self.delegate) + { + // Reload all the table + [self.delegate dataSource:self didCellChange:nil]; + } + } +} + +#pragma mark - Private methods + +- (void)replaceEvent:(MXEvent*)eventToReplace withEvent:(MXEvent*)event +{ + MXLogVerbose(@"[MXKRoomDataSource][%p] replaceEvent: %@ with: %@", self, eventToReplace.eventId, event.eventId); + + if (eventToReplace.isLocalEvent) + { + // Stop listening to the identifier change for the replaced event. + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidChangeIdentifierNotification object:eventToReplace]; + } + + // Retrieve the cell data hosting the replaced event + id bubbleData = [self cellDataOfEventWithEventId:eventToReplace.eventId]; + if (!bubbleData) + { + return; + } + + NSUInteger remainingEvents; + @synchronized (bubbleData) + { + // Check whether the local echo is replaced or removed + if (event) + { + remainingEvents = [bubbleData updateEvent:eventToReplace.eventId withEvent:event]; + } + else + { + remainingEvents = [bubbleData removeEvent:eventToReplace.eventId]; + } + } + + // Update bubbles mapping + @synchronized (eventIdToBubbleMap) + { + // Remove the broken link from the map + [eventIdToBubbleMap removeObjectForKey:eventToReplace.eventId]; + + if (event && remainingEvents) + { + eventIdToBubbleMap[event.eventId] = bubbleData; + + if (event.isLocalEvent) + { + // Listen to the identifier change for the local events. + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(localEventDidChangeIdentifier:) name:kMXEventDidChangeIdentifierNotification object:event]; + } + } + } + + // If there is no more events in the bubble, remove it + if (0 == remainingEvents) + { + [self removeCellData:bubbleData]; + } + + // Update the delegate + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } +} + +- (NSArray *)removeCellData:(id)cellData +{ + NSMutableArray *deletedRows = [NSMutableArray array]; + + MXLogVerbose(@"[MXKRoomDataSource][%p] removeCellData: %@", self, [cellData.events valueForKey:@"eventId"]); + + // Remove potential occurrences in bubble map + @synchronized (eventIdToBubbleMap) + { + for (MXEvent *event in cellData.events) + { + [eventIdToBubbleMap removeObjectForKey:event.eventId]; + + if (event.isLocalEvent) + { + // Stop listening to the identifier change for this event. + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidChangeIdentifierNotification object:event]; + } + } + } + + // Check whether the adjacent bubbles can merge together + @synchronized(bubbles) + { + NSUInteger index = [bubbles indexOfObject:cellData]; + if (index != NSNotFound) + { + [bubbles removeObjectAtIndex:index]; + [deletedRows addObject:[NSIndexPath indexPathForRow:index inSection:0]]; + + if (bubbles.count) + { + // Update flag in remaining data + if (index == 0) + { + // We removed here the first bubble. + // We have to update the 'isPaginationFirstBubble' and 'shouldHideSenderInformation' flags of the new first bubble. + id firstCellData = bubbles.firstObject; + + firstCellData.isPaginationFirstBubble = ((self.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay) && firstCellData.date); + + // Keep visible the sender information by default, + // except if the bubble has no display (composed only by ignored events). + firstCellData.shouldHideSenderInformation = firstCellData.hasNoDisplay; + } + else if (index < bubbles.count) + { + // We removed here a bubble which is not the before last. + id cellData1 = bubbles[index-1]; + id cellData2 = bubbles[index]; + + // Check first whether the neighbor bubbles can merge + Class class = [self cellDataClassForCellIdentifier:kMXKRoomBubbleCellDataIdentifier]; + if ([class instancesRespondToSelector:@selector(mergeWithBubbleCellData:)]) + { + if ([cellData1 mergeWithBubbleCellData:cellData2]) + { + [bubbles removeObjectAtIndex:index]; + [deletedRows addObject:[NSIndexPath indexPathForRow:(index + 1) inSection:0]]; + + cellData2 = nil; + } + } + + if (cellData2) + { + // Update its 'isPaginationFirstBubble' and 'shouldHideSenderInformation' flags + + // Pagination handling + if (self.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay && !cellData2.isPaginationFirstBubble) + { + // Check whether a new pagination starts on the second cellData + NSString *cellData1DateString = [self.eventFormatter dateStringFromDate:cellData1.date withTime:NO]; + NSString *cellData2DateString = [self.eventFormatter dateStringFromDate:cellData2.date withTime:NO]; + + if (!cellData1DateString) + { + cellData2.isPaginationFirstBubble = (cellData2DateString && cellData.isPaginationFirstBubble); + } + else + { + cellData2.isPaginationFirstBubble = (cellData2DateString && ![cellData2DateString isEqualToString:cellData1DateString]); + } + } + + // Check whether the sender information is relevant for this bubble. + // Check first if the bubble is not composed only by ignored events. + cellData2.shouldHideSenderInformation = cellData2.hasNoDisplay; + if (!cellData2.shouldHideSenderInformation && cellData2.isPaginationFirstBubble == NO) + { + // Check whether the neighbor bubbles have been sent by the same user. + cellData2.shouldHideSenderInformation = [cellData2 hasSameSenderAsBubbleCellData:cellData1]; + } + } + + } + } + } + } + + return deletedRows; +} + +- (void)didMXRoomInitialSynced:(NSNotification *)notif +{ + // Refresh the room data source when the room has been initialSync'ed + MXRoom *room = notif.object; + if (self.mxSession == room.mxSession && + ([self.roomId isEqualToString:room.roomId] || [self.secondaryRoomId isEqualToString:room.roomId])) + { + MXLogDebug(@"[MXKRoomDataSource][%p] didMXRoomInitialSynced for room: %@", self, room.roomId); + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXRoomInitialSyncNotification object:room]; + + [self reload]; + } +} + +- (void)didMXSessionUpdatePublicisedGroupsForUsers:(NSNotification *)notif +{ + // Retrieved the list of the concerned users + NSArray *userIds = notif.userInfo[kMXSessionNotificationUserIdsArrayKey]; + if (userIds.count) + { + // Check whether at least one listed user is a room member. + for (NSString* userId in userIds) + { + MXRoomMember * roomMember = [self.roomState.members memberWithUserId:userId]; + if (roomMember) + { + // Inform the delegate to refresh the bubble display + // We dispatch here this action in order to let each bubble data update their sender flair. + if (self.delegate) + { + dispatch_async(dispatch_get_main_queue(), ^{ + [self.delegate dataSource:self didCellChange:nil]; + }); + } + break; + } + } + } +} + +- (void)eventDidChangeSentState:(NSNotification *)notif +{ + MXEvent *event = notif.object; + if ([event.roomId isEqualToString:_roomId]) + { + MXLogVerbose(@"[MXKRoomDataSource][%p] eventDidChangeSentState: %@, to: %tu", self, event.eventId, event.sentState); + + // Retrieve the cell data hosting the local echo + id bubbleData = [self cellDataOfEventWithEventId:event.eventId]; + if (!bubbleData) + { + // Initial state for local echos + BOOL isInitial = event.isLocalEvent && + (event.sentState == MXEventSentStateSending || event.sentState == MXEventSentStateEncrypting); + if (!isInitial) + { + MXLogWarning(@"[MXKRoomDataSource][%p] eventDidChangeSentState: Cannot find bubble data for event: %@", self, event.eventId); + } + return; + } + + @synchronized (bubbleData) + { + [bubbleData updateEvent:event.eventId withEvent:event]; + } + + // Inform the delegate + if (self.delegate && (self.secondaryRoom ? bubbles.count > 0 : YES)) + { + [self.delegate dataSource:self didCellChange:nil]; + } + } +} + +- (void)localEventDidChangeIdentifier:(NSNotification *)notif +{ + MXEvent *event = notif.object; + NSString *previousId = notif.userInfo[kMXEventIdentifierKey]; + + MXLogVerbose(@"[MXKRoomDataSource][%p] localEventDidChangeIdentifier from: %@ to: %@", self, previousId, event.eventId); + + if (event && previousId) + { + // Update bubbles mapping + @synchronized (eventIdToBubbleMap) + { + id bubbleData = eventIdToBubbleMap[previousId]; + if (bubbleData && event.eventId) + { + eventIdToBubbleMap[event.eventId] = bubbleData; + [eventIdToBubbleMap removeObjectForKey:previousId]; + + // The bubble data must use the final event id too + [bubbleData updateEvent:previousId withEvent:event]; + } + } + + if (!event.isLocalEvent) + { + // Stop listening to the identifier change when the event becomes an actual event. + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidChangeIdentifierNotification object:event]; + } + } +} + +- (void)eventDidDecrypt:(NSNotification *)notif +{ + MXEvent *event = notif.object; + if ([event.roomId isEqualToString:_roomId] || + ([event.roomId isEqualToString:_secondaryRoomId] && [_secondaryRoomEventTypes containsObject:event.type])) + { + // Retrieve the cell data hosting the event + id bubbleData = [self cellDataOfEventWithEventId:event.eventId]; + if (!bubbleData) + { + return; + } + + // We need to update the data of the cell that displays the event. + // The trickiest update is when the cell contains several events and the event + // to update turns out to be an attachment. + // In this case, we need to split the cell into several cells so that the attachment + // has its own cell. + if (bubbleData.events.count == 1 || ![_eventFormatter isSupportedAttachment:event]) + { + // If the event is still a text, a simple update is enough + // If the event is an attachment, it has already its own cell. Let the bubble + // data handle the type change. + @synchronized (bubbleData) + { + [bubbleData updateEvent:event.eventId withEvent:event]; + } + } + else + { + @synchronized (bubbleData) + { + BOOL eventIsFirstInBubble = NO; + NSInteger bubbleDataIndex = [bubbles indexOfObject:bubbleData]; + + if (NSNotFound == bubbleDataIndex) + { + // If bubbleData is not in bubbles there is nothing to update for this event, its not displayed. + return; + } + + // We need to create a dedicated cell for the event attachment. + // From the current bubble, remove the updated event and all events after. + NSMutableArray *removedEvents; + NSUInteger remainingEvents = [bubbleData removeEventsFromEvent:event.eventId removedEvents:&removedEvents]; + + // If there is no more events in this bubble, remove it + if (0 == remainingEvents) + { + eventIsFirstInBubble = YES; + @synchronized (eventsToProcessSnapshot) + { + [bubbles removeObjectAtIndex:bubbleDataIndex]; + bubbleDataIndex--; + } + } + + // Create a dedicated bubble for the attachment + if (removedEvents.count) + { + Class class = [self cellDataClassForCellIdentifier:kMXKRoomBubbleCellDataIdentifier]; + + id newBubbleData = [[class alloc] initWithEvent:removedEvents[0] andRoomState:self.roomState andRoomDataSource:self]; + + if (eventIsFirstInBubble) + { + // Apply same config as before + newBubbleData.isPaginationFirstBubble = bubbleData.isPaginationFirstBubble; + newBubbleData.shouldHideSenderInformation = bubbleData.shouldHideSenderInformation; + } + else + { + // This new bubble is not the first. Show nothing + newBubbleData.isPaginationFirstBubble = NO; + newBubbleData.shouldHideSenderInformation = YES; + } + + // Update bubbles mapping + @synchronized (eventIdToBubbleMap) + { + eventIdToBubbleMap[event.eventId] = newBubbleData; + } + + @synchronized (eventsToProcessSnapshot) + { + [bubbles insertObject:newBubbleData atIndex:bubbleDataIndex + 1]; + } + } + + // And put other cutted events in another bubble + if (removedEvents.count > 1) + { + Class class = [self cellDataClassForCellIdentifier:kMXKRoomBubbleCellDataIdentifier]; + + id newBubbleData; + for (NSUInteger i = 1; i < removedEvents.count; i++) + { + MXEvent *removedEvent = removedEvents[i]; + if (i == 1) + { + newBubbleData = [[class alloc] initWithEvent:removedEvent andRoomState:self.roomState andRoomDataSource:self]; + } + else + { + [newBubbleData addEvent:removedEvent andRoomState:self.roomState]; + } + + // Update bubbles mapping + @synchronized (eventIdToBubbleMap) + { + eventIdToBubbleMap[removedEvent.eventId] = newBubbleData; + } + } + + // Do not show the + newBubbleData.isPaginationFirstBubble = NO; + newBubbleData.shouldHideSenderInformation = YES; + + @synchronized (eventsToProcessSnapshot) + { + [bubbles insertObject:newBubbleData atIndex:bubbleDataIndex + 2]; + } + } + } + } + + // Update the delegate + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } + } +} + +// Indicates whether an event has base requirements to allow actions (like reply, reactions, edit, etc.) +- (BOOL)canPerformActionOnEvent:(MXEvent*)event +{ + BOOL isSent = event.sentState == MXEventSentStateSent; + BOOL isRoomMessage = event.eventType == MXEventTypeRoomMessage; + + NSString *messageType = event.content[@"msgtype"]; + + return isSent && isRoomMessage && messageType && ![messageType isEqualToString:@"m.bad.encrypted"]; +} + +- (void)setState:(MXKDataSourceState)newState +{ + self->state = newState; + + if (self.delegate && [self.delegate respondsToSelector:@selector(dataSource:didStateChange:)]) + { + [self.delegate dataSource:self didStateChange:self->state]; + } +} + +- (void)setSecondaryRoomId:(NSString *)secondaryRoomId +{ + if (_secondaryRoomId != secondaryRoomId) + { + _secondaryRoomId = secondaryRoomId; + + if (self.state == MXKDataSourceStateReady) + { + [self reload]; + } + } +} + +- (void)setSecondaryRoomEventTypes:(NSArray *)secondaryRoomEventTypes +{ + if (_secondaryRoomEventTypes != secondaryRoomEventTypes) + { + _secondaryRoomEventTypes = secondaryRoomEventTypes; + + if (self.state == MXKDataSourceStateReady) + { + [self reload]; + } + } +} + +#pragma mark - Asynchronous events processing + + (dispatch_queue_t)processingQueue +{ + static dispatch_queue_t processingQueue; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + processingQueue = dispatch_queue_create("MXKRoomDataSource", DISPATCH_QUEUE_SERIAL); + }); + + return processingQueue; +} + +/** + Queue an event in order to process its display later. + + @param event the event to process. + @param roomState the state of the room when the event fired. + @param direction the order of the events in the arrays + */ +- (void)queueEventForProcessing:(MXEvent*)event withRoomState:(MXRoomState*)roomState direction:(MXTimelineDirection)direction +{ + if (event.isLocalEvent) + { + MXLogVerbose(@"[MXKRoomDataSource][%p] queueEventForProcessing: %@", self, event.eventId); + } + + if (self.filterMessagesWithURL) + { + // Check whether the event has a value for the 'url' key in its content. + if (!event.getMediaURLs.count) + { + // Ignore the event + return; + } + } + + // Check for undecryptable messages that were sent while the user was not in the room and hide them + if ([MXKAppSettings standardAppSettings].hidePreJoinedUndecryptableEvents + && direction == MXTimelineDirectionBackwards) + { + [self checkForPreJoinUTDWithEvent:event roomState:roomState]; + + // Hide pre joint UTD events + if (self.shouldStopBackPagination) + { + return; + } + } + + MXKQueuedEvent *queuedEvent = [[MXKQueuedEvent alloc] initWithEvent:event andRoomState:roomState direction:direction]; + + // Count queued events when the server sync is in progress + if (self.mxSession.state == MXSessionStateSyncInProgress) + { + queuedEvent.serverSyncEvent = YES; + _serverSyncEventCount++; + + if (_serverSyncEventCount == 1) + { + // Notify that sync process starts + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKRoomDataSourceSyncStatusChanged object:self userInfo:nil]; + } + } + + @synchronized(eventsToProcess) + { + [eventsToProcess addObject:queuedEvent]; + + if (self.secondaryRoom) + { + // use a stable sorting here, which means it won't change the order of events unless it has to. + [eventsToProcess sortWithOptions:NSSortStable + usingComparator:^NSComparisonResult(MXKQueuedEvent * _Nonnull event1, MXKQueuedEvent * _Nonnull event2) { + return [event2.eventDate compare:event1.eventDate]; + }]; + } + } +} + +- (BOOL)canPaginate:(MXTimelineDirection)direction +{ + if (_secondaryTimeline) + { + if (![_timeline canPaginate:direction] && ![_secondaryTimeline canPaginate:direction]) + { + return NO; + } + } + else + { + if (![_timeline canPaginate:direction]) + { + return NO; + } + } + + if (direction == MXTimelineDirectionBackwards && self.shouldStopBackPagination) + { + return NO; + } + + return YES; +} + +// Check for undecryptable messages that were sent while the user was not in the room. +- (void)checkForPreJoinUTDWithEvent:(MXEvent*)event roomState:(MXRoomState*)roomState +{ + // Only check for encrypted rooms + if (!self.room.summary.isEncrypted) + { + return; + } + + // Back pagination is stopped do not check for other pre join events + if (self.shouldStopBackPagination) + { + return; + } + + // if we reach a UTD and flag is set, hide previous encrypted messages and stop back-paginating + if (event.eventType == MXEventTypeRoomEncrypted + && [event.decryptionError.domain isEqualToString:MXDecryptingErrorDomain] + && self.shouldPreventBackPaginationOnPreviousUTDEvent) + { + self.shouldStopBackPagination = YES; + return; + } + + self.shouldStopBackPagination = NO; + + if (event.eventType != MXEventTypeRoomMember) + { + return; + } + + NSString *userId = event.stateKey; + + // Only check "m.room.member" event for current user + if (![userId isEqualToString:self.mxSession.myUserId]) + { + return; + } + + BOOL shouldPreventBackPaginationOnPreviousUTDEvent = NO; + + MXRoomMember *member = [roomState.members memberWithUserId:userId]; + + if (member) + { + switch (member.membership) { + case MXMembershipJoin: + { + // if we reach a join event for the user: + // - if prev-content is invite, continue back-paginating + // - if prev-content is join (was just an avatar or displayname change), continue back-paginating + // - otherwise, set a flag and continue back-paginating + + NSString *previousMemberhsip = event.prevContent[@"membership"]; + + BOOL isPrevContentAnInvite = [previousMemberhsip isEqualToString:@"invite"]; + BOOL isPrevContentAJoin = [previousMemberhsip isEqualToString:@"join"]; + + if (!(isPrevContentAnInvite || isPrevContentAJoin)) + { + shouldPreventBackPaginationOnPreviousUTDEvent = YES; + } + } + break; + case MXMembershipInvite: + // if we reach an invite event for the user, set flag and continue back-paginating + shouldPreventBackPaginationOnPreviousUTDEvent = YES; + break; + default: + break; + } + } + + self.shouldPreventBackPaginationOnPreviousUTDEvent = shouldPreventBackPaginationOnPreviousUTDEvent; +} + +- (BOOL)checkBing:(MXEvent*)event +{ + BOOL isHighlighted = NO; + + // read receipts have no rule + if (![event.type isEqualToString:kMXEventTypeStringReceipt]) { + // Check if we should bing this event + MXPushRule *rule = [self.mxSession.notificationCenter ruleMatchingEvent:event roomState:self.roomState]; + if (rule) + { + // Check whether is there an highlight tweak on it + for (MXPushRuleAction *ruleAction in rule.actions) + { + if (ruleAction.actionType == MXPushRuleActionTypeSetTweak) + { + if ([ruleAction.parameters[@"set_tweak"] isEqualToString:@"highlight"]) + { + // Check the highlight tweak "value" + // If not present, highlight. Else check its value before highlighting + if (nil == ruleAction.parameters[@"value"] || YES == [ruleAction.parameters[@"value"] boolValue]) + { + isHighlighted = YES; + break; + } + } + } + } + } + } + + event.mxkIsHighlighted = isHighlighted; + return isHighlighted; +} + +/** + Start processing pending events. + + @param onComplete a block called (on the main thread) when the processing has been done. Can be nil. + Note this block returns the number of added cells in first and last positions. + */ +- (void)processQueuedEvents:(void (^)(NSUInteger addedHistoryCellNb, NSUInteger addedLiveCellNb))onComplete +{ + MXWeakify(self); + + // Do the processing on the processing queue + dispatch_async(MXKRoomDataSource.processingQueue, ^{ + + MXStrongifyAndReturnIfNil(self); + + // Note: As this block is always called from the same processing queue, + // only one batch process is done at a time. Thus, an event cannot be + // processed twice + + // Snapshot queued events to avoid too long lock. + @synchronized(self->eventsToProcess) + { + if (self->eventsToProcess.count) + { + self->eventsToProcessSnapshot = self->eventsToProcess; + if (self.secondaryRoom) + { + @synchronized(self->bubbles) + { + [self->bubblesSnapshot removeAllObjects]; + } + } + else + { + self->eventsToProcess = [NSMutableArray array]; + } + } + } + + NSUInteger serverSyncEventCount = 0; + NSUInteger addedHistoryCellCount = 0; + NSUInteger addedLiveCellCount = 0; + + dispatch_group_t dispatchGroup = dispatch_group_create(); + + // Lock on `eventsToProcessSnapshot` to suspend reload or destroy during the process. + @synchronized(self->eventsToProcessSnapshot) + { + // Is there events to process? + // The list can be empty because several calls of processQueuedEvents may be processed + // in one pass in the processingQueue + if (self->eventsToProcessSnapshot.count) + { + // Make a quick copy of changing data to avoid to lock it too long time + @synchronized(self->bubbles) + { + self->bubblesSnapshot = [self->bubbles mutableCopy]; + } + + NSMutableSet> *collapsingCellDataSeriess = [NSMutableSet set]; + + for (MXKQueuedEvent *queuedEvent in self->eventsToProcessSnapshot) + { + @synchronized (self->eventIdToBubbleMap) + { + // Check whether the event processed before + if (self->eventIdToBubbleMap[queuedEvent.event.eventId]) + { + MXLogVerbose(@"[MXKRoomDataSource][%p] processQueuedEvents: Skip event: %@, state: %tu", self, queuedEvent.event.eventId, queuedEvent.event.sentState); + continue; + } + } + + @autoreleasepool + { + // Count events received while the server sync was in progress + if (queuedEvent.serverSyncEvent) + { + serverSyncEventCount ++; + } + + // Check whether the event must be highlighted + [self checkBing:queuedEvent.event]; + + // Retrieve the MXKCellData class to manage the data + Class class = [self cellDataClassForCellIdentifier:kMXKRoomBubbleCellDataIdentifier]; + NSAssert([class conformsToProtocol:@protocol(MXKRoomBubbleCellDataStoring)], @"MXKRoomDataSource only manages MXKCellData that conforms to MXKRoomBubbleCellDataStoring protocol"); + + BOOL eventManaged = NO; + BOOL updatedBubbleDataHadNoDisplay = NO; + id bubbleData; + if ([class instancesRespondToSelector:@selector(addEvent:andRoomState:)] && 0 < self->bubblesSnapshot.count) + { + // Try to concatenate the event to the last or the oldest bubble? + if (queuedEvent.direction == MXTimelineDirectionBackwards) + { + bubbleData = self->bubblesSnapshot.firstObject; + } + else + { + bubbleData = self->bubblesSnapshot.lastObject; + } + + @synchronized (bubbleData) + { + updatedBubbleDataHadNoDisplay = bubbleData.hasNoDisplay; + eventManaged = [bubbleData addEvent:queuedEvent.event andRoomState:queuedEvent.state]; + } + } + + if (NO == eventManaged) + { + // The event has not been concatenated to an existing cell, create a new bubble for this event + bubbleData = [[class alloc] initWithEvent:queuedEvent.event andRoomState:queuedEvent.state andRoomDataSource:self]; + if (!bubbleData) + { + // The event is ignored + continue; + } + + // Check cells collapsing + if (bubbleData.hasAttributedTextMessage) + { + if (bubbleData.collapsable) + { + if (queuedEvent.direction == MXTimelineDirectionBackwards) + { + // Try to collapse it with the series at the start of self.bubbles + if (self->collapsableSeriesAtStart && [self->collapsableSeriesAtStart collapseWith:bubbleData]) + { + // bubbleData becomes the oldest cell data of the current series + self->collapsableSeriesAtStart.prevCollapsableCellData = bubbleData; + bubbleData.nextCollapsableCellData = self->collapsableSeriesAtStart; + + // The new cell must have the collapsed state as the series + bubbleData.collapsed = self->collapsableSeriesAtStart.collapsed; + + // Release data of the previous header + self->collapsableSeriesAtStart.collapseState = nil; + self->collapsableSeriesAtStart.collapsedAttributedTextMessage = nil; + [collapsingCellDataSeriess removeObject:self->collapsableSeriesAtStart]; + + // And keep a ref of data for the new start of the series + self->collapsableSeriesAtStart = bubbleData; + self->collapsableSeriesAtStart.collapseState = queuedEvent.state; + [collapsingCellDataSeriess addObject:self->collapsableSeriesAtStart]; + } + else + { + // This is a ending point for a new collapsable series of cells + self->collapsableSeriesAtStart = bubbleData; + self->collapsableSeriesAtStart.collapseState = queuedEvent.state; + [collapsingCellDataSeriess addObject:self->collapsableSeriesAtStart]; + } + } + else + { + // Try to collapse it with the series at the end of self.bubbles + if (self->collapsableSeriesAtEnd && [self->collapsableSeriesAtEnd collapseWith:bubbleData]) + { + // Put bubbleData at the series tail + // Find the tail + id tailBubbleData = self->collapsableSeriesAtEnd; + while (tailBubbleData.nextCollapsableCellData) + { + tailBubbleData = tailBubbleData.nextCollapsableCellData; + } + + tailBubbleData.nextCollapsableCellData = bubbleData; + bubbleData.prevCollapsableCellData = tailBubbleData; + + // The new cell must have the collapsed state as the series + bubbleData.collapsed = tailBubbleData.collapsed; + + // If the start of the collapsible series stems from an event in a different processing + // batch, we need to track it here so that we can update the summary string later + if (![collapsingCellDataSeriess containsObject:self->collapsableSeriesAtEnd]) { + [collapsingCellDataSeriess addObject:self->collapsableSeriesAtEnd]; + } + } + else + { + // This is a starting point for a new collapsable series of cells + self->collapsableSeriesAtEnd = bubbleData; + self->collapsableSeriesAtEnd.collapseState = queuedEvent.state; + [collapsingCellDataSeriess addObject:self->collapsableSeriesAtEnd]; + } + } + } + else + { + // The new bubble is not collapsable. + // We can close one border of the current series being built (if any) + if (queuedEvent.direction == MXTimelineDirectionBackwards && self->collapsableSeriesAtStart) + { + // This is the begin border of the series + self->collapsableSeriesAtStart = nil; + } + else if (queuedEvent.direction == MXTimelineDirectionForwards && self->collapsableSeriesAtEnd) + { + // This is the end border of the series + self->collapsableSeriesAtEnd = nil; + } + } + } + + if (queuedEvent.direction == MXTimelineDirectionBackwards) + { + // The new bubble data will be inserted at first position. + // We have to update the 'isPaginationFirstBubble' and 'shouldHideSenderInformation' flags of the current first bubble. + + // Pagination handling + if ((self.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay) && bubbleData.date) + { + // A new pagination starts with this new bubble data + bubbleData.isPaginationFirstBubble = YES; + + // Check whether the current first displayed pagination title is still relevant. + if (self->bubblesSnapshot.count) + { + NSInteger index = 0; + id previousFirstBubbleDataWithDate; + NSString *firstBubbleDateString; + while (index < self->bubblesSnapshot.count) + { + previousFirstBubbleDataWithDate = self->bubblesSnapshot[index++]; + firstBubbleDateString = [self.eventFormatter dateStringFromDate:previousFirstBubbleDataWithDate.date withTime:NO]; + + if (firstBubbleDateString) + { + break; + } + } + + if (firstBubbleDateString) + { + NSString *bubbleDateString = [self.eventFormatter dateStringFromDate:bubbleData.date withTime:NO]; + previousFirstBubbleDataWithDate.isPaginationFirstBubble = (bubbleDateString && ![firstBubbleDateString isEqualToString:bubbleDateString]); + } + } + } + else + { + bubbleData.isPaginationFirstBubble = NO; + } + + // Sender information are required for this new first bubble data, + // except if the bubble has no display (composed only by ignored events). + bubbleData.shouldHideSenderInformation = bubbleData.hasNoDisplay; + + // Check whether this information is relevant for the current first bubble. + if (!bubbleData.shouldHideSenderInformation && self->bubblesSnapshot.count) + { + id previousFirstBubbleData = self->bubblesSnapshot.firstObject; + + if (previousFirstBubbleData.isPaginationFirstBubble == NO) + { + // Check whether the current first bubble has been sent by the same user. + previousFirstBubbleData.shouldHideSenderInformation |= [previousFirstBubbleData hasSameSenderAsBubbleCellData:bubbleData]; + } + } + + // Insert the new bubble data in first position + [self->bubblesSnapshot insertObject:bubbleData atIndex:0]; + + addedHistoryCellCount++; + } + else + { + // The new bubble data will be added at the last position + // We have to update its 'isPaginationFirstBubble' and 'shouldHideSenderInformation' flags according to the previous last bubble. + + // Pagination handling + if (self.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay) + { + // Check whether a new pagination starts at this bubble + NSString *bubbleDateString = [self.eventFormatter dateStringFromDate:bubbleData.date withTime:NO]; + + // Look for the current last bubble with date + NSInteger index = self->bubblesSnapshot.count; + NSString *lastBubbleDateString; + while (index--) + { + id previousLastBubbleData = self->bubblesSnapshot[index]; + lastBubbleDateString = [self.eventFormatter dateStringFromDate:previousLastBubbleData.date withTime:NO]; + + if (lastBubbleDateString) + { + break; + } + } + + if (lastBubbleDateString) + { + bubbleData.isPaginationFirstBubble = (bubbleDateString && ![bubbleDateString isEqualToString:lastBubbleDateString]); + } + else + { + bubbleData.isPaginationFirstBubble = (bubbleDateString != nil); + } + } + else + { + bubbleData.isPaginationFirstBubble = NO; + } + + // Check whether the sender information is relevant for this new bubble. + bubbleData.shouldHideSenderInformation = bubbleData.hasNoDisplay; + if (!bubbleData.shouldHideSenderInformation && self->bubblesSnapshot.count && (bubbleData.isPaginationFirstBubble == NO)) + { + // Check whether the previous bubble has been sent by the same user. + id previousLastBubbleData = self->bubblesSnapshot.lastObject; + bubbleData.shouldHideSenderInformation = [bubbleData hasSameSenderAsBubbleCellData:previousLastBubbleData]; + } + + // Insert the new bubble in last position + [self->bubblesSnapshot addObject:bubbleData]; + + addedLiveCellCount++; + } + } + else if (updatedBubbleDataHadNoDisplay && !bubbleData.hasNoDisplay) + { + // Here the event has been added in an existing bubble data which had no display, + // and the added event provides a display to this bubble data. + if (queuedEvent.direction == MXTimelineDirectionBackwards) + { + // The bubble is the first one. + + // Pagination handling + if ((self.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay) && bubbleData.date) + { + // A new pagination starts with this bubble data + bubbleData.isPaginationFirstBubble = YES; + + // Look for the first next bubble with date to check whether its pagination title is still relevant. + if (self->bubblesSnapshot.count) + { + NSInteger index = 1; + id nextBubbleDataWithDate; + NSString *firstNextBubbleDateString; + while (index < self->bubblesSnapshot.count) + { + nextBubbleDataWithDate = self->bubblesSnapshot[index++]; + firstNextBubbleDateString = [self.eventFormatter dateStringFromDate:nextBubbleDataWithDate.date withTime:NO]; + + if (firstNextBubbleDateString) + { + break; + } + } + + if (firstNextBubbleDateString) + { + NSString *bubbleDateString = [self.eventFormatter dateStringFromDate:bubbleData.date withTime:NO]; + nextBubbleDataWithDate.isPaginationFirstBubble = (bubbleDateString && ![firstNextBubbleDateString isEqualToString:bubbleDateString]); + } + } + } + else + { + bubbleData.isPaginationFirstBubble = NO; + } + + // Sender information are required for this new first bubble data + bubbleData.shouldHideSenderInformation = NO; + + // Check whether this information is still relevant for the next bubble. + if (self->bubblesSnapshot.count > 1) + { + id nextBubbleData = self->bubblesSnapshot[1]; + + if (nextBubbleData.isPaginationFirstBubble == NO) + { + // Check whether the current first bubble has been sent by the same user. + nextBubbleData.shouldHideSenderInformation |= [nextBubbleData hasSameSenderAsBubbleCellData:bubbleData]; + } + } + } + else + { + // The bubble data is the last one + + // Pagination handling + if (self.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay) + { + // Check whether a new pagination starts at this bubble + NSString *bubbleDateString = [self.eventFormatter dateStringFromDate:bubbleData.date withTime:NO]; + + // Look for the first previous bubble with date + NSInteger index = self->bubblesSnapshot.count - 1; + NSString *firstPreviousBubbleDateString; + while (index--) + { + id previousBubbleData = self->bubblesSnapshot[index]; + firstPreviousBubbleDateString = [self.eventFormatter dateStringFromDate:previousBubbleData.date withTime:NO]; + + if (firstPreviousBubbleDateString) + { + break; + } + } + + if (firstPreviousBubbleDateString) + { + bubbleData.isPaginationFirstBubble = (bubbleDateString && ![bubbleDateString isEqualToString:firstPreviousBubbleDateString]); + } + else + { + bubbleData.isPaginationFirstBubble = (bubbleDateString != nil); + } + } + else + { + bubbleData.isPaginationFirstBubble = NO; + } + + // Check whether the sender information is relevant for this new bubble. + bubbleData.shouldHideSenderInformation = NO; + if (self->bubblesSnapshot.count && (bubbleData.isPaginationFirstBubble == NO)) + { + // Check whether the previous bubble has been sent by the same user. + NSInteger index = self->bubblesSnapshot.count - 1; + if (index--) + { + id previousBubbleData = self->bubblesSnapshot[index]; + bubbleData.shouldHideSenderInformation = [bubbleData hasSameSenderAsBubbleCellData:previousBubbleData]; + } + } + } + } + + [self updateCellDataReactions:bubbleData forEventId:queuedEvent.event.eventId]; + + // Store event-bubble link to the map + @synchronized (self->eventIdToBubbleMap) + { + self->eventIdToBubbleMap[queuedEvent.event.eventId] = bubbleData; + } + + if (queuedEvent.event.isLocalEvent) + { + // Listen to the identifier change for the local events. + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(localEventDidChangeIdentifier:) name:kMXEventDidChangeIdentifierNotification object:queuedEvent.event]; + } + } + } + + for (MXKQueuedEvent *queuedEvent in self->eventsToProcessSnapshot) + { + @autoreleasepool + { + dispatch_group_enter(dispatchGroup); + [self addReadReceiptsForEvent:queuedEvent.event.eventId inCellDatas:self->bubblesSnapshot startingAtCellData:self->eventIdToBubbleMap[queuedEvent.event.eventId] completion:^{ + dispatch_group_leave(dispatchGroup); + }]; + } + } + + // Check if all cells of self.bubbles belongs to a single collapse series. + // In this case, collapsableSeriesAtStart and collapsableSeriesAtEnd must be equal + // in order to handle next forward or backward pagination. + if (self->collapsableSeriesAtStart && self->collapsableSeriesAtStart == self->bubbles.firstObject) + { + // Find the tail + id tailBubbleData = self->collapsableSeriesAtStart; + while (tailBubbleData.nextCollapsableCellData) + { + tailBubbleData = tailBubbleData.nextCollapsableCellData; + } + + if (tailBubbleData == self->bubbles.lastObject) + { + self->collapsableSeriesAtEnd = self->collapsableSeriesAtStart; + } + } + else if (self->collapsableSeriesAtEnd) + { + // Find the start + id startBubbleData = self->collapsableSeriesAtEnd; + while (startBubbleData.prevCollapsableCellData) + { + startBubbleData = startBubbleData.prevCollapsableCellData; + } + + if (startBubbleData == self->bubbles.firstObject) + { + self->collapsableSeriesAtStart = self->collapsableSeriesAtEnd; + } + } + + // Compose (= compute collapsedAttributedTextMessage) of collapsable seriess + for (id bubbleData in collapsingCellDataSeriess) + { + // Get all events of the series + NSMutableArray *events = [NSMutableArray array]; + id nextBubbleData = bubbleData; + do + { + [events addObjectsFromArray:nextBubbleData.events]; + } + while ((nextBubbleData = nextBubbleData.nextCollapsableCellData)); + + // Build the summary string for the series + bubbleData.collapsedAttributedTextMessage = [self.eventFormatter attributedStringFromEvents:events withRoomState:bubbleData.collapseState error:nil]; + + // Release collapseState objects, even the one of collapsableSeriesAtStart. + // We do not need to keep its state because if an collapsable event comes before collapsableSeriesAtStart, + // we will take the room state of this event. + if (bubbleData != self->collapsableSeriesAtEnd) + { + bubbleData.collapseState = nil; + } + } + } + self->eventsToProcessSnapshot = nil; + } + + // Check whether some events have been processed + if (self->bubblesSnapshot) + { + // Updated data can be displayed now + // Block MXKRoomDataSource.processingQueue while the processing is finalised on the main thread + dispatch_group_wait(dispatchGroup, DISPATCH_TIME_FOREVER); + + dispatch_sync(dispatch_get_main_queue(), ^{ + // Check whether self has not been reloaded or destroyed + if (self.state == MXKDataSourceStateReady && self->bubblesSnapshot) + { + if (self.serverSyncEventCount) + { + self->_serverSyncEventCount -= serverSyncEventCount; + if (!self.serverSyncEventCount) + { + // Notify that sync process ends + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKRoomDataSourceSyncStatusChanged object:self userInfo:nil]; + } + } + if (self.secondaryRoom) { + [self->bubblesSnapshot sortWithOptions:NSSortStable + usingComparator:^NSComparisonResult(MXKRoomBubbleCellData * _Nonnull bubbleData1, MXKRoomBubbleCellData * _Nonnull bubbleData2) { + if (bubbleData1.date) + { + if (bubbleData2.date) + { + return [bubbleData1.date compare:bubbleData2.date]; + } + else + { + return NSOrderedDescending; + } + } + else + { + if (bubbleData2.date) + { + return NSOrderedAscending; + } + else + { + return NSOrderedSame; + } + } + }]; + } + self->bubbles = self->bubblesSnapshot; + self->bubblesSnapshot = nil; + + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } + else + { + // Check the memory usage of the data source. Reload it if the cache is too huge. + [self limitMemoryUsage:self.maxBackgroundCachedBubblesCount]; + } + } + + // Inform about the end if requested + if (onComplete) + { + onComplete(addedHistoryCellCount, addedLiveCellCount); + } + }); + } + else + { + // No new event has been added, we just inform about the end if requested. + if (onComplete) + { + dispatch_group_notify(dispatchGroup, dispatch_get_main_queue(), ^{ + onComplete(0, 0); + }); + } + } + }); +} + +/** + Add the read receipts of an event into the timeline (which is in array of cell datas) + + If the event is not displayed, read receipts will be added to a previous displayed message. + + @param eventId the id of the event. + @param cellDatas the working array of cell datas. + @param cellData the original cell data the event belongs to. + */ +- (void)addReadReceiptsForEvent:(NSString*)eventId inCellDatas:(NSArray>*)cellDatas startingAtCellData:(id)cellData completion:(void (^)(void))completion +{ + if (self.showBubbleReceipts) + { + if (self.room) + { + [self.room getEventReceipts:eventId sorted:YES completion:^(NSArray * _Nonnull readReceipts) { + if (readReceipts.count) + { + NSInteger cellDataIndex = [cellDatas indexOfObject:cellData]; + if (cellDataIndex != NSNotFound) + { + [self addReadReceipts:readReceipts forEvent:eventId inCellDatas:cellDatas atCellDataIndex:cellDataIndex]; + } + } + + if (completion) + { + completion(); + } + }]; + } + else if (completion) + { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(); + }); + } + } + else if (completion) + { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(); + }); + } +} + +- (void)addReadReceipts:(NSArray *)readReceipts forEvent:(NSString*)eventId inCellDatas:(NSArray>*)cellDatas atCellDataIndex:(NSInteger)cellDataIndex +{ + id cellData = cellDatas[cellDataIndex]; + + if ([cellData isKindOfClass:MXKRoomBubbleCellData.class]) + { + MXKRoomBubbleCellData *roomBubbleCellData = (MXKRoomBubbleCellData*)cellData; + + BOOL areReadReceiptsAssigned = NO; + for (MXKRoomBubbleComponent *component in roomBubbleCellData.bubbleComponents.reverseObjectEnumerator) + { + if (component.attributedTextMessage) + { + if (roomBubbleCellData.readReceipts[component.event.eventId]) + { + NSArray *currentReadReceipts = roomBubbleCellData.readReceipts[component.event.eventId]; + NSMutableArray *newReadReceipts = [NSMutableArray arrayWithArray:currentReadReceipts]; + for (MXReceiptData *readReceipt in readReceipts) + { + BOOL alreadyHere = NO; + for (MXReceiptData *currentReadReceipt in currentReadReceipts) + { + if ([readReceipt.userId isEqualToString:currentReadReceipt.userId]) + { + alreadyHere = YES; + break; + } + } + + if (!alreadyHere) + { + [newReadReceipts addObject:readReceipt]; + } + } + [self updateCellData:roomBubbleCellData withReadReceipts:newReadReceipts forEventId:component.event.eventId]; + } + else + { + [self updateCellData:roomBubbleCellData withReadReceipts:readReceipts forEventId:component.event.eventId]; + } + areReadReceiptsAssigned = YES; + break; + } + + MXLogDebug(@"[MXKRoomDataSource][%p] addReadReceipts: Read receipts for an event(%@) that is not displayed", self, eventId); + } + + if (!areReadReceiptsAssigned) + { + MXLogDebug(@"[MXKRoomDataSource][%p] addReadReceipts: Try to attach read receipts to an older message: %@", self, eventId); + + // Try to assign RRs to a previous cell data + if (cellDataIndex >= 1) + { + [self addReadReceipts:readReceipts forEvent:eventId inCellDatas:cellDatas atCellDataIndex:cellDataIndex - 1]; + } + else + { + MXLogDebug(@"[MXKRoomDataSource][%p] addReadReceipts: Fail to attach read receipts for an event(%@)", self, eventId); + } + } + } +} + + +#pragma mark - UITableViewDataSource +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + // PATCH: Presently no bubble must be displayed until the user joins the room. + // FIXME: Handle room data source in case of room preview + if (self.room.summary.membership == MXMembershipInvite) + { + return 0; + } + + NSInteger count; + @synchronized(bubbles) + { + count = bubbles.count; + } + return count; +} + +- (void)scanBubbleDataIfNeeded:(id)bubbleData +{ + MXScanManager *scanManager = self.mxSession.scanManager; + + if (!scanManager && ![bubbleData isKindOfClass:MXKRoomBubbleCellData.class]) + { + return; + } + + MXKRoomBubbleCellData *roomBubbleCellData = (MXKRoomBubbleCellData*)bubbleData; + + NSString *contentURL = roomBubbleCellData.attachment.contentURL; + + // If the content url corresponds to an upload id, the upload is in progress or not complete. + // Create a fake event scan with in progress status when uploading media. + // Since there is no event scan in database it will be overriden by MXScanManager on media upload complete. + if (contentURL && [contentURL hasPrefix:kMXMediaUploadIdPrefix]) + { + MXKRoomBubbleComponent *firstBubbleComponent = roomBubbleCellData.bubbleComponents.firstObject; + MXEvent *firstBubbleComponentEvent = firstBubbleComponent.event; + + if (firstBubbleComponent && firstBubbleComponent.eventScan.antivirusScanStatus != MXAntivirusScanStatusInProgress && firstBubbleComponentEvent) + { + MXEventScan *uploadEventScan = [MXEventScan new]; + uploadEventScan.eventId = firstBubbleComponentEvent.eventId; + uploadEventScan.antivirusScanStatus = MXAntivirusScanStatusInProgress; + uploadEventScan.antivirusScanDate = nil; + uploadEventScan.mediaScans = @[]; + + firstBubbleComponent.eventScan = uploadEventScan; + } + } + else + { + for (MXKRoomBubbleComponent *bubbleComponent in roomBubbleCellData.bubbleComponents) + { + MXEvent *event = bubbleComponent.event; + + if ([event isContentScannable]) + { + [scanManager scanEventIfNeeded:event]; + // NOTE: - [MXScanManager scanEventIfNeeded:] perform modification in background, so - [MXScanManager eventScanWithId:] do not retrieve the last state of event scan. + // It is noticeable when eventScan should be created for the first time. It would be better to return an eventScan with an in progress scan status instead of nil. + MXEventScan *eventScan = [scanManager eventScanWithId:event.eventId]; + bubbleComponent.eventScan = eventScan; + } + } + } +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UITableViewCell *cell; + + id bubbleData = [self cellDataAtIndex:indexPath.row]; + + // Launch an antivirus scan on events contained in bubble data if needed + [self scanBubbleDataIfNeeded:bubbleData]; + + if (bubbleData && self.delegate) + { + // Retrieve the cell identifier according to cell data. + NSString *identifier = [self.delegate cellReuseIdentifierForCellData:bubbleData]; + if (identifier) + { + cell = [tableView dequeueReusableCellWithIdentifier:identifier forIndexPath:indexPath]; + + // Make sure we listen to user actions on the cell + cell.delegate = self; + + // Update typing flag before rendering + bubbleData.isTyping = _showTypingNotifications && currentTypingUsers && ([currentTypingUsers indexOfObject:bubbleData.senderId] != NSNotFound); + // Report the current timestamp display option + bubbleData.showBubbleDateTime = self.showBubblesDateTime; + // display the read receipts + bubbleData.showBubbleReceipts = self.showBubbleReceipts; + // let the caller application manages the time label? + bubbleData.useCustomDateTimeLabel = self.useCustomDateTimeLabel; + // let the caller application manages the receipt? + bubbleData.useCustomReceipts = self.useCustomReceipts; + // let the caller application manages the unsent button? + bubbleData.useCustomUnsentButton = self.useCustomUnsentButton; + + // Make the bubble display the data + [cell render:bubbleData]; + } + } + + // Sanity check: this method may be called during a layout refresh while room data have been modified. + if (!cell) + { + // Return an empty cell + return [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"fakeCell"]; + } + + return cell; +} + +#pragma mark - Groups + +- (MXGroup *)groupWithGroupId:(NSString*)groupId +{ + MXGroup *group = [self.mxSession groupWithGroupId:groupId]; + if (!group) + { + // Check whether an instance has been already created. + group = [externalRelatedGroups objectForKey:groupId]; + } + + if (!group) + { + // Create a new group instance. + group = [[MXGroup alloc] initWithGroupId:groupId]; + [externalRelatedGroups setObject:group forKey:groupId]; + + // Retrieve at least the group profile + [self.mxSession updateGroupProfile:group success:nil failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomDataSource][%p] groupWithGroupId: group profile update failed %@", self, groupId); + + }]; + } + + return group; +} + +#pragma mark - MXScanManager notifications + +- (void)registerScanManagerNotifications +{ + [[NSNotificationCenter defaultCenter] removeObserver:self name:MXScanManagerEventScanDidChangeNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(eventScansDidChange:) name:MXScanManagerEventScanDidChangeNotification object:nil]; +} + +- (void)unregisterScanManagerNotifications +{ + [[NSNotificationCenter defaultCenter] removeObserver:self name:MXScanManagerEventScanDidChangeNotification object:nil]; +} + +- (void)eventScansDidChange:(NSNotification*)notification +{ + // TODO: Avoid to call the delegate to often. Set a minimum time interval to avoid table view flickering. + [self.delegate dataSource:self didCellChange:nil]; +} + + +#pragma mark - Reactions + +- (void)registerReactionsChangeListener +{ + if (!self.showReactions || reactionsChangeListener) + { + return; + } + + MXWeakify(self); + reactionsChangeListener = [self.mxSession.aggregations listenToReactionCountUpdateInRoom:self.roomId block:^(NSDictionary * _Nonnull changes) { + MXStrongifyAndReturnIfNil(self); + + BOOL updated = NO; + for (NSString *eventId in changes) + { + id bubbleData = [self cellDataOfEventWithEventId:eventId]; + if (bubbleData) + { + // TODO: Be smarted and use changes[eventId] + [self updateCellDataReactions:bubbleData forEventId:eventId]; + updated = YES; + } + } + + if (updated) + { + [self.delegate dataSource:self didCellChange:nil]; + } + }]; +} + +- (void)unregisterReactionsChangeListener +{ + if (reactionsChangeListener) + { + [self.mxSession.aggregations removeListener:reactionsChangeListener]; + reactionsChangeListener = nil; + } +} + +- (void)updateCellDataReactions:(id)cellData forEventId:(NSString*)eventId +{ + if (!self.showReactions || ![cellData isKindOfClass:MXKRoomBubbleCellData.class]) + { + return; + } + + MXKRoomBubbleCellData *roomBubbleCellData = (MXKRoomBubbleCellData*)cellData; + + MXAggregatedReactions *aggregatedReactions = [self.mxSession.aggregations aggregatedReactionsOnEvent:eventId inRoom:self.roomId].aggregatedReactionsWithNonZeroCount; + + if (self.showOnlySingleEmojiReactions) + { + aggregatedReactions = aggregatedReactions.aggregatedReactionsWithSingleEmoji; + } + + if (aggregatedReactions) + { + if (!roomBubbleCellData.reactions) + { + roomBubbleCellData.reactions = [NSMutableDictionary dictionary]; + } + + roomBubbleCellData.reactions[eventId] = aggregatedReactions; + } + else + { + // unreaction + roomBubbleCellData.reactions[eventId] = nil; + } + + // Indicate that the text message layout should be recomputed. + [roomBubbleCellData invalidateTextLayout]; +} + +- (BOOL)canReactToEventWithId:(NSString*)eventId +{ + BOOL canReact = NO; + + MXEvent *event = [self eventWithEventId:eventId]; + + if ([self canPerformActionOnEvent:event]) + { + NSString *messageType = event.content[@"msgtype"]; + + if ([messageType isEqualToString:kMXMessageTypeKeyVerificationRequest]) + { + canReact = NO; + } + else + { + canReact = YES; + } + } + + return canReact; +} + +- (void)addReaction:(NSString *)reaction forEventId:(NSString *)eventId success:(void (^)(void))success failure:(void (^)(NSError *))failure +{ + [self.mxSession.aggregations addReaction:reaction forEvent:eventId inRoom:self.roomId success:success failure:^(NSError * _Nonnull error) { + MXLogDebug(@"[MXKRoomDataSource][%p] Fail to send reaction on eventId: %@", self, eventId); + if (failure) + { + failure(error); + } + }]; +} + +- (void)removeReaction:(NSString *)reaction forEventId:(NSString *)eventId success:(void (^)(void))success failure:(void (^)(NSError *))failure +{ + [self.mxSession.aggregations removeReaction:reaction forEvent:eventId inRoom:self.roomId success:success failure:^(NSError * _Nonnull error) { + MXLogDebug(@"[MXKRoomDataSource][%p] Fail to unreact on eventId: %@", self, eventId); + if (failure) + { + failure(error); + } + }]; +} + +#pragma mark - Editions + +- (BOOL)canEditEventWithId:(NSString*)eventId +{ + MXEvent *event = [self eventWithEventId:eventId]; + BOOL isRoomMessage = event.eventType == MXEventTypeRoomMessage; + NSString *messageType = event.content[@"msgtype"]; + + return isRoomMessage + && ([messageType isEqualToString:kMXMessageTypeText] || [messageType isEqualToString:kMXMessageTypeEmote]) + && [event.sender isEqualToString:self.mxSession.myUserId] + && [event.roomId isEqualToString:self.roomId]; +} + +- (NSString*)editableTextMessageForEvent:(MXEvent*)event +{ + NSString *editableTextMessage; + + if (event.isReplyEvent) + { + MXReplyEventParser *replyEventParser = [MXReplyEventParser new]; + MXReplyEventParts *replyEventParts = [replyEventParser parse:event]; + + editableTextMessage = replyEventParts.bodyParts.replyText; + } + else + { + editableTextMessage = event.content[@"body"]; + } + + return editableTextMessage; +} + +- (void)registerEventEditsListener +{ + if (eventEditsListener) + { + return; + } + + MXWeakify(self); + eventEditsListener = [self.mxSession.aggregations listenToEditsUpdateInRoom:self.roomId block:^(MXEvent * _Nonnull replaceEvent) { + MXStrongifyAndReturnIfNil(self); + + [self updateEventWithReplaceEvent:replaceEvent]; + }]; +} + +- (void)updateEventWithReplaceEvent:(MXEvent*)replaceEvent +{ + NSString *editedEventId = replaceEvent.relatesTo.eventId; + + dispatch_async(MXKRoomDataSource.processingQueue, ^{ + + // Check whether a message contains the edited event + id bubbleData = [self cellDataOfEventWithEventId:editedEventId]; + if (bubbleData) + { + BOOL hasChanged = [self updateCellData:bubbleData forEditionWithReplaceEvent:replaceEvent andEventId:editedEventId]; + + if (hasChanged) + { + // Update the delegate on main thread + dispatch_async(dispatch_get_main_queue(), ^{ + + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } + + }); + } + } + }); +} + +- (void)unregisterEventEditsListener +{ + if (eventEditsListener) + { + [self.mxSession.aggregations removeListener:eventEditsListener]; + eventEditsListener = nil; + } +} + +- (BOOL)updateCellData:(id)bubbleCellData forEditionWithReplaceEvent:(MXEvent*)replaceEvent andEventId:(NSString*)eventId +{ + BOOL hasChanged = NO; + + @synchronized (bubbleCellData) + { + // Retrieve the original event to edit it + NSArray *events = bubbleCellData.events; + MXEvent *editedEvent = nil; + + // If not already done, update edited event content in-place + // This is required for: + // - local echo + // - non live timeline in memory store (permalink) + for (MXEvent *event in events) + { + if ([event.eventId isEqualToString:eventId]) + { + // Check whether the event was not already edited + if (![event.unsignedData.relations.replace.eventId isEqualToString:replaceEvent.eventId]) + { + editedEvent = [event editedEventFromReplacementEvent:replaceEvent]; + } + break; + } + } + + if (editedEvent) + { + if (editedEvent.sentState != replaceEvent.sentState) + { + // Relay the replace event state to the edited event so that the display + // of the edited will rerun the classic sending color flow. + // Note: this must be done on the main thread (this operation triggers + // the call of [self eventDidChangeSentState]) + dispatch_async(dispatch_get_main_queue(), ^{ + editedEvent.sentState = replaceEvent.sentState; + }); + } + + [bubbleCellData updateEvent:eventId withEvent:editedEvent]; + [bubbleCellData invalidateTextLayout]; + hasChanged = YES; + } + } + + return hasChanged; +} + +- (void)replaceTextMessageForEventWithId:(NSString*)eventId + withTextMessage:(NSString *)text + success:(void (^)(NSString *))success + failure:(void (^)(NSError *))failure +{ + MXEvent *event = [self eventWithEventId:eventId]; + + NSString *sanitizedText = [self sanitizedMessageText:text]; + NSString *formattedText = [self htmlMessageFromSanitizedText:sanitizedText]; + + NSString *eventBody = event.content[@"body"]; + NSString *eventFormattedBody = event.content[@"formatted_body"]; + + if (![sanitizedText isEqualToString:eventBody] && (!eventFormattedBody || ![formattedText isEqualToString:eventFormattedBody])) + { + [self.mxSession.aggregations replaceTextMessageEvent:event withTextMessage:sanitizedText formattedText:formattedText localEchoBlock:^(MXEvent * _Nonnull replaceEventLocalEcho) { + + // Apply the local echo to the timeline + [self updateEventWithReplaceEvent:replaceEventLocalEcho]; + + // Integrate the replace local event into the timeline like when sending a message + // This also allows to manage read receipt on this replace event + [self queueEventForProcessing:replaceEventLocalEcho withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + + } success:success failure:failure]; + } + else + { + failure(nil); + } +} + +#pragma mark - Virtual Rooms + +- (void)virtualRoomsDidChange:(NSNotification *)notification +{ + // update secondary room id + self.secondaryRoomId = [self.mxSession virtualRoomOf:self.roomId]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSourceManager.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSourceManager.h new file mode 100644 index 000000000..46f6709da --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSourceManager.h @@ -0,0 +1,124 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKRoomDataSource.h" + +/** + `MXKRoomDataSourceManagerReleasePolicy` defines how a `MXKRoomDataSource` instance must be released + when [MXKRoomDataSourceManager closeRoomDataSourceWithRoomId:] is called. + + Once released, the in-memory data (messages that are outgoing, failed sending, ...) of room data source + is lost. + */ +typedef enum : NSUInteger { + + /** + Created `MXKRoomDataSource` instances are never released when they are closed. + */ + MXKRoomDataSourceManagerReleasePolicyNeverRelease, + + /** + Created `MXKRoomDataSource` instances are released when they are closed. + */ + MXKRoomDataSourceManagerReleasePolicyReleaseOnClose, + +} MXKRoomDataSourceManagerReleasePolicy; + + +/** + `MXKRoomDataSourceManager` manages a pool of `MXKRoomDataSource` instances for a given Matrix session. + + It makes the `MXKRoomDataSource` instances reusable so that their data (messages that are outgoing, failed sending, ...) + is not lost when the view controller that displays them is gone. + */ +@interface MXKRoomDataSourceManager : NSObject + +/** + Retrieve the MXKRoomDataSources manager for a particular Matrix session. + + @param mxSession the Matrix session, + @return the MXKRoomDataSources manager to use for this session. + */ ++ (MXKRoomDataSourceManager*)sharedManagerForMatrixSession:(MXSession*)mxSession; + +/** + Remove the MXKRoomDataSources manager for a particular Matrix session. + + @param mxSession the Matrix session. + */ ++ (void)removeSharedManagerForMatrixSession:(MXSession*)mxSession; + +/** + Register the MXKRoomDataSource-inherited class that will be used to instantiate all room data source. + By default MXKRoomDataSource class is considered. + + CAUTION: All existing room data source instances are reset in case of class change. + + @param roomDataSourceClass a MXKRoomDataSource-inherited class. + */ ++ (void)registerRoomDataSourceClass:(Class)roomDataSourceClass; + +/** + Force close all the current room data source instances. + */ +- (void)reset; + +/** + Get a room data source corresponding to a room id. + + If a room data source already exists for this room, its reference will be returned. Else, + if requested, the method will instantiate it. + + @param roomId the room id of the room. + @param create if YES, the MXKRoomDataSourceManager will create the room data source if it does not exist yet. + @param onComplete blocked with the room data source (instance of MXKRoomDataSource-inherited class). + */ +- (void)roomDataSourceForRoom:(NSString*)roomId create:(BOOL)create onComplete:(void (^)(MXKRoomDataSource *roomDataSource))onComplete; + +/** + Make a room data source be managed by the manager. + + Use this method to add a MXKRoomDataSource-inherited instance that cannot be automatically created by + [MXKRoomDataSourceManager roomDataSourceForRoom: create:]. + + @param roomDataSource the MXKRoomDataSource-inherited object to the manager scope. + */ +- (void)addRoomDataSource:(MXKRoomDataSource*)roomDataSource; + +/** + Close the roomDataSource. + + The roomDataSource instance will be actually destroyed according to the current release policy. + + @param roomId the room if of the data source to release. + @param forceRelease if yes the room data source instance will be destroyed whatever the policy is. + */ +- (void)closeRoomDataSourceWithRoomId:(NSString*)roomId forceClose:(BOOL)forceRelease; + +/** + The release policy to apply when `MXKRoomDataSource` instances are closed. + Default is MXKRoomDataSourceManagerReleasePolicyNeverRelease. + */ +@property (nonatomic) MXKRoomDataSourceManagerReleasePolicy releasePolicy; + +/** + Tells whether a server sync is in progress in the matrix session. + */ +@property (nonatomic, readonly) BOOL isServerSyncInProgress; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSourceManager.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSourceManager.m new file mode 100644 index 000000000..fd4e54c8a --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSourceManager.m @@ -0,0 +1,271 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomDataSourceManager.h" + +@interface MXKRoomDataSourceManager() +{ + MXSession *mxSession; + + /** + The list of running roomDataSources. + Each key is a room ID. Each value, the MXKRoomDataSource instance. + */ + NSMutableDictionary *roomDataSources; + + /** + Observe UIApplicationDidReceiveMemoryWarningNotification to dispose of any resources that can be recreated. + */ + id UIApplicationDidReceiveMemoryWarningNotificationObserver; +} + +@end + +static NSMutableDictionary *_roomDataSourceManagers = nil; +static Class _roomDataSourceClass; + +@implementation MXKRoomDataSourceManager + ++ (MXKRoomDataSourceManager *)sharedManagerForMatrixSession:(MXSession *)mxSession +{ + // Manage a pool of managers: one per Matrix session + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + _roomDataSourceManagers = [NSMutableDictionary dictionary]; + }); + + MXKRoomDataSourceManager *roomDataSourceManager; + + // Compute an id for this mxSession object: its pointer address as a string + NSString *mxSessionId = [NSString stringWithFormat:@"%p", mxSession]; + + @synchronized(_roomDataSourceManagers) + { + if (_roomDataSourceClass == nil) + { + // Set default class + _roomDataSourceClass = MXKRoomDataSource.class; + } + // If not available yet, create the `MXKRoomDataSourceManager` for this Matrix session + roomDataSourceManager = _roomDataSourceManagers[mxSessionId]; + if (!roomDataSourceManager) + { + roomDataSourceManager = [[MXKRoomDataSourceManager alloc]initWithMatrixSession:mxSession]; + _roomDataSourceManagers[mxSessionId] = roomDataSourceManager; + } + } + + return roomDataSourceManager; +} + ++ (void)removeSharedManagerForMatrixSession:(MXSession*)mxSession +{ + // Compute the id for this mxSession object: its pointer address as a string + NSString *mxSessionId = [NSString stringWithFormat:@"%p", mxSession]; + + @synchronized(_roomDataSourceManagers) + { + MXKRoomDataSourceManager *roomDataSourceManager = [_roomDataSourceManagers objectForKey:mxSessionId]; + if (roomDataSourceManager) + { + [roomDataSourceManager destroy]; + [_roomDataSourceManagers removeObjectForKey:mxSessionId]; + } + } +} + ++ (void)registerRoomDataSourceClass:(Class)roomDataSourceClass +{ + // Sanity check: accept only MXKRoomDataSource classes or sub-classes + NSParameterAssert([roomDataSourceClass isSubclassOfClass:MXKRoomDataSource.class]); + + @synchronized(_roomDataSourceManagers) + { + if (roomDataSourceClass !=_roomDataSourceClass) + { + _roomDataSourceClass = roomDataSourceClass; + + NSArray *mxSessionIds = _roomDataSourceManagers.allKeys; + for (NSString *mxSessionId in mxSessionIds) + { + MXKRoomDataSourceManager *roomDataSourceManager = [_roomDataSourceManagers objectForKey:mxSessionId]; + if (roomDataSourceManager) + { + [roomDataSourceManager destroy]; + [_roomDataSourceManagers removeObjectForKey:mxSessionId]; + } + } + } + } +} + +- (instancetype)initWithMatrixSession:(MXSession *)matrixSession +{ + self = [super init]; + if (self) + { + mxSession = matrixSession; + roomDataSources = [NSMutableDictionary dictionary]; + _releasePolicy = MXKRoomDataSourceManagerReleasePolicyNeverRelease; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXSessionDidLeaveRoom:) name:kMXSessionDidLeaveRoomNotification object:nil]; + + // Observe UIApplicationDidReceiveMemoryWarningNotification + UIApplicationDidReceiveMemoryWarningNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidReceiveMemoryWarningNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXLogDebug(@"[MXKRoomDataSourceManager] %@: Received memory warning.", self); + + // Reload all data sources (except the current used ones) to reduce memory usage. + for (MXKRoomDataSource *roomDataSource in self->roomDataSources.allValues) + { + if (!roomDataSource.delegate) + { + [roomDataSource reload]; + } + } + + }]; + } + return self; +} + +- (void)dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidLeaveRoomNotification object:nil]; +} + +- (void)destroy +{ + [self reset]; + + if (UIApplicationDidReceiveMemoryWarningNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:UIApplicationDidReceiveMemoryWarningNotificationObserver]; + UIApplicationDidReceiveMemoryWarningNotificationObserver = nil; + } +} + +#pragma mark + +- (BOOL)isServerSyncInProgress +{ + // Check first the matrix session state + if (mxSession.state == MXSessionStateSyncInProgress) + { + return YES; + } + + // Check all data sources (events process is asynchronous, server sync may not be complete in data source). + for (MXKRoomDataSource *roomDataSource in roomDataSources.allValues) + { + if (roomDataSource.serverSyncEventCount) + { + return YES; + } + } + + return NO; +} + +#pragma mark + +- (void)reset +{ + NSArray *roomIds = roomDataSources.allKeys; + for (NSString *roomId in roomIds) + { + [self closeRoomDataSourceWithRoomId:roomId forceClose:YES]; + } +} + +- (void)roomDataSourceForRoom:(NSString *)roomId create:(BOOL)create onComplete:(void (^)(MXKRoomDataSource *roomDataSource))onComplete +{ + NSParameterAssert(roomId); + + // If not available yet, create the room data source + MXKRoomDataSource *roomDataSource = roomDataSources[roomId]; + + if (!roomDataSource && create && roomId) + { + [_roomDataSourceClass loadRoomDataSourceWithRoomId:roomId andMatrixSession:mxSession onComplete:^(id roomDataSource) { + [self addRoomDataSource:roomDataSource]; + onComplete(roomDataSource); + }]; + } + else + { + onComplete(roomDataSource); + } +} + +- (void)addRoomDataSource:(MXKRoomDataSource *)roomDataSource +{ + roomDataSources[roomDataSource.roomId] = roomDataSource; +} + +- (void)closeRoomDataSourceWithRoomId:(NSString*)roomId forceClose:(BOOL)forceRelease; +{ + // Check first whether this roomDataSource is well handled by this manager + if (!roomId || !roomDataSources[roomId]) + { + MXLogDebug(@"[MXKRoomDataSourceManager] Failed to close an unknown room id: %@", roomId); + return; + } + + MXKRoomDataSource *roomDataSource = roomDataSources[roomId]; + + // According to the policy, it is interesting to keep the room data source in life: it can keep managing echo messages + // in background for instance + MXKRoomDataSourceManagerReleasePolicy releasePolicy = _releasePolicy; + if (forceRelease) + { + // Act as ReleaseOnClose policy + releasePolicy = MXKRoomDataSourceManagerReleasePolicyReleaseOnClose; + } + + switch (releasePolicy) + { + case MXKRoomDataSourceManagerReleasePolicyReleaseOnClose: + + // Destroy and forget the instance + [roomDataSource destroy]; + [roomDataSources removeObjectForKey:roomDataSource.roomId]; + break; + + case MXKRoomDataSourceManagerReleasePolicyNeverRelease: + + // The close here consists in no more sending actions to the current view controller, the room data source delegate + roomDataSource.delegate = nil; + + // Keep the instance for life (reduce memory usage by flushing room data if the number of bubbles is over 30). + [roomDataSource limitMemoryUsage:roomDataSource.maxBackgroundCachedBubblesCount]; + break; + + default: + break; + } +} + +- (void)didMXSessionDidLeaveRoom:(NSNotification *)notif +{ + if (mxSession == notif.object) + { + // The room is no more available, remove it from the manager + [self closeRoomDataSourceWithRoomId:notif.userInfo[kMXSessionNotificationRoomIdKey] forceClose:YES]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.h b/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.h new file mode 100644 index 000000000..6d0d4b842 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.h @@ -0,0 +1,25 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +/** + A `MXKSendReplyEventStringLocalizer` instance represents string localizations used when send reply event to a message in a room. + */ +@interface MXKSendReplyEventStringLocalizer : NSObject + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.m b/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.m new file mode 100644 index 000000000..f116c466b --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.m @@ -0,0 +1,53 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + + +#import "MXKSendReplyEventStringLocalizer.h" +#import "MXKSwiftHeader.h" + +@implementation MXKSendReplyEventStringLocalizer + +- (NSString *)senderSentAnImage +{ + return [MatrixKitL10n messageReplyToSenderSentAnImage]; +} + +- (NSString *)senderSentAVideo +{ + return [MatrixKitL10n messageReplyToSenderSentAVideo]; +} + +- (NSString *)senderSentAnAudioFile +{ + return [MatrixKitL10n messageReplyToSenderSentAnAudioFile]; +} + +- (NSString *)senderSentAVoiceMessage +{ + return [MatrixKitL10n messageReplyToSenderSentAVoiceMessage]; +} + +- (NSString *)senderSentAFile +{ + return [MatrixKitL10n messageReplyToSenderSentAFile]; +} + +- (NSString *)messageToReplyToPrefix +{ + return [MatrixKitL10n messageReplyToMessageToReplyToPrefix]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.h b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.h new file mode 100644 index 000000000..d2791b9cf --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.h @@ -0,0 +1,33 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +@import Foundation; + +/** + Slash commands used to perform actions from a room. + */ + +FOUNDATION_EXPORT NSString *const kMXKSlashCmdChangeDisplayName; +FOUNDATION_EXPORT NSString *const kMXKSlashCmdEmote; +FOUNDATION_EXPORT NSString *const kMXKSlashCmdJoinRoom; +FOUNDATION_EXPORT NSString *const kMXKSlashCmdPartRoom; +FOUNDATION_EXPORT NSString *const kMXKSlashCmdInviteUser; +FOUNDATION_EXPORT NSString *const kMXKSlashCmdKickUser; +FOUNDATION_EXPORT NSString *const kMXKSlashCmdBanUser; +FOUNDATION_EXPORT NSString *const kMXKSlashCmdUnbanUser; +FOUNDATION_EXPORT NSString *const kMXKSlashCmdSetUserPowerLevel; +FOUNDATION_EXPORT NSString *const kMXKSlashCmdResetUserPowerLevel; +FOUNDATION_EXPORT NSString *const kMXKSlashCmdChangeRoomTopic; diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.m b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.m new file mode 100644 index 000000000..b83e42f3e --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.m @@ -0,0 +1,29 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKSlashCommands.h" + +NSString *const kMXKSlashCmdChangeDisplayName = @"/nick"; +NSString *const kMXKSlashCmdEmote = @"/me"; +NSString *const kMXKSlashCmdJoinRoom = @"/join"; +NSString *const kMXKSlashCmdPartRoom = @"/part"; +NSString *const kMXKSlashCmdInviteUser = @"/invite"; +NSString *const kMXKSlashCmdKickUser = @"/kick"; +NSString *const kMXKSlashCmdBanUser = @"/ban"; +NSString *const kMXKSlashCmdUnbanUser = @"/unban"; +NSString *const kMXKSlashCmdSetUserPowerLevel = @"/op"; +NSString *const kMXKSlashCmdResetUserPowerLevel = @"/deop"; +NSString *const kMXKSlashCmdChangeRoomTopic = @"/topic"; diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKURLPreviewDataProtocol.h b/Riot/Modules/MatrixKit/Models/Room/MXKURLPreviewDataProtocol.h new file mode 100644 index 000000000..3518d81f8 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKURLPreviewDataProtocol.h @@ -0,0 +1,40 @@ +// +// Copyright 2020 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +@protocol MXKURLPreviewDataProtocol + +/// The URL that's represented by the preview data. +@property (readonly, nonnull) NSURL *url; + +/// The ID of the event that created this preview. +@property (readonly, nonnull) NSString *eventID; + +/// The ID of the room that this preview is from. +@property (readonly, nonnull) NSString *roomID; + +/// The OpenGraph site name for the URL. +@property (readonly, nullable) NSString *siteName; + +/// The OpenGraph title for the URL. +@property (readonly, nullable) NSString *title; + +/// The OpenGraph description for the URL. +@property (readonly, nullable) NSString *text; + +/// The OpenGraph image for the URL. +@property (readwrite, nullable) UIImage *image; + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomList/MXKInterleavedRecentsDataSource.h b/Riot/Modules/MatrixKit/Models/RoomList/MXKInterleavedRecentsDataSource.h new file mode 100644 index 000000000..646dfc18a --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomList/MXKInterleavedRecentsDataSource.h @@ -0,0 +1,26 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRecentsDataSource.h" + +/** + 'MXKInterleavedRecentsDataSource' class inherits from 'MXKRecentsDataSource'. + + It interleaves the recents in case of multiple sessions to display first the most recent room. + */ +@interface MXKInterleavedRecentsDataSource : MXKRecentsDataSource + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomList/MXKInterleavedRecentsDataSource.m b/Riot/Modules/MatrixKit/Models/RoomList/MXKInterleavedRecentsDataSource.m new file mode 100644 index 000000000..28bb4c6ae --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomList/MXKInterleavedRecentsDataSource.m @@ -0,0 +1,439 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKInterleavedRecentsDataSource.h" + +#import "MXKInterleavedRecentTableViewCell.h" + +#import "MXKAccountManager.h" + +#import "NSBundle+MatrixKit.h" + +@interface MXKInterleavedRecentsDataSource () +{ + /** + The interleaved recents: cell data served by `MXKInterleavedRecentsDataSource`. + */ + NSMutableArray *interleavedCellDataArray; +} + +@end + +@implementation MXKInterleavedRecentsDataSource + +- (instancetype)init +{ + self = [super init]; + if (self) + { + interleavedCellDataArray = [NSMutableArray array]; + } + return self; +} + +#pragma mark - Override MXKDataSource + +- (void)destroy +{ + interleavedCellDataArray = nil; + + [super destroy]; +} + +#pragma mark - Override MXKRecentsDataSource + +- (UIView *)viewForHeaderInSection:(NSInteger)section withFrame:(CGRect)frame +{ + UIView *sectionHeader = nil; + + if (displayedRecentsDataSourceArray.count > 1 && section == 0) + { + sectionHeader = [[UIView alloc] initWithFrame:frame]; + sectionHeader.backgroundColor = [UIColor colorWithRed:0.9 green:0.9 blue:0.9 alpha:1.0]; + CGFloat btnWidth = frame.size.width / displayedRecentsDataSourceArray.count; + UIButton *previousShrinkButton; + + for (NSInteger index = 0; index < displayedRecentsDataSourceArray.count; index++) + { + MXKSessionRecentsDataSource *recentsDataSource = [displayedRecentsDataSourceArray objectAtIndex:index]; + NSString* btnTitle = recentsDataSource.mxSession.myUser.userId; + + // Add shrink button + UIButton *shrinkButton = [UIButton buttonWithType:UIButtonTypeCustom]; + CGRect btnFrame = CGRectMake(index * btnWidth, 0, btnWidth, sectionHeader.frame.size.height); + shrinkButton.frame = btnFrame; + shrinkButton.backgroundColor = [UIColor clearColor]; + + [shrinkButton addTarget:self action:@selector(onButtonPressed:) forControlEvents:UIControlEventTouchUpInside]; + shrinkButton.tag = index; + [sectionHeader addSubview:shrinkButton]; + sectionHeader.userInteractionEnabled = YES; + + // Set shrink button constraints + NSLayoutConstraint *leftConstraint; + NSLayoutConstraint *widthConstraint; + shrinkButton.translatesAutoresizingMaskIntoConstraints = NO; + if (!previousShrinkButton) + { + leftConstraint = [NSLayoutConstraint constraintWithItem:shrinkButton + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:sectionHeader + attribute:NSLayoutAttributeLeading + multiplier:1 + constant:0]; + widthConstraint = [NSLayoutConstraint constraintWithItem:shrinkButton + attribute:NSLayoutAttributeWidth + relatedBy:NSLayoutRelationEqual + toItem:sectionHeader + attribute:NSLayoutAttributeWidth + multiplier:(1.0 /displayedRecentsDataSourceArray.count) + constant:0]; + } + else + { + leftConstraint = [NSLayoutConstraint constraintWithItem:shrinkButton + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:previousShrinkButton + attribute:NSLayoutAttributeTrailing + multiplier:1 + constant:0]; + widthConstraint = [NSLayoutConstraint constraintWithItem:shrinkButton + attribute:NSLayoutAttributeWidth + relatedBy:NSLayoutRelationEqual + toItem:previousShrinkButton + attribute:NSLayoutAttributeWidth + multiplier:1 + constant:0]; + } + [NSLayoutConstraint activateConstraints:@[leftConstraint, widthConstraint]]; + previousShrinkButton = shrinkButton; + + // Add shrink icon + UIImage *chevron; + if ([shrinkedRecentsDataSourceArray indexOfObject:recentsDataSource] != NSNotFound) + { + chevron = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"disclosure"]; + } + else + { + chevron = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"shrink"]; + } + UIImageView *chevronView = [[UIImageView alloc] initWithImage:chevron]; + if ([shrinkedRecentsDataSourceArray indexOfObject:recentsDataSource] == NSNotFound) + { + // Display the tint color of the user + MXKAccount *account = [[MXKAccountManager sharedManager] accountForUserId:recentsDataSource.mxSession.myUser.userId]; + if (account) + { + chevronView.backgroundColor = account.userTintColor; + } + else + { + chevronView.backgroundColor = [UIColor clearColor]; + } + } + else + { + chevronView.backgroundColor = [UIColor lightGrayColor]; + } + chevronView.contentMode = UIViewContentModeCenter; + frame = chevronView.frame; + frame.size.width = frame.size.height = shrinkButton.frame.size.height - 10; + frame.origin.x = shrinkButton.frame.size.width - frame.size.width - 8; + frame.origin.y = (shrinkButton.frame.size.height - frame.size.height) / 2; + chevronView.frame = frame; + [shrinkButton addSubview:chevronView]; + chevronView.autoresizingMask |= (UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin); + + // Add label + frame = shrinkButton.frame; + frame.origin.x = 5; + frame.origin.y = 5; + frame.size.width = chevronView.frame.origin.x - 10; + frame.size.height -= 10; + UILabel *headerLabel = [[UILabel alloc] initWithFrame:frame]; + headerLabel.font = [UIFont boldSystemFontOfSize:16]; + headerLabel.backgroundColor = [UIColor clearColor]; + headerLabel.text = btnTitle; + [shrinkButton addSubview:headerLabel]; + headerLabel.autoresizingMask |= (UIViewAutoresizingFlexibleWidth); + } + } + + return sectionHeader; +} + +- (id)cellDataAtIndexPath:(NSIndexPath *)indexPath +{ + id cellData = nil; + + // Only one section is handled by this data source + if (indexPath.section == 0) + { + // Consider first the case where there is only one data source (no interleaving). + if (displayedRecentsDataSourceArray.count == 1) + { + MXKSessionRecentsDataSource *recentsDataSource = displayedRecentsDataSourceArray.firstObject; + cellData = [recentsDataSource cellDataAtIndex:indexPath.row]; + } + // Else all the cells have been interleaved. + else if (indexPath.row < interleavedCellDataArray.count) + { + cellData = interleavedCellDataArray[indexPath.row]; + } + } + + return cellData; +} + +- (CGFloat)cellHeightAtIndexPath:(NSIndexPath *)indexPath +{ + CGFloat height = 0; + + // Only one section is handled by this data source + if (indexPath.section == 0) + { + // Consider first the case where there is only one data source (no interleaving). + if (displayedRecentsDataSourceArray.count == 1) + { + MXKSessionRecentsDataSource *recentsDataSource = displayedRecentsDataSourceArray.firstObject; + height = [recentsDataSource cellHeightAtIndex:indexPath.row]; + } + // Else all the cells have been interleaved. + else if (indexPath.row < interleavedCellDataArray.count) + { + id recentCellData = interleavedCellDataArray[indexPath.row]; + + // Select the related recent data source + MXKDataSource *dataSource = recentCellData.dataSource; + if ([dataSource isKindOfClass:[MXKSessionRecentsDataSource class]]) + { + MXKSessionRecentsDataSource *recentsDataSource = (MXKSessionRecentsDataSource*)dataSource; + // Count the index of this cell data in original data source array + NSInteger rank = 0; + for (NSInteger index = 0; index < indexPath.row; index++) + { + id cellData = interleavedCellDataArray[index]; + if (cellData.roomSummary == recentCellData.roomSummary) + { + rank++; + } + } + + height = [recentsDataSource cellHeightAtIndex:rank]; + } + } + } + + return height; +} + +- (NSIndexPath*)cellIndexPathWithRoomId:(NSString*)roomId andMatrixSession:(MXSession*)matrixSession +{ + NSIndexPath *indexPath = nil; + + // Consider first the case where there is only one data source (no interleaving). + if (displayedRecentsDataSourceArray.count == 1) + { + MXKSessionRecentsDataSource *recentsDataSource = displayedRecentsDataSourceArray.firstObject; + if (recentsDataSource.mxSession == matrixSession) + { + // Look for the cell + for (NSInteger index = 0; index < recentsDataSource.numberOfCells; index ++) + { + id recentCellData = [recentsDataSource cellDataAtIndex:index]; + if ([roomId isEqualToString:recentCellData.roomIdentifier]) + { + // Got it + indexPath = [NSIndexPath indexPathForRow:index inSection:0]; + break; + } + } + } + } + else + { + // Look for the right data source + for (MXKSessionRecentsDataSource *recentsDataSource in displayedRecentsDataSourceArray) + { + if (recentsDataSource.mxSession == matrixSession) + { + // Check whether the source is not shrinked + if ([shrinkedRecentsDataSourceArray indexOfObject:recentsDataSource] == NSNotFound) + { + // Look for the cell + for (NSInteger index = 0; index < interleavedCellDataArray.count; index ++) + { + id recentCellData = interleavedCellDataArray[index]; + if ([roomId isEqualToString:recentCellData.roomIdentifier]) + { + // Got it + indexPath = [NSIndexPath indexPathForRow:index inSection:0]; + break; + } + } + } + break; + } + } + } + + return indexPath; +} + +#pragma mark - UITableViewDataSource + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView +{ + // Check whether all data sources are ready before rendering recents + if (self.state == MXKDataSourceStateReady) + { + // Only one section is handled by this data source. + return (displayedRecentsDataSourceArray.count ? 1 : 0); + } + return 0; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + // Consider first the case where there is only one data source (no interleaving). + if (displayedRecentsDataSourceArray.count == 1) + { + MXKSessionRecentsDataSource *recentsDataSource = displayedRecentsDataSourceArray.firstObject; + return recentsDataSource.numberOfCells; + } + + return interleavedCellDataArray.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + id roomData = [self cellDataAtIndexPath:indexPath]; + if (roomData && self.delegate) + { + NSString *cellIdentifier = [self.delegate cellReuseIdentifierForCellData:roomData]; + if (cellIdentifier) + { + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellIdentifier forIndexPath:indexPath]; + + // Make sure we listen to user actions on the cell + cell.delegate = self; + + // Make the bubble display the data + [cell render:roomData]; + + // Clear the user flag, if only one recents list is available + if (displayedRecentsDataSourceArray.count == 1 && [cell isKindOfClass:[MXKInterleavedRecentTableViewCell class]]) + { + ((MXKInterleavedRecentTableViewCell*)cell).userFlag.backgroundColor = [UIColor clearColor]; + } + + return cell; + } + } + + // Return a fake cell to prevent app from crashing. + return [[UITableViewCell alloc] init]; +} + +#pragma mark - MXKDataSourceDelegate + +- (void)dataSource:(MXKDataSource*)dataSource didCellChange:(id)changes +{ + // Consider first the case where there is only one data source (no interleaving). + if (displayedRecentsDataSourceArray.count == 1) + { + // Flush interleaved cells array, we will refer directly to the cell data of the unique data source. + [interleavedCellDataArray removeAllObjects]; + } + else + { + // Handle here the specific case where a second source is just added. + // The empty interleaved cells array has to be prefilled with the cell data of the other source (except if this other source is shrinked). + if (!interleavedCellDataArray.count && displayedRecentsDataSourceArray.count == 2) + { + // This is the first interleaving, look for the other source + MXKSessionRecentsDataSource *recentsDataSource = displayedRecentsDataSourceArray.firstObject; + if (recentsDataSource == dataSource) + { + recentsDataSource = displayedRecentsDataSourceArray.lastObject; + } + + if ([shrinkedRecentsDataSourceArray indexOfObject:recentsDataSource] == NSNotFound) + { + // Report all cell data + for (NSInteger index = 0; index < recentsDataSource.numberOfCells; index ++) + { + [interleavedCellDataArray addObject:[recentsDataSource cellDataAtIndex:index]]; + } + } + } + + // Update now interleaved cells array, TODO take into account 'changes' parameter + MXKSessionRecentsDataSource *updateRecentsDataSource = (MXKSessionRecentsDataSource*)dataSource; + NSInteger numberOfUpdatedCells = 0; + // Check whether this dataSource is used + if ([displayedRecentsDataSourceArray indexOfObject:dataSource] != NSNotFound && [shrinkedRecentsDataSourceArray indexOfObject:dataSource] == NSNotFound) + { + numberOfUpdatedCells = updateRecentsDataSource.numberOfCells; + } + + NSInteger currentCellIndex = 0; + NSInteger updatedCellIndex = 0; + id updatedCellData = nil; + + if (numberOfUpdatedCells) + { + updatedCellData = [updateRecentsDataSource cellDataAtIndex:updatedCellIndex++]; + } + + // Review all cell data items of the current list + while (currentCellIndex < interleavedCellDataArray.count) + { + id currentCellData = interleavedCellDataArray[currentCellIndex]; + + // Remove existing cell data of the updated data source + if (currentCellData.dataSource == dataSource) + { + [interleavedCellDataArray removeObjectAtIndex:currentCellIndex]; + } + else + { + while (updatedCellData && (updatedCellData.roomSummary.lastMessage.originServerTs > currentCellData.roomSummary.lastMessage.originServerTs)) + { + [interleavedCellDataArray insertObject:updatedCellData atIndex:currentCellIndex++]; + updatedCellData = [updateRecentsDataSource cellDataAtIndex:updatedCellIndex++]; + } + + currentCellIndex++; + } + } + + while (updatedCellData) + { + [interleavedCellDataArray addObject:updatedCellData]; + updatedCellData = [updateRecentsDataSource cellDataAtIndex:updatedCellIndex++]; + } + } + + // Call super to keep update readyRecentsDataSourceArray. + [super dataSource:dataSource didCellChange:changes]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellData.h b/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellData.h new file mode 100644 index 000000000..b02001881 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellData.h @@ -0,0 +1,26 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKRecentCellDataStoring.h" + +/** + `MXKRecentCellData` modelised the data for a `MXKRecentTableViewCell` cell. + */ +@interface MXKRecentCellData : MXKCellData + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellData.m b/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellData.m new file mode 100644 index 000000000..b2bfc1885 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellData.m @@ -0,0 +1,133 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRecentCellData.h" + +@import MatrixSDK; + +#import "MXKDataSource.h" +#import "MXEvent+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@implementation MXKRecentCellData +@synthesize roomSummary, dataSource, lastEventDate; + +- (instancetype)initWithRoomSummary:(id)theRoomSummary + dataSource:(MXKDataSource*)theDataSource; +{ + self = [self init]; + if (self) + { + roomSummary = theRoomSummary; + dataSource = theDataSource; + } + return self; +} + +- (void)dealloc +{ + roomSummary = nil; +} + +- (MXSession *)mxSession +{ + return dataSource.mxSession; +} + +- (NSString*)lastEventDate +{ + return (NSString*)roomSummary.lastMessage.others[@"lastEventDate"]; +} + +- (BOOL)hasUnread +{ + return (roomSummary.localUnreadEventCount != 0); +} + +- (NSString *)roomIdentifier +{ + if (self.isSuggestedRoom) + { + return self.roomSummary.spaceChildInfo.childRoomId; + } + return roomSummary.roomId; +} + +- (NSString *)roomDisplayname +{ + if (self.isSuggestedRoom) + { + return self.roomSummary.spaceChildInfo.displayName; + } + return roomSummary.displayname; +} + +- (NSString *)avatarUrl +{ + if (self.isSuggestedRoom) + { + return self.roomSummary.spaceChildInfo.avatarUrl; + } + return roomSummary.avatar; +} + +- (NSString *)lastEventTextMessage +{ + if (self.isSuggestedRoom) + { + return roomSummary.spaceChildInfo.topic; + } + return roomSummary.lastMessage.text; +} + +- (NSAttributedString *)lastEventAttributedTextMessage +{ + if (self.isSuggestedRoom) + { + return nil; + } + return roomSummary.lastMessage.attributedText; +} + +- (NSUInteger)notificationCount +{ + return roomSummary.notificationCount; +} + +- (NSUInteger)highlightCount +{ + return roomSummary.highlightCount; +} + +- (NSString*)notificationCountStringValue +{ + return [NSString stringWithFormat:@"%tu", self.notificationCount]; +} + +- (NSString*)description +{ + return [NSString stringWithFormat:@"%@ %@: %@ - %@", super.description, self.roomSummary.roomId, self.roomDisplayname, self.lastEventTextMessage]; +} + +- (BOOL)isSuggestedRoom +{ + // As off now, we only store MXSpaceChildInfo in case of suggested rooms + return self.roomSummary.spaceChildInfo != nil; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellDataStoring.h b/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellDataStoring.h new file mode 100644 index 000000000..8cf046696 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellDataStoring.h @@ -0,0 +1,75 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import "MXKCellData.h" + +@class MXKDataSource; +@class MXSpaceChildInfo; + +/** + `MXKRecentCellDataStoring` defines a protocol a class must conform in order to store recent cell data + managed by `MXKSessionRecentsDataSource`. + */ +@protocol MXKRecentCellDataStoring + +#pragma mark - Data displayed by a room recent cell + +/** + The original data source of the recent displayed by the cell. + */ +@property (nonatomic, weak, readonly) MXKDataSource *dataSource; + +/** + The `MXRoomSummaryProtocol` instance of the room for the recent displayed by the cell. + */ +@property (nonatomic, readonly) id roomSummary; + +@property (nonatomic, readonly) NSString *roomIdentifier; +@property (nonatomic, readonly) NSString *roomDisplayname; +@property (nonatomic, readonly) NSString *avatarUrl; +@property (nonatomic, readonly) NSString *lastEventTextMessage; +@property (nonatomic, readonly) NSString *lastEventDate; + +@property (nonatomic, readonly) BOOL hasUnread; +@property (nonatomic, readonly) NSUInteger notificationCount; +@property (nonatomic, readonly) NSUInteger highlightCount; +@property (nonatomic, readonly) NSString *notificationCountStringValue; +@property (nonatomic, readonly) BOOL isSuggestedRoom; + +@property (nonatomic, readonly) MXSession *mxSession; + +#pragma mark - Public methods +/** + Create a new `MXKCellData` object for a new recent cell. + + @param roomSummary the `id` object that has data about the room. + @param dataSource the `MXKDataSource` object that will use this instance. + @return the newly created instance. + */ +- (instancetype)initWithRoomSummary:(id)roomSummary + dataSource:(MXKDataSource*)dataSource; + +@optional +/** + The `lastEventTextMessage` with sets of attributes. + */ +@property (nonatomic, readonly) NSAttributedString *lastEventAttributedTextMessage; + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentsDataSource.h b/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentsDataSource.h new file mode 100644 index 000000000..b9de69b4a --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentsDataSource.h @@ -0,0 +1,140 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKSessionRecentsDataSource.h" + +/** + 'MXKRecentsDataSource' is a base class to handle recents from one or multiple matrix sessions. + A 'MXKRecentsDataSource' instance provides the recents data source for `MXKRecentListViewController`. + + By default, the recents list of different sessions are handled into separate sections. + */ +@interface MXKRecentsDataSource : MXKDataSource +{ +@protected + /** + Array of `MXKSessionRecentsDataSource` instances. Only ready and non empty data source are listed here. + (Note: a data source may be considered as empty during searching) + */ + NSMutableArray *displayedRecentsDataSourceArray; + + /** + Array of shrinked sources. Sub-list of displayedRecentsDataSourceArray. + */ + NSMutableArray *shrinkedRecentsDataSourceArray; +} + +/** + List of associated matrix sessions. + */ +@property (nonatomic, readonly) NSArray* mxSessions; + +/** + The number of available recents data sources (This count may be different than mxSession.count because empty data sources are ignored). + */ +@property (nonatomic, readonly) NSUInteger displayedRecentsDataSourcesCount; + +/** + Tell whether there are some unread messages. + */ +@property (nonatomic, readonly) BOOL hasUnread; + +/** + The current search patterns list. + */ +@property (nonatomic, readonly) NSArray* searchPatternsList; + +@property (nonatomic, strong) MXSpace *currentSpace; + +#pragma mark - Configuration + +/** + Add recents data from a matrix session. + + @param mxSession the Matrix session to retrieve contextual data. + @return the new 'MXKSessionRecentsDataSource' instance created for this Matrix session. + */ +- (MXKSessionRecentsDataSource *)addMatrixSession:(MXSession*)mxSession; + +/** + Remove recents data related to a matrix session. + + @param mxSession the session to remove. + */ +- (void)removeMatrixSession:(MXSession*)mxSession; + +/** + Filter the current recents list according to the provided patterns. + + @param patternsList the list of patterns (`NSString` instances) to match with. Set nil to cancel search. + */ +- (void)searchWithPatterns:(NSArray*)patternsList; + +/** + Get the section header view. + + @param section the section index + @param frame the drawing area for the header of the specified section. + @return the section header. + */ +- (UIView *)viewForHeaderInSection:(NSInteger)section withFrame:(CGRect)frame; + +/** + Get the data for the cell at the given index path. + + @param indexPath the index of the cell + @return the cell data + */ +- (id)cellDataAtIndexPath:(NSIndexPath*)indexPath; + +/** + Get the height of the cell at the given index path. + + @param indexPath the index of the cell + @return the cell height + */ +- (CGFloat)cellHeightAtIndexPath:(NSIndexPath*)indexPath; + +/** + Get the index path of the cell related to the provided roomId and session. + + @param roomId the room identifier. + @param mxSession the matrix session in which the room should be available. + @return indexPath the index of the cell (nil if not found or if the related section is shrinked). + */ +- (NSIndexPath*)cellIndexPathWithRoomId:(NSString*)roomId andMatrixSession:(MXSession*)mxSession; + +/** + Returns the room at the index path + + @param indexPath the index of the cell + @return the MXRoom if it exists + */ +- (MXRoom*)getRoomAtIndexPath:(NSIndexPath *)indexPath; + +/** + Leave the room at the index path + + @param indexPath the index of the cell + */ +- (void)leaveRoomAtIndexPath:(NSIndexPath *)indexPath; + +/** + Action registered on buttons used to shrink/disclose recents sources. + */ +- (IBAction)onButtonPressed:(id)sender; + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentsDataSource.m b/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentsDataSource.m new file mode 100644 index 000000000..0da982a1d --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentsDataSource.m @@ -0,0 +1,657 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRecentsDataSource.h" + +@import MatrixSDK.MXMediaManager; + +#import "NSBundle+MatrixKit.h" + +#import "MXKConstants.h" + +@interface MXKRecentsDataSource () +{ + /** + Array of `MXSession` instances. + */ + NSMutableArray *mxSessionArray; + + /** + Array of `MXKSessionRecentsDataSource` instances (one by matrix session). + */ + NSMutableArray *recentsDataSourceArray; +} + +@end + +@implementation MXKRecentsDataSource + +- (instancetype)init +{ + self = [super init]; + if (self) + { + mxSessionArray = [NSMutableArray array]; + recentsDataSourceArray = [NSMutableArray array]; + + displayedRecentsDataSourceArray = [NSMutableArray array]; + shrinkedRecentsDataSourceArray = [NSMutableArray array]; + + // Set default data and view classes + [self registerCellDataClass:MXKRecentCellData.class forCellIdentifier:kMXKRecentCellIdentifier]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXSessionInviteRoomUpdate:) name:kMXSessionInvitedRoomsDidChangeNotification object:nil]; + } + return self; +} + +- (instancetype)initWithMatrixSession:(MXSession *)matrixSession +{ + self = [self init]; + if (self) + { + [self addMatrixSession:matrixSession]; + } + return self; +} + +- (MXKSessionRecentsDataSource *)addMatrixSession:(MXSession *)matrixSession +{ + MXKSessionRecentsDataSource *recentsDataSource = [[MXKSessionRecentsDataSource alloc] initWithMatrixSession:matrixSession]; + + if (recentsDataSource) + { + // Set the actual data and view classes + [recentsDataSource registerCellDataClass:[self cellDataClassForCellIdentifier:kMXKRecentCellIdentifier] forCellIdentifier:kMXKRecentCellIdentifier]; + + [mxSessionArray addObject:matrixSession]; + + recentsDataSource.delegate = self; + [recentsDataSourceArray addObject:recentsDataSource]; + + [recentsDataSource finalizeInitialization]; + + if (self.delegate && [self.delegate respondsToSelector:@selector(dataSource:didAddMatrixSession:)]) + { + [self.delegate dataSource:self didAddMatrixSession:matrixSession]; + } + + // Check the current state of the data source + [self dataSource:recentsDataSource didStateChange:recentsDataSource.state]; + } + + return recentsDataSource; +} + +- (void)removeMatrixSession:(MXSession*)matrixSession +{ + for (NSUInteger index = 0; index < mxSessionArray.count; index++) + { + MXSession *mxSession = [mxSessionArray objectAtIndex:index]; + if (mxSession == matrixSession) + { + MXKSessionRecentsDataSource *recentsDataSource = [recentsDataSourceArray objectAtIndex:index]; + [recentsDataSource destroy]; + + [displayedRecentsDataSourceArray removeObject:recentsDataSource]; + + [recentsDataSourceArray removeObjectAtIndex:index]; + [mxSessionArray removeObjectAtIndex:index]; + + // Loop on 'didCellChange' method to let inherited 'MXKRecentsDataSource' class handle this removed data source. + [self dataSource:recentsDataSource didCellChange:nil]; + + if (self.delegate && [self.delegate respondsToSelector:@selector(dataSource:didRemoveMatrixSession:)]) + { + [self.delegate dataSource:self didRemoveMatrixSession:matrixSession]; + } + + break; + } + } +} + +- (void)setCurrentSpace:(MXSpace *)currentSpace +{ + _currentSpace = currentSpace; + + for (MXKSessionRecentsDataSource *recentsDataSource in recentsDataSourceArray) { + recentsDataSource.currentSpace = currentSpace; + } +} + +#pragma mark - MXKDataSource overridden + +- (MXSession*)mxSession +{ + if (mxSessionArray.count > 1) + { + MXLogDebug(@"[MXKRecentsDataSource] CAUTION: mxSession property is not relevant in case of multi-sessions (%tu)", mxSessionArray.count); + } + + // TODO: This property is not well adapted in case of multi-sessions + // We consider by default the first added session as the main one... + if (mxSessionArray.count) + { + return [mxSessionArray firstObject]; + } + return nil; +} + +- (MXKDataSourceState)state +{ + // Manage a global state based on the state of each internal data source. + + MXKDataSourceState currentState = MXKDataSourceStateUnknown; + MXKSessionRecentsDataSource *dataSource; + + if (recentsDataSourceArray.count) + { + dataSource = [recentsDataSourceArray firstObject]; + currentState = dataSource.state; + + // Deduce the current state according to the internal data sources + for (NSUInteger index = 1; index < recentsDataSourceArray.count; index++) + { + dataSource = [recentsDataSourceArray objectAtIndex:index]; + + switch (dataSource.state) + { + case MXKDataSourceStateUnknown: + break; + case MXKDataSourceStatePreparing: + currentState = MXKDataSourceStatePreparing; + break; + case MXKDataSourceStateFailed: + if (currentState == MXKDataSourceStateUnknown) + { + currentState = MXKDataSourceStateFailed; + } + break; + case MXKDataSourceStateReady: + if (currentState == MXKDataSourceStateUnknown || currentState == MXKDataSourceStateFailed) + { + currentState = MXKDataSourceStateReady; + } + break; + + default: + break; + } + } + } + + return currentState; +} + +- (void)destroy +{ + for (MXKSessionRecentsDataSource *recentsDataSource in recentsDataSourceArray) + { + [recentsDataSource destroy]; + } + displayedRecentsDataSourceArray = nil; + recentsDataSourceArray = nil; + shrinkedRecentsDataSourceArray = nil; + mxSessionArray = nil; + + _searchPatternsList = nil; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionInvitedRoomsDidChangeNotification object:nil]; + + [super destroy]; +} + +#pragma mark - + +- (NSArray*)mxSessions +{ + return [NSArray arrayWithArray:mxSessionArray]; +} + +- (NSUInteger)displayedRecentsDataSourcesCount +{ + return displayedRecentsDataSourceArray.count; +} + +- (BOOL)hasUnread +{ + // Check hasUnread flag in all ready data sources + for (MXKSessionRecentsDataSource *recentsDataSource in displayedRecentsDataSourceArray) + { + if (recentsDataSource.hasUnread) + { + return YES; + } + } + return NO; +} + +- (void)searchWithPatterns:(NSArray*)patternsList +{ + _searchPatternsList = patternsList; + + // CAUTION: Apply here the search pattern to all ready data sources (not only displayed ones). + // Some data sources may have been removed from 'displayedRecentsDataSourceArray' during a previous search if no recent was matching. + for (MXKSessionRecentsDataSource *recentsDataSource in recentsDataSourceArray) + { + if (recentsDataSource.state == MXKDataSourceStateReady) + { + [recentsDataSource searchWithPatterns:patternsList]; + } + } +} + +- (UIView *)viewForHeaderInSection:(NSInteger)section withFrame:(CGRect)frame +{ + UIView *sectionHeader = nil; + + if (displayedRecentsDataSourceArray.count > 1 && section < displayedRecentsDataSourceArray.count) + { + MXKSessionRecentsDataSource *recentsDataSource = [displayedRecentsDataSourceArray objectAtIndex:section]; + + NSString* sectionTitle = recentsDataSource.mxSession.myUser.userId; + + sectionHeader = [[UIView alloc] initWithFrame:frame]; + sectionHeader.backgroundColor = [UIColor colorWithRed:0.9 green:0.9 blue:0.9 alpha:1.0]; + + // Add shrink button + UIButton *shrinkButton = [UIButton buttonWithType:UIButtonTypeCustom]; + frame.origin.x = frame.origin.y = 0; + shrinkButton.frame = frame; + shrinkButton.backgroundColor = [UIColor clearColor]; + [shrinkButton addTarget:self action:@selector(onButtonPressed:) forControlEvents:UIControlEventTouchUpInside]; + shrinkButton.tag = section; + [sectionHeader addSubview:shrinkButton]; + sectionHeader.userInteractionEnabled = YES; + + // Add shrink icon + UIImage *chevron; + if ([shrinkedRecentsDataSourceArray indexOfObject:recentsDataSource] != NSNotFound) + { + chevron = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"disclosure"]; + } + else + { + chevron = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"shrink"]; + } + UIImageView *chevronView = [[UIImageView alloc] initWithImage:chevron]; + chevronView.contentMode = UIViewContentModeCenter; + frame = chevronView.frame; + frame.origin.x = sectionHeader.frame.size.width - frame.size.width - 8; + frame.origin.y = (sectionHeader.frame.size.height - frame.size.height) / 2; + chevronView.frame = frame; + [sectionHeader addSubview:chevronView]; + chevronView.autoresizingMask = (UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin); + + // Add label + frame = sectionHeader.frame; + frame.origin.x = 5; + frame.origin.y = 5; + frame.size.width = chevronView.frame.origin.x - 10; + frame.size.height -= 10; + UILabel *headerLabel = [[UILabel alloc] initWithFrame:frame]; + headerLabel.font = [UIFont boldSystemFontOfSize:16]; + headerLabel.backgroundColor = [UIColor clearColor]; + headerLabel.text = sectionTitle; + [sectionHeader addSubview:headerLabel]; + } + + return sectionHeader; +} + +- (id)cellDataAtIndexPath:(NSIndexPath *)indexPath +{ + if (indexPath.section < displayedRecentsDataSourceArray.count) + { + MXKSessionRecentsDataSource *recentsDataSource = [displayedRecentsDataSourceArray objectAtIndex:indexPath.section]; + + return [recentsDataSource cellDataAtIndex:indexPath.row]; + } + return nil; +} + +- (CGFloat)cellHeightAtIndexPath:(NSIndexPath *)indexPath +{ + if (indexPath.section < displayedRecentsDataSourceArray.count) + { + MXKSessionRecentsDataSource *recentsDataSource = [displayedRecentsDataSourceArray objectAtIndex:indexPath.section]; + + return [recentsDataSource cellHeightAtIndex:indexPath.row]; + } + return 0; +} + +- (NSIndexPath*)cellIndexPathWithRoomId:(NSString*)roomId andMatrixSession:(MXSession*)matrixSession +{ + NSIndexPath *indexPath = nil; + + // Look for the right data source + for (NSInteger section = 0; section < displayedRecentsDataSourceArray.count; section++) + { + MXKSessionRecentsDataSource *recentsDataSource = displayedRecentsDataSourceArray[section]; + if (recentsDataSource.mxSession == matrixSession) + { + // Check whether the source is not shrinked + if ([shrinkedRecentsDataSourceArray indexOfObject:recentsDataSource] == NSNotFound) + { + // Look for the cell + for (NSInteger index = 0; index < recentsDataSource.numberOfCells; index ++) + { + id recentCellData = [recentsDataSource cellDataAtIndex:index]; + if ([roomId isEqualToString:recentCellData.roomIdentifier]) + { + // Got it + indexPath = [NSIndexPath indexPathForRow:index inSection:section]; + break; + } + } + } + break; + } + } + + return indexPath; +} + +#pragma mark - UITableViewDataSource + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView +{ + // Check whether all data sources are ready before rendering recents + if (self.state == MXKDataSourceStateReady) + { + return displayedRecentsDataSourceArray.count; + } + return 0; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + if (section < displayedRecentsDataSourceArray.count) + { + MXKSessionRecentsDataSource *recentsDataSource = [displayedRecentsDataSourceArray objectAtIndex:section]; + + // Check whether the source is shrinked + if ([shrinkedRecentsDataSourceArray indexOfObject:recentsDataSource] == NSNotFound) + { + return recentsDataSource.numberOfCells; + } + } + + return 0; +} + +- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section +{ + NSString* sectionTitle = nil; + + if (displayedRecentsDataSourceArray.count > 1 && section < displayedRecentsDataSourceArray.count) + { + MXKSessionRecentsDataSource *recentsDataSource = [displayedRecentsDataSourceArray objectAtIndex:section]; + + sectionTitle = recentsDataSource.mxSession.myUser.userId; + } + + return sectionTitle; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (indexPath.section < displayedRecentsDataSourceArray.count && self.delegate) + { + MXKSessionRecentsDataSource *recentsDataSource = [displayedRecentsDataSourceArray objectAtIndex:indexPath.section]; + + id roomData = [recentsDataSource cellDataAtIndex:indexPath.row]; + + NSString *cellIdentifier = [self.delegate cellReuseIdentifierForCellData:roomData]; + if (cellIdentifier) + { + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellIdentifier forIndexPath:indexPath]; + + // Make sure we listen to user actions on the cell + cell.delegate = self; + + // Make the bubble display the data + [cell render:roomData]; + + return cell; + } + } + + // Return a fake cell to prevent app from crashing. + return [[UITableViewCell alloc] init]; +} + +- (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath +{ + // Return NO if you do not want the specified item to be editable. + return YES; +} + +- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (editingStyle == UITableViewCellEditingStyleDelete) + { + [self leaveRoomAtIndexPath:indexPath]; + } +} + +#pragma mark - MXKDataSourceDelegate + +- (Class)cellViewClassForCellData:(MXKCellData*)cellData +{ + // Retrieve the class from the delegate here + if (self.delegate) + { + return [self.delegate cellViewClassForCellData:cellData]; + } + + return nil; +} + +- (NSString *)cellReuseIdentifierForCellData:(MXKCellData*)cellData +{ + // Retrieve the identifier from the delegate here + if (self.delegate) + { + return [self.delegate cellReuseIdentifierForCellData:cellData]; + } + + return nil; +} + +- (void)dataSource:(MXKDataSource*)dataSource didCellChange:(id)changes +{ + // Keep update readyRecentsDataSourceArray by checking number of cells + if (dataSource.state == MXKDataSourceStateReady) + { + MXKSessionRecentsDataSource *recentsDataSource = (MXKSessionRecentsDataSource*)dataSource; + + if (recentsDataSource.numberOfCells) + { + // Check whether the data source must be added + if ([displayedRecentsDataSourceArray indexOfObject:recentsDataSource] == NSNotFound) + { + // Add this data source first + [self dataSource:dataSource didStateChange:dataSource.state]; + return; + } + } + else + { + // Check whether this data source must be removed + if ([displayedRecentsDataSourceArray indexOfObject:recentsDataSource] != NSNotFound) + { + [displayedRecentsDataSourceArray removeObject:recentsDataSource]; + + // Loop on 'didCellChange' method to let inherited 'MXKRecentsDataSource' class handle this removed data source. + [self dataSource:recentsDataSource didCellChange:nil]; + return; + } + } + } + + // Notify delegate + [self.delegate dataSource:self didCellChange:changes]; +} + +- (void)dataSource:(MXKDataSource*)dataSource didStateChange:(MXKDataSourceState)state +{ + // Update list of ready data sources + MXKSessionRecentsDataSource *recentsDataSource = (MXKSessionRecentsDataSource*)dataSource; + if (dataSource.state == MXKDataSourceStateReady && recentsDataSource.numberOfCells) + { + if ([displayedRecentsDataSourceArray indexOfObject:recentsDataSource] == NSNotFound) + { + // Add this new recents data source. + if (!displayedRecentsDataSourceArray.count) + { + [displayedRecentsDataSourceArray addObject:recentsDataSource]; + } + else + { + // To display multiple accounts in a consistent order, we sort the recents data source by considering the account user id (alphabetic order). + NSUInteger index; + for (index = 0; index < displayedRecentsDataSourceArray.count; index++) + { + MXKSessionRecentsDataSource *currentRecentsDataSource = displayedRecentsDataSourceArray[index]; + if ([currentRecentsDataSource.mxSession.myUser.userId compare:recentsDataSource.mxSession.myUser.userId] == NSOrderedDescending) + { + break; + } + } + + // Insert this data source + [displayedRecentsDataSourceArray insertObject:recentsDataSource atIndex:index]; + } + + // Check whether a search session is in progress + if (_searchPatternsList) + { + [recentsDataSource searchWithPatterns:_searchPatternsList]; + } + else + { + // Loop on 'didCellChange' method to let inherited 'MXKRecentsDataSource' class handle this new added data source. + [self dataSource:recentsDataSource didCellChange:nil]; + } + } + } + else if ([displayedRecentsDataSourceArray indexOfObject:recentsDataSource] != NSNotFound) + { + [displayedRecentsDataSourceArray removeObject:recentsDataSource]; + + // Loop on 'didCellChange' method to let inherited 'MXKRecentsDataSource' class handle this removed data source. + [self dataSource:recentsDataSource didCellChange:nil]; + } + + if (self.delegate && [self.delegate respondsToSelector:@selector(dataSource:didStateChange:)]) + { + [self.delegate dataSource:self didStateChange:self.state]; + } +} + +#pragma mark - Action + +- (IBAction)onButtonPressed:(id)sender +{ + if ([sender isKindOfClass:[UIButton class]]) + { + UIButton *shrinkButton = (UIButton*)sender; + + if (shrinkButton.tag < displayedRecentsDataSourceArray.count) + { + MXKSessionRecentsDataSource *recentsDataSource = [displayedRecentsDataSourceArray objectAtIndex:shrinkButton.tag]; + + NSUInteger index = [shrinkedRecentsDataSourceArray indexOfObject:recentsDataSource]; + if (index != NSNotFound) + { + // Disclose the + [shrinkedRecentsDataSourceArray removeObjectAtIndex:index]; + } + else + { + // Shrink the recents from this session + [shrinkedRecentsDataSourceArray addObject:recentsDataSource]; + } + + // Loop on 'didCellChange' method to let inherited 'MXKRecentsDataSource' class handle change on this data source. + [self dataSource:recentsDataSource didCellChange:nil]; + } + } +} + +#pragma mark - room actions +- (MXRoom*)getRoomAtIndexPath:(NSIndexPath *)indexPath +{ + // Leave the selected room + id recentCellData = [self cellDataAtIndexPath:indexPath]; + + if (recentCellData) + { + return [self.mxSession roomWithRoomId:recentCellData.roomIdentifier]; + } + + return nil; +} + +- (void)leaveRoomAtIndexPath:(NSIndexPath *)indexPath +{ + MXRoom* room = [self getRoomAtIndexPath:indexPath]; + + if (room) + { + // cancel pending uploads/downloads + // they are useless by now + [MXMediaManager cancelDownloadsInCacheFolder:room.roomId]; + + // TODO GFO cancel pending uploads related to this room + + [room leave:^{ + + // Trigger recents table refresh + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRecentsDataSource] Failed to leave room (%@) failed", room.roomId); + + // Notify MatrixKit user + NSString *myUserId = room.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } +} + +- (void)didMXSessionInviteRoomUpdate:(NSNotification *)notif +{ + MXSession *mxSession = notif.object; + if ([self.mxSessions indexOfObject:mxSession] != NSNotFound) + { + // do nothing by default + // the inherited classes might require to perform a full or a particial refresh. + //[self.delegate dataSource:self didCellChange:nil]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomList/MXKSessionRecentsDataSource.h b/Riot/Modules/MatrixKit/Models/RoomList/MXKSessionRecentsDataSource.h new file mode 100644 index 000000000..0748f1e62 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomList/MXKSessionRecentsDataSource.h @@ -0,0 +1,90 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKConstants.h" +#import "MXKDataSource.h" +#import "MXKRecentCellData.h" + +@class MXSpace; + +/** + Identifier to use for cells that display a room in the recents list. + */ +extern NSString *const kMXKRecentCellIdentifier; + +/** + The recents data source based on a unique matrix session. + */ +MXK_DEPRECATED_ATTRIBUTE_WITH_MSG("See MXSession.roomListDataManager") +@interface MXKSessionRecentsDataSource : MXKDataSource { + +@protected + + /** + The data for the cells served by `MXKSessionRecentsDataSource`. + */ + NSMutableArray *cellDataArray; + + /** + The filtered recents: sub-list of `cellDataArray` defined by `searchWithPatterns:` call. + */ + NSMutableArray *filteredCellDataArray; +} + +/** + The current number of cells. + */ +@property (nonatomic, readonly) NSInteger numberOfCells; + +/** + Tell whether there are some unread messages. + */ +@property (nonatomic, readonly) BOOL hasUnread; + +@property (nonatomic, strong, nullable) MXSpace *currentSpace; + + +#pragma mark - Life cycle + +/** + Filter the current recents list according to the provided patterns. + When patterns are not empty, the search result is stored in `filteredCellDataArray`, + this array provides then data for the cells served by `MXKRecentsDataSource`. + + @param patternsList the list of patterns (`NSString` instances) to match with. Set nil to cancel search. + */ +- (void)searchWithPatterns:(NSArray*)patternsList; + +/** + Get the data for the cell at the given index. + + @param index the index of the cell in the array + @return the cell data + */ +- (id)cellDataAtIndex:(NSInteger)index; + +/** + Get height of the cell at the given index. + + @param index the index of the cell in the array + @return the cell height + */ +- (CGFloat)cellHeightAtIndex:(NSInteger)index; + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomList/MXKSessionRecentsDataSource.m b/Riot/Modules/MatrixKit/Models/RoomList/MXKSessionRecentsDataSource.m new file mode 100644 index 000000000..bf4c600aa --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomList/MXKSessionRecentsDataSource.m @@ -0,0 +1,552 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKSessionRecentsDataSource.h" + +@import MatrixSDK; + +#import "MXKRoomDataSourceManager.h" + +#import "MXKSwiftHeader.h" + +#pragma mark - Constant definitions +NSString *const kMXKRecentCellIdentifier = @"kMXKRecentCellIdentifier"; +static NSTimeInterval const roomSummaryChangeThrottlerDelay = .5; + + +@interface MXKSessionRecentsDataSource () +{ + MXKRoomDataSourceManager *roomDataSourceManager; + + /** + Internal array used to regulate change notifications. + Cell data changes are stored instantly in this array. + These changes are reported to the delegate only if no server sync is in progress. + */ + NSMutableArray *internalCellDataArray; + + /** + Store the current search patterns list. + */ + NSArray* searchPatternsList; + + /** + Do not react on every summary change + */ + MXThrottler *roomSummaryChangeThrottler; + + /** + Last received suggested rooms per space ID + */ + NSMutableDictionary *> *lastSuggestedRooms; + + /** + Event listener of the current space used to update the UI if an event occurs. + */ + id spaceEventsListener; + + /** + Observer used to reload data when the space service is initialised + */ + id spaceServiceDidInitialiseObserver; +} + +/** + Additional suggestedRooms related to the current selected Space + */ +@property (nonatomic, strong) NSArray *suggestedRooms; + +@end + +@implementation MXKSessionRecentsDataSource + +- (instancetype)initWithMatrixSession:(MXSession *)matrixSession +{ + self = [super initWithMatrixSession:matrixSession]; + if (self) + { + roomDataSourceManager = [MXKRoomDataSourceManager sharedManagerForMatrixSession:self.mxSession]; + + internalCellDataArray = [NSMutableArray array]; + filteredCellDataArray = nil; + + lastSuggestedRooms = [NSMutableDictionary new]; + + // Set default data and view classes + [self registerCellDataClass:MXKRecentCellData.class forCellIdentifier:kMXKRecentCellIdentifier]; + + roomSummaryChangeThrottler = [[MXThrottler alloc] initWithMinimumDelay:roomSummaryChangeThrottlerDelay]; + + [[MXKAppSettings standardAppSettings] addObserver:self forKeyPath:@"showAllRoomsInHomeSpace" options:0 context:nil]; + } + return self; +} + +- (void)destroy +{ + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXRoomSummaryDidChangeNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXKRoomDataSourceSyncStatusChanged object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionNewRoomNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidLeaveRoomNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDirectRoomsDidChangeNotification object:nil]; + + if (spaceServiceDidInitialiseObserver) { + [[NSNotificationCenter defaultCenter] removeObserver:spaceServiceDidInitialiseObserver]; + } + + [roomSummaryChangeThrottler cancelAll]; + roomSummaryChangeThrottler = nil; + + cellDataArray = nil; + internalCellDataArray = nil; + filteredCellDataArray = nil; + lastSuggestedRooms = nil; + + searchPatternsList = nil; + + [[MXKAppSettings standardAppSettings] removeObserver:self forKeyPath:@"showAllRoomsInHomeSpace" context:nil]; + + [super destroy]; +} + +- (void)didMXSessionStateChange +{ + if (MXSessionStateStoreDataReady <= self.mxSession.state) + { + // Check whether some data have been already load + if (0 == internalCellDataArray.count) + { + [self loadData]; + } + else if (!roomDataSourceManager.isServerSyncInProgress) + { + // Sort cell data and notify the delegate + [self sortCellDataAndNotifyChanges]; + } + } +} + +- (void)setCurrentSpace:(MXSpace *)currentSpace +{ + if (_currentSpace == currentSpace) + { + return; + } + + if (_currentSpace && spaceEventsListener) + { + [_currentSpace.room removeListener:spaceEventsListener]; + } + + _currentSpace = currentSpace; + + self.suggestedRooms = _currentSpace ? lastSuggestedRooms[_currentSpace.spaceId] : nil; + [self updateSuggestedRooms]; + + MXWeakify(self); + spaceEventsListener = [self.currentSpace.room listenToEvents:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + MXStrongifyAndReturnIfNil(self); + [self updateSuggestedRooms]; + }]; +} + +-(void)setSuggestedRooms:(NSArray *)suggestedRooms +{ + _suggestedRooms = suggestedRooms; + [self loadData]; +} + +-(void)updateSuggestedRooms +{ + if (self.currentSpace) + { + NSString *currentSpaceId = self.currentSpace.spaceId; + MXWeakify(self); + [self.mxSession.spaceService getSpaceChildrenForSpaceWithId:currentSpaceId suggestedOnly:YES limit:5 maxDepth:1 paginationToken:nil success:^(MXSpaceChildrenSummary * _Nonnull childrenSummary) { + MXLogDebug(@"[MXKSessionRecentsDataSource] getSpaceChildrenForSpaceWithId %@: %ld found", self.currentSpace.spaceId, childrenSummary.childInfos.count); + MXStrongifyAndReturnIfNil(self); + self->lastSuggestedRooms[currentSpaceId] = childrenSummary.childInfos; + if ([self.currentSpace.spaceId isEqual:currentSpaceId]) { + self.suggestedRooms = childrenSummary.childInfos; + } + } failure:^(NSError * _Nonnull error) { + MXLogError(@"[MXKSessionRecentsDataSource] getSpaceChildrenForSpaceWithId failed with error: %@", error); + }]; + } +} + +#pragma mark - + +- (NSInteger)numberOfCells +{ + if (filteredCellDataArray) + { + return filteredCellDataArray.count; + } + return cellDataArray.count; +} + +- (BOOL)hasUnread +{ + // Check all current cells + // Use numberOfRowsInSection methods so that we take benefit of the filtering + for (NSUInteger i = 0; i < self.numberOfCells; i++) + { + id cellData = [self cellDataAtIndex:i]; + if (cellData.hasUnread) + { + return YES; + } + } + return NO; +} + +- (void)searchWithPatterns:(NSArray*)patternsList +{ + if (patternsList.count) + { + searchPatternsList = patternsList; + + if (filteredCellDataArray) + { + [filteredCellDataArray removeAllObjects]; + } + else + { + filteredCellDataArray = [NSMutableArray arrayWithCapacity:cellDataArray.count]; + } + + for (id cellData in cellDataArray) + { + for (NSString* pattern in patternsList) + { + if (cellData.roomDisplayname && [cellData.roomDisplayname rangeOfString:pattern options:NSCaseInsensitiveSearch].location != NSNotFound) + { + [filteredCellDataArray addObject:cellData]; + break; + } + } + } + } + else + { + filteredCellDataArray = nil; + searchPatternsList = nil; + } + + [self.delegate dataSource:self didCellChange:nil]; +} + +- (id)cellDataAtIndex:(NSInteger)index +{ + if (filteredCellDataArray) + { + if (index < filteredCellDataArray.count) + { + return filteredCellDataArray[index]; + } + } + else if (index < cellDataArray.count) + { + return cellDataArray[index]; + } + + return nil; +} + +- (CGFloat)cellHeightAtIndex:(NSInteger)index +{ + if (self.delegate) + { + id cellData = [self cellDataAtIndex:index]; + + Class class = [self.delegate cellViewClassForCellData:cellData]; + return [class heightForCellData:cellData withMaximumWidth:0]; + } + + return 0; +} + +#pragma mark - Events processing + +/** + Filtering in this method won't have any effect anymore. This class is not maintained. + */ +- (void)loadData +{ + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXRoomSummaryDidChangeNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXKRoomDataSourceSyncStatusChanged object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionNewRoomNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidLeaveRoomNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDirectRoomsDidChangeNotification object:nil]; + + if (!self.mxSession.spaceService.isInitialised && !spaceServiceDidInitialiseObserver) { + MXWeakify(self); + spaceServiceDidInitialiseObserver = [[NSNotificationCenter defaultCenter] addObserverForName:MXSpaceService.didInitialise object:self.mxSession.spaceService queue:nil usingBlock:^(NSNotification * _Nonnull note) { + MXStrongifyAndReturnIfNil(self); + [self loadData]; + }]; + } + + // Reset the table + [internalCellDataArray removeAllObjects]; + + // Retrieve the MXKCellData class to manage the data + Class class = [self cellDataClassForCellIdentifier:kMXKRecentCellIdentifier]; + NSAssert([class conformsToProtocol:@protocol(MXKRecentCellDataStoring)], @"MXKSessionRecentsDataSource only manages MXKCellData that conforms to MXKRecentCellDataStoring protocol"); + + NSDate *startDate = [NSDate date]; + + for (MXRoomSummary *roomSummary in self.mxSession.roomsSummaries) + { + // Filter out private rooms with conference users + if (!roomSummary.isConferenceUserRoom // @TODO Abstract this condition with roomSummary.hiddenFromUser + && !roomSummary.hiddenFromUser) + { + id cellData = [[class alloc] initWithRoomSummary:roomSummary dataSource:self]; + if (cellData) + { + [internalCellDataArray addObject:cellData]; + } + } + } + + for (MXSpaceChildInfo *childInfo in _suggestedRooms) + { + id summary = [[MXRoomSummary alloc] initWithSpaceChildInfo:childInfo]; + id cellData = [[class alloc] initWithRoomSummary:summary + dataSource:self]; + if (cellData) + { + [internalCellDataArray addObject:cellData]; + } + } + + MXLogDebug(@"[MXKSessionRecentsDataSource] Loaded %tu recents in %.3fms", self.mxSession.rooms.count, [[NSDate date] timeIntervalSinceDate:startDate] * 1000); + + // Make sure all rooms have a last message + [self.mxSession fixRoomsSummariesLastMessage]; + + // Report loaded array except if sync is in progress + if (!roomDataSourceManager.isServerSyncInProgress) + { + [self sortCellDataAndNotifyChanges]; + } + + // Listen to MXSession rooms count changes + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXSessionHaveNewRoom:) name:kMXSessionNewRoomNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXSessionDidLeaveRoom:) name:kMXSessionDidLeaveRoomNotification object:nil]; + + // Listen to the direct rooms list + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didDirectRoomsChange:) name:kMXSessionDirectRoomsDidChangeNotification object:nil]; + + // Listen to MXRoomSummary + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didRoomSummaryChanged:) name:kMXRoomSummaryDidChangeNotification object:nil]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXSessionStateChange) name:kMXKRoomDataSourceSyncStatusChanged object:nil]; +} + +- (void)didDirectRoomsChange:(NSNotification *)notif +{ + // Inform the delegate about the update + [self.delegate dataSource:self didCellChange:nil]; +} + +- (void)didRoomSummaryChanged:(NSNotification *)notif +{ + [roomSummaryChangeThrottler throttle:^{ + [self didRoomSummaryChanged2:notif]; + }]; +} + +- (void)didRoomSummaryChanged2:(NSNotification *)notif +{ + MXRoomSummary *roomSummary = notif.object; + if (roomSummary.mxSession == self.mxSession && internalCellDataArray.count) + { + // Find the index of the related cell data + NSInteger index = NSNotFound; + for (index = 0; index < internalCellDataArray.count; index++) + { + id theRoomData = [internalCellDataArray objectAtIndex:index]; + if (theRoomData.roomSummary == roomSummary) + { + break; + } + } + + if (index < internalCellDataArray.count) + { + if (roomSummary.hiddenFromUser) + { + [internalCellDataArray removeObjectAtIndex:index]; + } + else + { + // Create a new instance to not modify the content of 'cellDataArray' (the copy is not a deep copy). + Class class = [self cellDataClassForCellIdentifier:kMXKRecentCellIdentifier]; + id cellData = [[class alloc] initWithRoomSummary:roomSummary dataSource:self]; + if (cellData) + { + [internalCellDataArray replaceObjectAtIndex:index withObject:cellData]; + } + } + + // Report change except if sync is in progress + if (!roomDataSourceManager.isServerSyncInProgress) + { + [self sortCellDataAndNotifyChanges]; + } + } + else + { + MXLogDebug(@"[MXKSessionRecentsDataSource] didRoomLastMessageChanged: Cannot find the changed room summary for %@ (%@). It is probably not managed by this recents data source", roomSummary.roomId, roomSummary); + } + } + else + { + // Inform the delegate that all the room summaries have been updated. + [self.delegate dataSource:self didCellChange:nil]; + } +} + +- (void)didMXSessionHaveNewRoom:(NSNotification *)notif +{ + MXSession *mxSession = notif.object; + if (mxSession == self.mxSession) + { + NSString *roomId = notif.userInfo[kMXSessionNotificationRoomIdKey]; + + // Add the room if there is not yet a cell for it + id roomData = [self cellDataWithRoomId:roomId]; + if (nil == roomData) + { + MXLogDebug(@"MXKSessionRecentsDataSource] Add newly joined room: %@", roomId); + + // Retrieve the MXKCellData class to manage the data + Class class = [self cellDataClassForCellIdentifier:kMXKRecentCellIdentifier]; + + MXRoomSummary *roomSummary = [mxSession roomSummaryWithRoomId:roomId]; + id cellData = [[class alloc] initWithRoomSummary:roomSummary dataSource:self]; + if (cellData) + { + [internalCellDataArray addObject:cellData]; + + // Report change except if sync is in progress + if (!roomDataSourceManager.isServerSyncInProgress) + { + [self sortCellDataAndNotifyChanges]; + } + } + } + } +} + +- (void)didMXSessionDidLeaveRoom:(NSNotification *)notif +{ + MXSession *mxSession = notif.object; + if (mxSession == self.mxSession) + { + NSString *roomId = notif.userInfo[kMXSessionNotificationRoomIdKey]; + id roomData = [self cellDataWithRoomId:roomId]; + + if (roomData) + { + MXLogDebug(@"MXKSessionRecentsDataSource] Remove left room: %@", roomId); + + [internalCellDataArray removeObject:roomData]; + + // Report change except if sync is in progress + if (!roomDataSourceManager.isServerSyncInProgress) + { + [self sortCellDataAndNotifyChanges]; + } + } + } +} + +// Order cells +- (void)sortCellDataAndNotifyChanges +{ + // Order them by origin_server_ts + [internalCellDataArray sortUsingComparator:^NSComparisonResult(id cellData1, id cellData2) + { + return [cellData1.roomSummary.lastMessage compareOriginServerTs:cellData2.roomSummary.lastMessage]; + }]; + + // Snapshot the cell data array + cellDataArray = [internalCellDataArray copy]; + + // Update search result if any + if (searchPatternsList) + { + [self searchWithPatterns:searchPatternsList]; + } + + // Update here data source state + if (state != MXKDataSourceStateReady) + { + state = MXKDataSourceStateReady; + if (self.delegate && [self.delegate respondsToSelector:@selector(dataSource:didStateChange:)]) + { + [self.delegate dataSource:self didStateChange:state]; + } + } + + // And inform the delegate about the update + [self.delegate dataSource:self didCellChange:nil]; +} + +// Find the cell data that stores information about the given room id +- (id)cellDataWithRoomId:(NSString*)roomId +{ + id theRoomData; + + NSMutableArray *dataArray = internalCellDataArray; + if (!roomDataSourceManager.isServerSyncInProgress) + { + dataArray = cellDataArray; + } + + for (id roomData in dataArray) + { + if ([roomData.roomSummary.roomId isEqualToString:roomId]) + { + theRoomData = roomData; + break; + } + } + return theRoomData; +} + +#pragma mark - KVO + +- (void)observeValueForKeyPath:(NSString *)keyPath + ofObject:(id)object + change:(NSDictionary *)change + context:(void *)context +{ + if (object == [MXKAppSettings standardAppSettings] && [keyPath isEqualToString:@"showAllRoomsInHomeSpace"]) + { + if (self.currentSpace == nil) + { + [self loadData]; + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellData.h b/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellData.h new file mode 100644 index 000000000..edd954fd2 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellData.h @@ -0,0 +1,33 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import "MXKRoomMemberCellDataStoring.h" + +/** + `MXKRoomMemberCellData` modelised the data for a `MXKRoomMemberTableViewCell` cell. + */ +@interface MXKRoomMemberCellData : MXKCellData + +/** + The matrix session + */ +@property (nonatomic, readonly) MXSession *mxSession; + + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellData.m b/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellData.m new file mode 100644 index 000000000..c10c5137f --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellData.m @@ -0,0 +1,66 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomMemberCellData.h" + +#import "MXKRoomMemberListDataSource.h" + +@interface MXKRoomMemberCellData () +{ + MXKRoomMemberListDataSource *roomMemberListDataSource; +} + +@end + +@implementation MXKRoomMemberCellData +@synthesize roomMember; +@synthesize memberDisplayName, powerLevel, isTyping; + +- (instancetype)initWithRoomMember:(MXRoomMember*)member roomState:(MXRoomState*)roomState andRoomMemberListDataSource:(MXKRoomMemberListDataSource*)memberListDataSource +{ + self = [self init]; + if (self) + { + roomMember = member; + roomMemberListDataSource = memberListDataSource; + + // Report member info from the current room state + memberDisplayName = [roomState.members memberName:roomMember.userId]; + powerLevel = [roomState memberNormalizedPowerLevel:roomMember.userId]; + isTyping = NO; + } + + return self; +} + +- (void)updateWithRoomState:(MXRoomState*)roomState +{ + memberDisplayName = [roomState.members memberName:roomMember.userId]; + powerLevel = [roomState memberNormalizedPowerLevel:roomMember.userId]; +} + +- (void)dealloc +{ + roomMember = nil; + roomMemberListDataSource = nil; +} + +- (MXSession*)mxSession +{ + return roomMemberListDataSource.mxSession; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellDataStoring.h b/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellDataStoring.h new file mode 100644 index 000000000..053e9a60c --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellDataStoring.h @@ -0,0 +1,67 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import "MXKCellData.h" + +@class MXKRoomMemberListDataSource; + +/** + `MXKRoomMemberCellDataStoring` defines a protocol a class must conform in order to store room member cell data + managed by `MXKRoomMemberListDataSource`. + */ +@protocol MXKRoomMemberCellDataStoring + + +#pragma mark - Data displayed by a room member cell + +/** + The member displayed by the cell. + */ +@property (nonatomic, readonly) MXRoomMember *roomMember; + +/** + The member display name + */ +@property (nonatomic, readonly) NSString *memberDisplayName; + +/** + The member power level + */ +@property (nonatomic, readonly) CGFloat powerLevel; + +/** + YES when member is typing in the room + */ +@property (nonatomic) BOOL isTyping; + +#pragma mark - Public methods +/** + Create a new `MXKCellData` object for a new member cell. + + @param memberListDataSource the `MXKRoomMemberListDataSource` object that will use this instance. + @return the newly created instance. + */ +- (instancetype)initWithRoomMember:(MXRoomMember*)member roomState:(MXRoomState*)roomState andRoomMemberListDataSource:(MXKRoomMemberListDataSource*)memberListDataSource; + +/** + Update the member data with the provided roon state. + */ +- (void)updateWithRoomState:(MXRoomState*)roomState; + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberListDataSource.h b/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberListDataSource.h new file mode 100644 index 000000000..54ffca11c --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberListDataSource.h @@ -0,0 +1,97 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import "MXKDataSource.h" +#import "MXKRoomMemberCellData.h" + +#import "MXKAppSettings.h" + +/** + Identifier to use for cells that display a room member. + */ +extern NSString *const kMXKRoomMemberCellIdentifier; + +/** + The data source for `MXKRoomMemberListViewController`. + */ +@interface MXKRoomMemberListDataSource : MXKDataSource { + +@protected + + /** + The data for the cells served by `MXKRoomMembersDataSource`. + */ + NSMutableArray *cellDataArray; + + /** + The filtered members: sub-list of `cellDataArray` defined by `searchWithPatterns:`. + */ + NSMutableArray *filteredCellDataArray; +} + +/** + The id of the room managed by the data source. + */ +@property (nonatomic, readonly) NSString *roomId; + +/** + The settings used to sort/display room members. + + By default the shared application settings are considered. + */ +@property (nonatomic) MXKAppSettings *settings; + + +#pragma mark - Life cycle + +/** + Initialise the data source to serve members corresponding to the passed room. + + @param roomId the id of the room to get members from. + @param mxSession the Matrix session to get data from. + @return the newly created instance. + */ +- (instancetype)initWithRoomId:(NSString*)roomId andMatrixSession:(MXSession*)mxSession; + +/** + Filter the current members list according to the provided patterns. + When patterns are not empty, the search result is stored in `filteredCellDataArray`, + this array provides then data for the cells served by `MXKRoomMembersDataSource`. + + @param patternsList the list of patterns (`NSString` instances) to match with. Set nil to cancel search. + */ +- (void)searchWithPatterns:(NSArray*)patternsList; + +/** + Get the data for the cell at the given index. + + @param index the index of the cell in the array + @return the cell data + */ +- (id)cellDataAtIndex:(NSInteger)index; + +/** + Get height of the celle at the given index. + + @param index the index of the cell in the array + @return the cell height + */ +- (CGFloat)cellHeightAtIndex:(NSInteger)index; + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberListDataSource.m b/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberListDataSource.m new file mode 100644 index 000000000..ab8b879ae --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberListDataSource.m @@ -0,0 +1,464 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomMemberListDataSource.h" + +@import MatrixSDK.MXCallManager; + +#import "MXKRoomMemberCellData.h" + + +#pragma mark - Constant definitions +NSString *const kMXKRoomMemberCellIdentifier = @"kMXKRoomMemberCellIdentifier"; + + +@interface MXKRoomMemberListDataSource () +{ + /** + The room in which members are listed. + */ + MXRoom *mxRoom; + + /** + Cache for loaded room state. + */ + MXRoomState *mxRoomState; + + /** + The members events listener. + */ + id membersListener; + + /** + The typing notification listener in the room. + */ + id typingNotifListener; +} + +@end + +@implementation MXKRoomMemberListDataSource + +- (instancetype)initWithRoomId:(NSString*)roomId andMatrixSession:(MXSession*)mxSession +{ + self = [super initWithMatrixSession:mxSession]; + if (self) + { + _roomId = roomId; + + cellDataArray = [NSMutableArray array]; + filteredCellDataArray = nil; + + // Consider the shared app settings by default + _settings = [MXKAppSettings standardAppSettings]; + + // Set default data class + [self registerCellDataClass:MXKRoomMemberCellData.class forCellIdentifier:kMXKRoomMemberCellIdentifier]; + } + return self; +} + +- (void)destroy +{ + cellDataArray = nil; + filteredCellDataArray = nil; + + if (membersListener) + { + [self.mxSession removeListener:membersListener]; + membersListener = nil; + } + + if (typingNotifListener) + { + MXWeakify(self); + [mxRoom liveTimeline:^(MXEventTimeline *liveTimeline) { + MXStrongifyAndReturnIfNil(self); + + [liveTimeline removeListener:self->typingNotifListener]; + self->typingNotifListener = nil; + }]; + } + + [super destroy]; +} + +- (void)didMXSessionStateChange +{ + if (MXSessionStateStoreDataReady <= self.mxSession.state) + { + // Check whether the room is not already set + if (!mxRoom) + { + mxRoom = [self.mxSession roomWithRoomId:_roomId]; + if (mxRoom) + { + MXWeakify(self); + [mxRoom state:^(MXRoomState *roomState) { + MXStrongifyAndReturnIfNil(self); + + self->mxRoomState = roomState; + + [self loadData]; + + // Register on typing notif + [self listenTypingNotifications]; + + // Register on members events + [self listenMembersEvents]; + + // Update here data source state + self->state = MXKDataSourceStateReady; + + // Notify delegate + if (self.delegate) + { + if ([self.delegate respondsToSelector:@selector(dataSource:didStateChange:)]) + { + [self.delegate dataSource:self didStateChange:self->state]; + } + [self.delegate dataSource:self didCellChange:nil]; + } + }]; + } + else + { + MXLogDebug(@"[MXKRoomMemberDataSource] The user does not know the room %@", _roomId); + + // Update here data source state + state = MXKDataSourceStateFailed; + + // Notify delegate + if (self.delegate && [self.delegate respondsToSelector:@selector(dataSource:didStateChange:)]) + { + [self.delegate dataSource:self didStateChange:state]; + } + } + } + } +} + +- (void)searchWithPatterns:(NSArray*)patternsList +{ + if (patternsList.count) + { + if (filteredCellDataArray) + { + [filteredCellDataArray removeAllObjects]; + } + else + { + filteredCellDataArray = [NSMutableArray arrayWithCapacity:cellDataArray.count]; + } + + for (id cellData in cellDataArray) + { + for (NSString* pattern in patternsList) + { + if ([[cellData memberDisplayName] rangeOfString:pattern options:NSCaseInsensitiveSearch].location != NSNotFound) + { + [filteredCellDataArray addObject:cellData]; + break; + } + } + } + } + else + { + filteredCellDataArray = nil; + } + + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } +} + +- (id)cellDataAtIndex:(NSInteger)index +{ + if (filteredCellDataArray) + { + return filteredCellDataArray[index]; + } + return cellDataArray[index]; +} + +- (CGFloat)cellHeightAtIndex:(NSInteger)index +{ + if (self.delegate) + { + id cellData = [self cellDataAtIndex:index]; + + Class class = [self.delegate cellViewClassForCellData:cellData]; + return [class heightForCellData:cellData withMaximumWidth:0]; + } + return 0; +} + +#pragma mark - Members processing + +- (void)loadData +{ + NSArray* membersList = [mxRoomState.members membersWithoutConferenceUser]; + + if (!_settings.showLeftMembersInRoomMemberList) + { + NSMutableArray* filteredMembers = [[NSMutableArray alloc] init]; + + for (MXRoomMember* member in membersList) + { + // Filter out left users + if (member.membership != MXMembershipLeave) + { + [filteredMembers addObject:member]; + } + } + + membersList = filteredMembers; + } + + [cellDataArray removeAllObjects]; + + // Retrieve the MXKCellData class to manage the data + Class class = [self cellDataClassForCellIdentifier:kMXKRoomMemberCellIdentifier]; + NSAssert([class conformsToProtocol:@protocol(MXKRoomMemberCellDataStoring)], @"MXKRoomMemberListDataSource only manages MXKCellData that conforms to MXKRoomMemberCellDataStoring protocol"); + + for (MXRoomMember *member in membersList) + { + + id cellData = [[class alloc] initWithRoomMember:member roomState:mxRoomState andRoomMemberListDataSource:self]; + if (cellData) + { + [cellDataArray addObject:cellData]; + } + } + + [self sortMembers]; +} + +- (void)sortMembers +{ + NSArray *sortedMembers = [cellDataArray sortedArrayUsingComparator:^NSComparisonResult(id member1, id member2) + { + + // Move banned and left members at the end of the list + if (member1.roomMember.membership == MXMembershipLeave || member1.roomMember.membership == MXMembershipBan) + { + if (member2.roomMember.membership != MXMembershipLeave && member2.roomMember.membership != MXMembershipBan) + { + return NSOrderedDescending; + } + } + else if (member2.roomMember.membership == MXMembershipLeave || member2.roomMember.membership == MXMembershipBan) + { + return NSOrderedAscending; + } + + // Move invited members just before left and banned members + if (member1.roomMember.membership == MXMembershipInvite) + { + if (member2.roomMember.membership != MXMembershipInvite) + { + return NSOrderedDescending; + } + } + else if (member2.roomMember.membership == MXMembershipInvite) + { + return NSOrderedAscending; + } + + if (self->_settings.sortRoomMembersUsingLastSeenTime) + { + // Get the users that correspond to these members + MXUser *user1 = [self.mxSession userWithUserId:member1.roomMember.userId]; + MXUser *user2 = [self.mxSession userWithUserId:member2.roomMember.userId]; + + // Move users who are not online or unavailable at the end (before invited users) + if ((user1.presence == MXPresenceOnline) || (user1.presence == MXPresenceUnavailable)) + { + if ((user2.presence != MXPresenceOnline) && (user2.presence != MXPresenceUnavailable)) + { + return NSOrderedAscending; + } + } + else if ((user2.presence == MXPresenceOnline) || (user2.presence == MXPresenceUnavailable)) + { + return NSOrderedDescending; + } + else + { + // Here both users are neither online nor unavailable (the lastActive ago is useless) + // We will sort them according to their display, by keeping in front the offline users + if (user1.presence == MXPresenceOffline) + { + if (user2.presence != MXPresenceOffline) + { + return NSOrderedAscending; + } + } + else if (user2.presence == MXPresenceOffline) + { + return NSOrderedDescending; + } + return [[self->mxRoomState.members memberSortedName:member1.roomMember.userId] compare:[self->mxRoomState.members memberSortedName:member2.roomMember.userId] options:NSCaseInsensitiveSearch]; + } + + // Consider user's lastActive ago value + if (user1.lastActiveAgo < user2.lastActiveAgo) + { + return NSOrderedAscending; + } + else if (user1.lastActiveAgo == user2.lastActiveAgo) + { + return [[self->mxRoomState.members memberSortedName:member1.roomMember.userId] compare:[self->mxRoomState.members memberSortedName:member2.roomMember.userId] options:NSCaseInsensitiveSearch]; + } + return NSOrderedDescending; + } + else + { + // Move user without display name at the end (before invited users) + if (member1.roomMember.displayname.length) + { + if (!member2.roomMember.displayname.length) + { + return NSOrderedAscending; + } + } + else if (member2.roomMember.displayname.length) + { + return NSOrderedDescending; + } + + return [[self->mxRoomState.members memberSortedName:member1.roomMember.userId] compare:[self->mxRoomState.members memberSortedName:member2.roomMember.userId] options:NSCaseInsensitiveSearch]; + } + }]; + + cellDataArray = [NSMutableArray arrayWithArray:sortedMembers]; +} + +- (void)listenMembersEvents +{ + // Remove the previous live listener + if (membersListener) + { + [self.mxSession removeListener:membersListener]; + } + + // Register a listener for events that concern room members + NSArray *mxMembersEvents = @[ + kMXEventTypeStringRoomMember, + kMXEventTypeStringRoomPowerLevels, + kMXEventTypeStringPresence + ]; + membersListener = [self.mxSession listenToEventsOfTypes:mxMembersEvents onEvent:^(MXEvent *event, MXTimelineDirection direction, id customObject) + { + // consider only live event + if (direction == MXTimelineDirectionForwards) + { + // Check the room Id (if any) + if (event.roomId && [event.roomId isEqualToString:self->mxRoom.roomId] == NO) + { + // This event does not concern the current room members + return; + } + + // refresh the whole members list. TODO GFO refresh only the updated members. + [self loadData]; + + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } + } + }]; +} + +- (void)listenTypingNotifications +{ + // Remove the previous live listener + if (self->typingNotifListener) + { + [mxRoom removeListener:self->typingNotifListener]; + } + + // Add typing notification listener + self->typingNotifListener = [mxRoom listenToEventsOfTypes:@[kMXEventTypeStringTypingNotification] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + // Handle only live events + if (direction == MXTimelineDirectionForwards) + { + // Retrieve typing users list + NSMutableArray *typingUsers = [NSMutableArray arrayWithArray:self->mxRoom.typingUsers]; + // Remove typing info for the current user + NSUInteger index = [typingUsers indexOfObject:self.mxSession.myUser.userId]; + if (index != NSNotFound) + { + [typingUsers removeObjectAtIndex:index]; + } + + for (id cellData in self->cellDataArray) + { + if ([typingUsers indexOfObject:cellData.roomMember.userId] == NSNotFound) + { + cellData.isTyping = NO; + } + else + { + cellData.isTyping = YES; + } + } + + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } + } + }]; +} + +#pragma mark - UITableViewDataSource + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + if (filteredCellDataArray) + { + return filteredCellDataArray.count; + } + return cellDataArray.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + id roomData = [self cellDataAtIndex:indexPath.row]; + + if (roomData && self.delegate) + { + NSString *identifier = [self.delegate cellReuseIdentifierForCellData:roomData]; + if (identifier) + { + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier forIndexPath:indexPath]; + + // Make the bubble display the data + [cell render:roomData]; + + return cell; + } + } + + // Return a fake cell to prevent app from crashing. + return [[UITableViewCell alloc] init]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Search/MXKSearchCellData.h b/Riot/Modules/MatrixKit/Models/Search/MXKSearchCellData.h new file mode 100644 index 000000000..d92c3b073 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Search/MXKSearchCellData.h @@ -0,0 +1,25 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKCellData.h" +#import "MXKSearchCellDataStoring.h" + +/** + `MXKSearchCellData` modelised the data for a `MXKSearchCell` cell. + */ +@interface MXKSearchCellData : MXKCellData + +@end diff --git a/Riot/Modules/MatrixKit/Models/Search/MXKSearchCellData.m b/Riot/Modules/MatrixKit/Models/Search/MXKSearchCellData.m new file mode 100644 index 000000000..246dbd87d --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Search/MXKSearchCellData.m @@ -0,0 +1,69 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKSearchCellData.h" + +#import "MXKSearchDataSource.h" + + +@implementation MXKSearchCellData +@synthesize roomId, senderDisplayName; +@synthesize searchResult, title, message, date, shouldShowRoomDisplayName, roomDisplayName, attachment, isAttachmentWithThumbnail, attachmentIcon; + +- (instancetype)initWithSearchResult:(MXSearchResult *)searchResult2 andSearchDataSource:(MXKSearchDataSource *)searchDataSource +{ + self = [super init]; + if (self) + { + searchResult = searchResult2; + + if (searchDataSource.roomEventFilter.rooms.count == 1) + { + // We are displaying a search within a room + // As title, display the user id + title = searchResult.result.sender; + + roomId = searchDataSource.roomEventFilter.rooms[0]; + } + else + { + // We are displaying a search over all user's rooms + // As title, display the room name of this search result + MXRoom *room = [searchDataSource.mxSession roomWithRoomId:searchResult.result.roomId]; + if (room) + { + title = room.summary.displayname; + } + else + { + title = searchResult.result.roomId; + } + } + + date = [searchDataSource.eventFormatter dateStringFromEvent:searchResult.result withTime:YES]; + + // Code from [MXEventFormatter stringFromEvent] for the particular case of a text message + message = [searchResult.result.content[@"body"] isKindOfClass:[NSString class]] ? searchResult.result.content[@"body"] : nil; + } + return self; +} + ++ (void)cellDataWithSearchResult:(MXSearchResult *)searchResult andSearchDataSource:(MXKSearchDataSource *)searchDataSource onComplete:(void (^)(id))onComplete +{ + onComplete([[self alloc] initWithSearchResult:searchResult andSearchDataSource:searchDataSource]); +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Search/MXKSearchCellDataStoring.h b/Riot/Modules/MatrixKit/Models/Search/MXKSearchCellDataStoring.h new file mode 100644 index 000000000..b5ab3536b --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Search/MXKSearchCellDataStoring.h @@ -0,0 +1,83 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import "MXKAttachment.h" + +@class MXKSearchDataSource; + +/** + `MXKSearchCellDataStoring` defines a protocol a class must conform in order to store + a search result in a cell data managed by `MXKSearchDataSource`. + */ +@protocol MXKSearchCellDataStoring + +/** + The room id + */ +@property (nonatomic) NSString *roomId; + +@property (nonatomic, readonly) NSString *title; +@property (nonatomic, readonly) NSString *message; +@property (nonatomic, readonly) NSString *date; + +// Bulk result returned by MatrixSDK +@property (nonatomic, readonly) MXSearchResult *searchResult; + +/** + Tell whether the room display name should be displayed in the cell. NO by default. + */ +@property (nonatomic) BOOL shouldShowRoomDisplayName; + +/** + The room display name. + */ +@property (nonatomic) NSString *roomDisplayName; + +/** + The sender display name. + */ +@property (nonatomic) NSString *senderDisplayName; + +/** + The bubble attachment (if any). + */ +@property (nonatomic) MXKAttachment *attachment; + +/** + YES when the bubble correspond to an attachment displayed with a thumbnail (see image, video). + */ +@property (nonatomic, readonly) BOOL isAttachmentWithThumbnail; + +/** + The default icon relative to the attachment (if any). + */ +@property (nonatomic, readonly) UIImage* attachmentIcon; + + +#pragma mark - Public methods +/** + Create a new `MXKCellData` object for a new search result cell. + + @param searchResult Bulk result returned by MatrixSDK. + @param searchDataSource the `MXKSearchDataSource` object that will use this instance. + @param onComplete a block providing the newly created instance. + */ ++ (void)cellDataWithSearchResult:(MXSearchResult*)searchResult andSearchDataSource:(MXKSearchDataSource*)searchDataSource onComplete:(void (^)(id cellData))onComplete; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Search/MXKSearchDataSource.h b/Riot/Modules/MatrixKit/Models/Search/MXKSearchDataSource.h new file mode 100644 index 000000000..5a89c09d1 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Search/MXKSearchDataSource.h @@ -0,0 +1,108 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKDataSource.h" +#import "MXKSearchCellDataStoring.h" + +#import "MXKEventFormatter.h" + +/** + String identifying the object used to store and prepare the cell data of a result during a message search. + */ +extern NSString *const kMXKSearchCellDataIdentifier; + +/** + The data source for `MXKSearchViewController` in case of message search. + + Use the `initWithMatrixSession:` constructor to search in all user's rooms. + Use the `initWithRoomId:andMatrixSession: constructor to search in a specific room. + */ +@interface MXKSearchDataSource : MXKDataSource +{ + @protected + /** + List of results retrieved from the server. + The` MXKSearchDataSource` class stores MXKSearchCellDataStoring objects in it. + */ + NSMutableArray *cellDataArray; +} + +/** + The current search. + */ +@property (nonatomic, readonly) NSString *searchText; + +/** + The room events filter which is applied during the messages search. + */ +@property (nonatomic) MXRoomEventFilter *roomEventFilter; + +/** + Total number of results available on the server. + */ +@property (nonatomic, readonly) NSUInteger serverCount; + +/** + The events to display texts formatter. + `MXKCellData` instances can use it to format text. + */ +@property (nonatomic) MXKEventFormatter *eventFormatter; + +/** + Flag indicating if there are still results (in the past) to get with paginateBack. + */ +@property (nonatomic, readonly) BOOL canPaginate; + +/** + Tell whether the room display name should be displayed in each result cell. NO by default. + */ +@property (nonatomic) BOOL shouldShowRoomDisplayName; + + +/** + Launch a message search homeserver side. + + @discussion The result depends on the 'roomEventFilter' propertie. + + @param textPattern the text to search in messages data. + @param force tell whether the search must be launched even if the text pattern is unchanged. + */ +- (void)searchMessages:(NSString*)textPattern force:(BOOL)force; + +/** + Load more results from the past. + */ +- (void)paginateBack; + +/** + Get the data for the cell at the given index. + + @param index the index of the cell in the array + @return the cell data + */ +- (MXKCellData*)cellDataAtIndex:(NSInteger)index; + +/** + Convert the results of a homeserver search requests into cells. + + This methods is in charge of filling `cellDataArray`. + + @param roomEventResults the homeserver response as provided by MatrixSDK. + @param onComplete the block called once complete. + */ +- (void)convertHomeserverResultsIntoCells:(MXSearchRoomEventResults*)roomEventResults onComplete:(dispatch_block_t)onComplete; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Search/MXKSearchDataSource.m b/Riot/Modules/MatrixKit/Models/Search/MXKSearchDataSource.m new file mode 100644 index 000000000..c79dcf217 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Search/MXKSearchDataSource.m @@ -0,0 +1,275 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKSearchDataSource.h" + +#import "MXKSearchCellData.h" + +#pragma mark - Constant definitions +NSString *const kMXKSearchCellDataIdentifier = @"kMXKSearchCellDataIdentifier"; + + +@interface MXKSearchDataSource () +{ + /** + The current search request. + */ + MXHTTPOperation *searchRequest; + + /** + Token that can be used to get the next batch of results in the group, if exists. + */ + NSString *nextBatch; +} + +@end + +@implementation MXKSearchDataSource + +- (instancetype)initWithMatrixSession:(MXSession *)mxSession +{ + self = [super initWithMatrixSession:mxSession]; + if (self) + { + // Set default data and view classes + // Cell data + [self registerCellDataClass:MXKSearchCellData.class forCellIdentifier:kMXKSearchCellDataIdentifier]; + + // Set default MXEvent -> NSString formatter + _eventFormatter = [[MXKEventFormatter alloc] initWithMatrixSession:mxSession]; + + _roomEventFilter = [[MXRoomEventFilter alloc] init]; + + cellDataArray = [NSMutableArray array]; + } + return self; +} + +- (void)destroy +{ + cellDataArray = nil; + _eventFormatter = nil; + + _roomEventFilter = nil; + + [super destroy]; +} + +- (void)searchMessages:(NSString*)textPattern force:(BOOL)force +{ + if (force || ![_searchText isEqualToString:textPattern]) + { + // Reset data before making the new search + if (searchRequest) + { + [searchRequest cancel]; + searchRequest = nil; + } + + _searchText = textPattern; + _serverCount = 0; + _canPaginate = NO; + nextBatch = nil; + + self.state = MXKDataSourceStatePreparing; + [cellDataArray removeAllObjects]; + + if (textPattern.length) + { + MXLogDebug(@"[MXKSearchDataSource] searchMessages: %@", textPattern); + [self doSearch]; + } + else + { + // Refresh table display. + self.state = MXKDataSourceStateReady; + [self.delegate dataSource:self didCellChange:nil]; + } + } +} + +- (void)paginateBack +{ + MXLogDebug(@"[MXKSearchDataSource] paginateBack"); + + self.state = MXKDataSourceStatePreparing; + [self doSearch]; +} + +- (MXKCellData*)cellDataAtIndex:(NSInteger)index +{ + MXKCellData *cellData; + if (index < cellDataArray.count) + { + cellData = cellDataArray[index]; + } + + return cellData; +} + +- (void)convertHomeserverResultsIntoCells:(MXSearchRoomEventResults*)roomEventResults onComplete:(dispatch_block_t)onComplete +{ + // Retrieve the MXKCellData class to manage the data + // Note: MXKSearchDataSource only manages MXKCellData that conforms to MXKSearchCellDataStoring protocol + // see `[registerCellDataClass:forCellIdentifier:]` + Class class = [self cellDataClassForCellIdentifier:kMXKSearchCellDataIdentifier]; + + dispatch_group_t group = dispatch_group_create(); + + for (MXSearchResult *result in roomEventResults.results) + { + dispatch_group_enter(group); + [class cellDataWithSearchResult:result andSearchDataSource:self onComplete:^(__autoreleasing id cellData) { + dispatch_group_leave(group); + + if (cellData) + { + ((id)cellData).shouldShowRoomDisplayName = self.shouldShowRoomDisplayName; + + // Use profile information as data to display + MXSearchUserProfile *userProfile = result.context.profileInfo[result.result.sender]; + cellData.senderDisplayName = userProfile.displayName; + + [self->cellDataArray insertObject:cellData atIndex:0]; + } + }]; + } + + dispatch_group_notify(group, dispatch_get_main_queue(), ^{ + onComplete(); + }); +} + +#pragma mark - Private methods + +// Update the MXKDataSource and notify the delegate +- (void)setState:(MXKDataSourceState)newState +{ + state = newState; + + if (self.delegate) + { + if ([self.delegate respondsToSelector:@selector(dataSource:didStateChange:)]) + { + [self.delegate dataSource:self didStateChange:state]; + } + } +} + +- (void)doSearch +{ + // Handle one request at a time + if (searchRequest) + { + return; + } + + NSDate *startDate = [NSDate date]; + + MXWeakify(self); + searchRequest = [self.mxSession.matrixRestClient searchMessagesWithText:_searchText roomEventFilter:_roomEventFilter beforeLimit:0 afterLimit:0 nextBatch:nextBatch success:^(MXSearchRoomEventResults *roomEventResults) { + MXStrongifyAndReturnIfNil(self); + + MXLogDebug(@"[MXKSearchDataSource] searchMessages: %@ (%d). Done in %.3fms - Got %tu / %tu messages", self.searchText, self.roomEventFilter.containsURL, [[NSDate date] timeIntervalSinceDate:startDate] * 1000, roomEventResults.results.count, roomEventResults.count); + + self->searchRequest = nil; + self->_serverCount = roomEventResults.count; + self->nextBatch = roomEventResults.nextBatch; + self->_canPaginate = (nil != self->nextBatch); + + // Process HS response to cells data + MXWeakify(self); + [self convertHomeserverResultsIntoCells:roomEventResults onComplete:^{ + MXStrongifyAndReturnIfNil(self); + + self.state = MXKDataSourceStateReady; + + // Provide changes information to the delegate + NSIndexSet *insertedIndexes; + if (roomEventResults.results.count) + { + insertedIndexes = [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, roomEventResults.results.count)]; + } + + [self.delegate dataSource:self didCellChange:insertedIndexes]; + }]; + + } failure:^(NSError *error) { + MXStrongifyAndReturnIfNil(self); + + self->searchRequest = nil; + self.state = MXKDataSourceStateFailed; + }]; +} + +#pragma mark - Override MXKDataSource + +- (void)registerCellDataClass:(Class)cellDataClass forCellIdentifier:(NSString *)identifier +{ + if ([identifier isEqualToString:kMXKSearchCellDataIdentifier]) + { + // Sanity check + NSAssert([cellDataClass conformsToProtocol:@protocol(MXKSearchCellDataStoring)], @"MXKSearchDataSource only manages MXKCellData that conforms to MXKSearchCellDataStoring protocol"); + } + + [super registerCellDataClass:cellDataClass forCellIdentifier:identifier]; +} + +- (void)cancelAllRequests +{ + if (searchRequest) + { + [searchRequest cancel]; + searchRequest = nil; + } + + [super cancelAllRequests]; +} + +#pragma mark - UITableViewDataSource + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + return cellDataArray.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + MXKCellData* cellData = [self cellDataAtIndex:indexPath.row]; + + NSString *cellIdentifier = [self.delegate cellReuseIdentifierForCellData:cellData]; + if (cellIdentifier) + { + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellIdentifier forIndexPath:indexPath]; + + // Make the bubble display the data + [cell render:cellData]; + + // Disable any interactions defined in the cell + // because we want [tableView didSelectRowAtIndexPath:] to be called + cell.contentView.userInteractionEnabled = NO; + + // Force background color change on selection + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + + return cell; + } + + // Return a fake cell to prevent app from crashing. + return [[UITableViewCell alloc] init]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorAlertPresentation.h b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorAlertPresentation.h new file mode 100644 index 000000000..b5fed5637 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorAlertPresentation.h @@ -0,0 +1,26 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +@import Foundation; + +#import "MXKErrorPresentation.h" + +/** + `MXKErrorAlertPresentation` is a concrete implementation of `MXKErrorPresentation` using UIAlertViewController. Display error alert from a view controller. + */ +@interface MXKErrorAlertPresentation : NSObject + +@end diff --git a/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorAlertPresentation.m b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorAlertPresentation.m new file mode 100644 index 000000000..c8ff3ad96 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorAlertPresentation.m @@ -0,0 +1,108 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKErrorAlertPresentation.h" + +#import "MXKErrorPresentableBuilder.h" +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@interface MXKErrorAlertPresentation() + +@property (nonatomic, strong) MXKErrorPresentableBuilder *errorPresentableBuidler; + +@end + +#pragma mark - Implementation + +@implementation MXKErrorAlertPresentation + +#pragma mark - Setup & Teardown + +- (instancetype)init +{ + self = [super init]; + if (self) { + _errorPresentableBuidler = [[MXKErrorPresentableBuilder alloc] init]; + } + return self; +} + +#pragma mark - MXKErrorPresentation + +- (void)presentErrorFromViewController:(UIViewController*)viewController + title:(NSString*)title + message:(NSString*)message + animated:(BOOL)animated + handler:(void (^)(void))handler +{ + + UIAlertController *alert = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * _Nonnull action) { + if (handler) + { + handler(); + } + }]]; + + [viewController presentViewController:alert animated:animated completion:nil]; +} + +- (void)presentErrorFromViewController:(UIViewController*)viewController + forError:(NSError*)error + animated:(BOOL)animated + handler:(void (^)(void))handler +{ + id errorPresentable = [self.errorPresentableBuidler errorPresentableFromError:error]; + + if (errorPresentable) + { + [self presentErrorFromViewController:viewController + forErrorPresentable:errorPresentable + animated:animated + handler:handler]; + } +} + +- (void)presentGenericErrorFromViewController:(UIViewController*)viewController + animated:(BOOL)animated + handler:(void (^)(void))handler +{ + id errorPresentable = [self.errorPresentableBuidler commonErrorPresentable]; + + [self presentErrorFromViewController:viewController + forErrorPresentable:errorPresentable + animated:animated + handler:handler]; +} + +- (void)presentErrorFromViewController:(UIViewController*)viewController + forErrorPresentable:(id)errorPresentable + animated:(BOOL)animated + handler:(void (^)(void))handler +{ + [self presentErrorFromViewController:viewController + title:errorPresentable.title + message:errorPresentable.message + animated:animated + handler:handler]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentable.h b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentable.h new file mode 100644 index 000000000..d8d69498c --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentable.h @@ -0,0 +1,29 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +/** + `MXKErrorPresentable` describe an error to display on screen. + */ +@protocol MXKErrorPresentable + +@required + +@property (strong, nonatomic, readonly) NSString *title; +@property (strong, nonatomic, readonly) NSString *message; + +- (id)initWithTitle:(NSString*)title message:(NSString*)message; + +@end diff --git a/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentableBuilder.h b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentableBuilder.h new file mode 100644 index 000000000..8760f7fb3 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentableBuilder.h @@ -0,0 +1,41 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +@import Foundation; + +#import "MXKErrorPresentable.h" + +/** + `MXKErrorPresentableBuilder` enable to create error to present on screen. + */ +@interface MXKErrorPresentableBuilder : NSObject + +/** + Build a displayable error from a NSError. + + @param error an NSError. + @return Return nil in case of network request cancellation error otherwise return a presentable error from NSError informations. + */ +- (id )errorPresentableFromError:(NSError*)error; + +/** + Build a common displayable error. Generic error message to present as fallback when error explanation can't be user friendly. + + @return Common default error. + */ +- (id )commonErrorPresentable; + +@end diff --git a/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentableBuilder.m b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentableBuilder.m new file mode 100644 index 000000000..10c1aca39 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentableBuilder.m @@ -0,0 +1,56 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKErrorPresentableBuilder.h" + +#import "NSBundle+MatrixKit.h" +#import "MXKErrorViewModel.h" + +#import "MXKSwiftHeader.h" + +@implementation MXKErrorPresentableBuilder + +- (id )errorPresentableFromError:(NSError*)error +{ + // Ignore nil error or connection cancellation error + if (!error || ([error.domain isEqualToString:NSURLErrorDomain] && error.code == NSURLErrorCancelled)) + { + return nil; + } + + NSString *title = [error.userInfo valueForKey:NSLocalizedFailureReasonErrorKey]; + NSString *message = [error.userInfo valueForKey:NSLocalizedDescriptionKey]; + + if (!title) + { + title = [MatrixKitL10n error]; + } + + if (!message) + { + message = [MatrixKitL10n errorCommonMessage]; + } + + return [[MXKErrorViewModel alloc] initWithTitle:title message:message]; +} + +- (id )commonErrorPresentable +{ + return [[MXKErrorViewModel alloc] initWithTitle:[MatrixKitL10n error] + message:[MatrixKitL10n errorCommonMessage]]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentation.h b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentation.h new file mode 100644 index 000000000..03ea60974 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentation.h @@ -0,0 +1,49 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +@import Foundation; +@import UIKit; + +#import "MXKErrorPresentable.h" + +/** + `MXKErrorPresentation` describe an error display handler for presenting error from a view controller. + */ +@protocol MXKErrorPresentation + +- (void)presentErrorFromViewController:(UIViewController*)viewController + title:(NSString*)title + message:(NSString*)message + animated:(BOOL)animated + handler:(void (^)(void))handler; + +- (void)presentErrorFromViewController:(UIViewController*)viewController + forError:(NSError*)error + animated:(BOOL)animated + handler:(void (^)(void))handler; + +- (void)presentGenericErrorFromViewController:(UIViewController*)viewController + animated:(BOOL)animated + handler:(void (^)(void))handler; + +@required + +- (void)presentErrorFromViewController:(UIViewController*)viewController + forErrorPresentable:(id)errorPresentable + animated:(BOOL)animated + handler:(void (^)(void))handler; + +@end diff --git a/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorViewModel.h b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorViewModel.h new file mode 100644 index 000000000..15aac9350 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorViewModel.h @@ -0,0 +1,26 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +@import Foundation; + +#import "MXKErrorPresentable.h" + +/** + `MXKErrorViewModel` is a concrete implementation of `MXKErrorPresentable` + */ +@interface MXKErrorViewModel : NSObject + +@end diff --git a/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorViewModel.m b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorViewModel.m new file mode 100644 index 000000000..18e54464e --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorViewModel.m @@ -0,0 +1,41 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKErrorViewModel.h" + +@interface MXKErrorViewModel() + +@property (strong, nonatomic) NSString *title; +@property (strong, nonatomic) NSString *message; + +@end + +@implementation MXKErrorViewModel + +- (id)initWithTitle:(NSString*)title message:(NSString*)message +{ + self = [super init]; + + if (self) + { + _title = title; + _message = message; + } + + return self; +} + +@end diff --git a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.h b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.h new file mode 100644 index 000000000..0df5c3a75 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.h @@ -0,0 +1,409 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import + +#import "MXKAppSettings.h" + +@protocol MarkdownToHTMLRendererProtocol; +/** + Formatting result codes. + */ +typedef enum : NSUInteger { + + /** + The formatting was successful. + */ + MXKEventFormatterErrorNone = 0, + + /** + The formatter knows the event type but it encountered data that it does not support. + */ + MXKEventFormatterErrorUnsupported, + + /** + The formatter encountered unexpected data in the event. + */ + MXKEventFormatterErrorUnexpected, + + /** + The formatter does not support the type of the passed event. + */ + MXKEventFormatterErrorUnknownEventType + +} MXKEventFormatterError; + +/** + `MXKEventFormatter` is an utility class for formating Matrix events into strings which + will be displayed to the end user. + */ +@interface MXKEventFormatter : NSObject +{ +@protected + /** + The matrix session. Used to get contextual data. + */ + MXSession *mxSession; + + /** + The date formatter used to build date string without time information. + */ + NSDateFormatter *dateFormatter; + + /** + The time formatter used to build time string without date information. + */ + NSDateFormatter *timeFormatter; + + /** + The default room summary updater from the MXSession. + */ + MXRoomSummaryUpdater *defaultRoomSummaryUpdater; +} + +/** + The settings used to handle room events. + + By default the shared application settings are considered. + */ +@property (nonatomic) MXKAppSettings *settings; + +/** + Flag indicating if the formatter must build strings that will be displayed as subtitle. + Default is NO. + */ +@property (nonatomic) BOOL isForSubtitle; + +/** + Flags indicating if the formatter must create clickable links for Matrix user ids, + room ids, room aliases or event ids. + Default is NO. + */ +@property (nonatomic) BOOL treatMatrixUserIdAsLink; +@property (nonatomic) BOOL treatMatrixRoomIdAsLink; +@property (nonatomic) BOOL treatMatrixRoomAliasAsLink; +@property (nonatomic) BOOL treatMatrixEventIdAsLink; +@property (nonatomic) BOOL treatMatrixGroupIdAsLink; + +/** + Initialise the event formatter. + + @param mxSession the Matrix to retrieve contextual data. + @return the newly created instance. + */ +- (instancetype)initWithMatrixSession:(MXSession*)mxSession; + +/** + Initialise the date and time formatters. + This formatter could require to be updated after updating the device settings. + e.g the time format switches from 24H format to AM/PM. + */ +- (void)initDateTimeFormatters; + +/** + The types of events allowed to be displayed in the room history. + No string will be returned by the formatter for the events whose the type doesn't belong to this array. + + Default is nil. All messages types are displayed. + */ +@property (nonatomic) NSArray *eventTypesFilterForMessages; + +@property (nonatomic, strong) id markdownToHTMLRenderer; + +/** + Checks whether the event is related to an attachment and if it is supported. + + @param event an event. + @return YES if the provided event is related to a supported attachment type. + */ +- (BOOL)isSupportedAttachment:(MXEvent*)event; + +#pragma mark - Events to strings conversion methods +/** + Compose the event sender display name according to the current room state. + + @param event the event to format. + @param roomState the room state right before the event. + @return the sender display name + */ +- (NSString*)senderDisplayNameForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState; + +/** + Compose the event target display name according to the current room state. + + @discussion "target" refers to the room member who is the target of this event (if any), e.g. + the invitee, the person being banned, etc. + + @param event the event to format. + @param roomState the room state right before the event. + @return the target display name (if any) + */ +- (NSString*)targetDisplayNameForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState; + +/** + Retrieve the avatar url of the event sender from the current room state. + + @param event the event to format. + @param roomState the room state right before the event. + @return the sender avatar url + */ +- (NSString*)senderAvatarUrlForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState; + +/** + Retrieve the avatar url of the event target from the current room state. + + @discussion "target" refers to the room member who is the target of this event (if any), e.g. + the invitee, the person being banned, etc. + + @param event the event to format. + @param roomState the room state right before the event. + @return the target avatar url (if any) + */ +- (NSString*)targetAvatarUrlForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState; + +/** + Generate a displayable string representating the event. + + @param event the event to format. + @param roomState the room state right before the event. + @param error the error code. In case of formatting error, the formatter may return non nil string as a proposal. + @return the display text for the event. + */ +- (NSString*)stringFromEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState error:(MXKEventFormatterError*)error; + +/** + Generate a displayable attributed string representating the event. + + @param event the event to format. + @param roomState the room state right before the event. + @param error the error code. In case of formatting error, the formatter may return non nil string as a proposal. + @return the attributed string for the event. + */ +- (NSAttributedString*)attributedStringFromEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState error:(MXKEventFormatterError*)error; + +/** + Generate a displayable attributed string representating a summary for the provided events. + + @param events the series of events to format. + @param roomState the room state right before the first event in the series. + @param error the error code. In case of formatting error, the formatter may return non nil string as a proposal. + @return the attributed string. + */ +- (NSAttributedString*)attributedStringFromEvents:(NSArray*)events withRoomState:(MXRoomState*)roomState error:(MXKEventFormatterError*)error; + +/** + Render a random string into an attributed string with the font and the text color + that correspond to the passed event. + + @param string the string to render. + @param event the event associated to the string. + @return an attributed string. + */ +- (NSAttributedString*)renderString:(NSString*)string forEvent:(MXEvent*)event; + +/** + Render a random html string into an attributed string with the font and the text color + that correspond to the passed event. + + @param htmlString the HTLM string to render. + @param event the event associated to the string. + @param roomState the room state right before the event. + @return an attributed string. + */ +- (NSAttributedString*)renderHTMLString:(NSString*)htmlString forEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState; + +/** + Same as [self renderString:forEvent:] but add a prefix. + The prefix will be rendered with 'prefixTextFont' and 'prefixTextColor'. + + @param string the string to render. + @param prefix the prefix to add. + @param event the event associated to the string. + @return an attributed string. + */ +- (NSAttributedString*)renderString:(NSString*)string withPrefix:(NSString*)prefix forEvent:(MXEvent*)event; + +#pragma mark - Conversion tools + +/** + Convert a Markdown string to HTML. + + @param markdownString the string to convert. + @return an HTML formatted string. + */ +- (NSString*)htmlStringFromMarkdownString:(NSString*)markdownString; + +#pragma mark - Timestamp formatting + +/** + Generate the date in string format corresponding to the date. + + @param date The date. + @param time The flag used to know if the returned string must include time information or not. + @return the string representation of the date. + */ +- (NSString*)dateStringFromDate:(NSDate *)date withTime:(BOOL)time; + +/** + Generate the date in string format corresponding to the timestamp. + The returned string is localised according to the current device settings. + + @param timestamp The timestamp in milliseconds since Epoch. + @param time The flag used to know if the returned string must include time information or not. + @return the string representation of the date. + */ +- (NSString*)dateStringFromTimestamp:(uint64_t)timestamp withTime:(BOOL)time; + +/** + Generate the date in string format corresponding to the event. + The returned string is localised according to the current device settings. + + @param event The event to format. + @param time The flag used to know if the returned string must include time information or not. + @return the string representation of the event date. + */ +- (NSString*)dateStringFromEvent:(MXEvent*)event withTime:(BOOL)time; + +/** + Generate the time string of the provided date by considered the current system time formatting. + + @param date The date. + @return the string representation of the time component of the date. + */ +- (NSString*)timeStringFromDate:(NSDate *)date; + + +# pragma mark - Customisation +/** + The list of allowed HTML tags in rendered attributed strings. + */ +@property (nonatomic) NSArray *allowedHTMLTags; + +/** + A block to run on HTML `img` tags when calling `renderHTMLString:forEvent:withRoomState:`. + + This block provides the original URL for the image and can be used to download the image locally + and return a local file URL for the image to attach to the rendered attributed string. + */ +@property (nonatomic, copy) NSURL* (^htmlImageHandler)(NSString *sourceURL, CGFloat width, CGFloat height); + +/** + The style sheet used by the 'renderHTMLString' method. +*/ +@property (nonatomic) NSString *defaultCSS; + +/** + Default color used to display text content of event. + Default is [UIColor blackColor]. + */ +@property (nonatomic) UIColor *defaultTextColor; + +/** + Default color used to display text content of event when it is displayed as subtitle (related to 'isForSubtitle' property). + Default is [UIColor blackColor]. + */ +@property (nonatomic) UIColor *subTitleTextColor; + +/** + Color applied on the event description prefix used to display for example the message sender name. + Default is [UIColor blackColor]. + */ +@property (nonatomic) UIColor *prefixTextColor; + +/** + Color used when the event must be bing to the end user. This happens when the event + matches the user's push rules. + Default is [UIColor blueColor]. + */ +@property (nonatomic) UIColor *bingTextColor; + +/** + Color used to display text content of an event being encrypted. + Default is [UIColor lightGrayColor]. + */ +@property (nonatomic) UIColor *encryptingTextColor; + +/** + Color used to display text content of an event being sent. + Default is [UIColor lightGrayColor]. + */ +@property (nonatomic) UIColor *sendingTextColor; + +/** + Color used to display error text. + Default is red. + */ +@property (nonatomic) UIColor *errorTextColor; + +/** + Color used to display the side border of HTML blockquotes. + Default is a grey. + */ +@property (nonatomic) UIColor *htmlBlockquoteBorderColor; + +/** + Default text font used to display text content of event. + Default is SFUIText-Regular 14. + */ +@property (nonatomic) UIFont *defaultTextFont; + +/** + Font applied on the event description prefix used to display for example the message sender name. + Default is SFUIText-Regular 14. + */ +@property (nonatomic) UIFont *prefixTextFont; + +/** + Text font used when the event must be bing to the end user. This happens when the event + matches the user's push rules. + Default is SFUIText-Regular 14. + */ +@property (nonatomic) UIFont *bingTextFont; + +/** + Text font used when the event is a state event. + Default is italic SFUIText-Regular 14. + */ +@property (nonatomic) UIFont *stateEventTextFont; + +/** + Text font used to display call notices (invite, answer, hangup). + Default is SFUIText-Regular 14. + */ +@property (nonatomic) UIFont *callNoticesTextFont; + +/** + Text font used to display encrypted messages. + Default is SFUIText-Regular 14. + */ +@property (nonatomic) UIFont *encryptedMessagesTextFont; + +/** + Text font used to display message containing a single emoji. + Default is nil (same font as self.emojiOnlyTextFont). + */ +@property (nonatomic) UIFont *singleEmojiTextFont; + +/** + Text font used to display message containing only emojis. + Default is nil (same font as self.defaultTextFont). + */ +@property (nonatomic) UIFont *emojiOnlyTextFont; + +@end diff --git a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m new file mode 100644 index 000000000..d28641dbf --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m @@ -0,0 +1,2215 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKEventFormatter.h" + +@import MatrixSDK; +@import DTCoreText; + +#import "MXEvent+MatrixKit.h" +#import "NSBundle+MatrixKit.h" +#import "MXKSwiftHeader.h" +#import "MXKTools.h" +#import "MXRoom+Sync.h" + +#import "MXKRoomNameStringLocalizer.h" + +static NSString *const kHTMLATagRegexPattern = @"([^<]*)"; + +@interface MXKEventFormatter () +{ + /** + The default CSS converted in DTCoreText object. + */ + DTCSSStylesheet *dtCSS; + + /** + Links detector in strings. + */ + NSDataDetector *linkDetector; +} +@end + +@implementation MXKEventFormatter + +- (instancetype)initWithMatrixSession:(MXSession *)matrixSession +{ + self = [super init]; + if (self) + { + mxSession = matrixSession; + + [self initDateTimeFormatters]; + + // Use the same list as matrix-react-sdk ( https://github.com/matrix-org/matrix-react-sdk/blob/24223ae2b69debb33fa22fcda5aeba6fa93c93eb/src/HtmlUtils.js#L25 ) + _allowedHTMLTags = @[ + @"font", // custom to matrix for IRC-style font coloring + @"del", // for markdown + @"body", // added internally by DTCoreText + @"mx-reply", + @"h1", @"h2", @"h3", @"h4", @"h5", @"h6", @"blockquote", @"p", @"a", @"ul", @"ol", + @"nl", @"li", @"b", @"i", @"u", @"strong", @"em", @"strike", @"code", @"hr", @"br", @"div", + @"table", @"thead", @"caption", @"tbody", @"tr", @"th", @"td", @"pre" + ]; + + self.defaultCSS = @" \ + pre,code { \ + background-color: #eeeeee; \ + display: inline; \ + font-family: monospace; \ + white-space: pre; \ + -coretext-fontname: Menlo-Regular; \ + font-size: small; \ + } \ + h1,h2 { \ + font-size: 1.2em; \ + }"; // match the size of h1/h2 to h3 to stop people shouting. + + // Set default colors + _defaultTextColor = [UIColor blackColor]; + _subTitleTextColor = [UIColor blackColor]; + _prefixTextColor = [UIColor blackColor]; + _bingTextColor = [UIColor blueColor]; + _encryptingTextColor = [UIColor lightGrayColor]; + _sendingTextColor = [UIColor lightGrayColor]; + _errorTextColor = [UIColor redColor]; + _htmlBlockquoteBorderColor = [MXKTools colorWithRGBValue:0xDDDDDD]; + + _defaultTextFont = [UIFont systemFontOfSize:14]; + _prefixTextFont = [UIFont systemFontOfSize:14]; + _bingTextFont = [UIFont systemFontOfSize:14]; + _stateEventTextFont = [UIFont italicSystemFontOfSize:14]; + _callNoticesTextFont = [UIFont italicSystemFontOfSize:14]; + _encryptedMessagesTextFont = [UIFont italicSystemFontOfSize:14]; + + _eventTypesFilterForMessages = nil; + + // Consider the shared app settings by default + _settings = [MXKAppSettings standardAppSettings]; + + defaultRoomSummaryUpdater = [MXRoomSummaryUpdater roomSummaryUpdaterForSession:matrixSession]; + defaultRoomSummaryUpdater.lastMessageEventTypesAllowList = MXKAppSettings.standardAppSettings.lastMessageEventTypesAllowList; + defaultRoomSummaryUpdater.ignoreRedactedEvent = !_settings.showRedactionsInRoomHistory; + defaultRoomSummaryUpdater.roomNameStringLocalizer = [MXKRoomNameStringLocalizer new]; + + linkDetector = [NSDataDetector dataDetectorWithTypes:NSTextCheckingTypeLink error:nil]; + + _markdownToHTMLRenderer = [MarkdownToHTMLRendererHardBreaks new]; + } + return self; +} + +- (void)initDateTimeFormatters +{ + // Prepare internal date formatter + dateFormatter = [[NSDateFormatter alloc] init]; + [dateFormatter setLocale:[[NSLocale alloc] initWithLocaleIdentifier:[[[NSBundle mainBundle] preferredLocalizations] objectAtIndex:0]]]; + [dateFormatter setFormatterBehavior:NSDateFormatterBehavior10_4]; + // Set default date format + [dateFormatter setDateFormat:@"MMM dd"]; + + // Create a time formatter to get time string by considered the current system time formatting. + timeFormatter = [[NSDateFormatter alloc] init]; + [timeFormatter setDateStyle:NSDateFormatterNoStyle]; + [timeFormatter setTimeStyle:NSDateFormatterShortStyle]; +} + +#pragma mark - Event formatter settings + +// Checks whether the event is related to an attachment and if it is supported +- (BOOL)isSupportedAttachment:(MXEvent*)event +{ + BOOL isSupportedAttachment = NO; + + if (event.eventType == MXEventTypeRoomMessage) + { + NSString *msgtype; + MXJSONModelSetString(msgtype, event.content[@"msgtype"]); + + NSString *urlField; + NSDictionary *fileField; + MXJSONModelSetString(urlField, event.content[@"url"]); + MXJSONModelSetDictionary(fileField, event.content[@"file"]); + + BOOL hasUrl = urlField.length; + BOOL hasFile = NO; + + if (fileField) + { + NSString *fileUrlField; + MXJSONModelSetString(fileUrlField, fileField[@"url"]); + NSString *fileIvField; + MXJSONModelSetString(fileIvField, fileField[@"iv"]); + NSDictionary *fileHashesField; + MXJSONModelSetDictionary(fileHashesField, fileField[@"hashes"]); + NSDictionary *fileKeyField; + MXJSONModelSetDictionary(fileKeyField, fileField[@"key"]); + + hasFile = fileUrlField.length && fileIvField.length && fileHashesField && fileKeyField; + } + + if ([msgtype isEqualToString:kMXMessageTypeImage]) + { + isSupportedAttachment = hasUrl || hasFile; + } + else if ([msgtype isEqualToString:kMXMessageTypeAudio]) + { + isSupportedAttachment = hasUrl || hasFile; + } + else if ([msgtype isEqualToString:kMXMessageTypeVideo]) + { + isSupportedAttachment = hasUrl || hasFile; + } + else if ([msgtype isEqualToString:kMXMessageTypeLocation]) + { + // Not supported yet + } + else if ([msgtype isEqualToString:kMXMessageTypeFile]) + { + isSupportedAttachment = hasUrl || hasFile; + } + } + else if (event.eventType == MXEventTypeSticker) + { + NSString *urlField; + NSDictionary *fileField; + MXJSONModelSetString(urlField, event.content[@"url"]); + MXJSONModelSetDictionary(fileField, event.content[@"file"]); + + BOOL hasUrl = urlField.length; + BOOL hasFile = NO; + + // @TODO: Check whether the encrypted sticker uses the same `file dict than other media + if (fileField) + { + NSString *fileUrlField; + MXJSONModelSetString(fileUrlField, fileField[@"url"]); + NSString *fileIvField; + MXJSONModelSetString(fileIvField, fileField[@"iv"]); + NSDictionary *fileHashesField; + MXJSONModelSetDictionary(fileHashesField, fileField[@"hashes"]); + NSDictionary *fileKeyField; + MXJSONModelSetDictionary(fileKeyField, fileField[@"key"]); + + hasFile = fileUrlField.length && fileIvField.length && fileHashesField && fileKeyField; + } + + isSupportedAttachment = hasUrl || hasFile; + } + return isSupportedAttachment; +} + + +#pragma mark event sender/target info + +- (NSString*)senderDisplayNameForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState +{ + // Check whether the sender name is updated by the current event. This happens in case of a + // newly joined member. Otherwise, fall back to the current display name defined in the provided + // room state (note: this room state is supposed to not take the new event into account). + return [self userDisplayNameFromContentInEvent:event withMembershipFilter:@"join"] ?: [roomState.members memberName:event.sender]; +} + +- (NSString*)targetDisplayNameForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState +{ + if (![event.type isEqualToString:kMXEventTypeStringRoomMember]) + { + return nil; // Non-membership events don't have a target + } + return [self userDisplayNameFromContentInEvent:event withMembershipFilter:nil] ?: [roomState.members memberName:event.stateKey]; +} + +- (NSString*)userDisplayNameFromContentInEvent:(MXEvent*)event withMembershipFilter:(NSString *)filter +{ + NSString* membership; + MXJSONModelSetString(membership, event.content[@"membership"]); + NSString* displayname; + MXJSONModelSetString(displayname, event.content[@"displayname"]); + + if (membership && (!filter || [membership isEqualToString:filter]) && [displayname length]) + { + return displayname; + } + + return nil; +} + +- (NSString*)senderAvatarUrlForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState +{ + // Check whether the avatar URL is updated by the current event. This happens in case of a + // newly joined member. Otherwise, fall back to the avatar URL defined in the provided room + // state (note: this room state is supposed to not take the new event into account). + NSString *avatarUrl = [self userAvatarUrlFromContentInEvent:event withMembershipFilter:@"join"] ?: [roomState.members memberWithUserId:event.sender].avatarUrl; + + // Handle here the case where no avatar is defined + return avatarUrl ?: [self fallbackAvatarUrlForUserId:event.sender]; +} + +- (NSString*)targetAvatarUrlForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState +{ + if (![event.type isEqualToString:kMXEventTypeStringRoomMember]) + { + return nil; // Non-membership events don't have a target + } + NSString *avatarUrl = [self userAvatarUrlFromContentInEvent:event withMembershipFilter:nil] ?: [roomState.members memberWithUserId:event.stateKey].avatarUrl; + return avatarUrl ?: [self fallbackAvatarUrlForUserId:event.stateKey]; +} + +- (NSString*)userAvatarUrlFromContentInEvent:(MXEvent*)event withMembershipFilter:(NSString *)filter +{ + NSString* membership; + MXJSONModelSetString(membership, event.content[@"membership"]); + NSString* avatarUrl; + MXJSONModelSetString(avatarUrl, event.content[@"avatar_url"]); + + if (membership && (!filter || [membership isEqualToString:filter]) && [avatarUrl length]) + { + // We ignore non mxc avatar url + if ([avatarUrl hasPrefix:kMXContentUriScheme]) + { + return avatarUrl; + } + } + + return nil; +} + +- (NSString*)fallbackAvatarUrlForUserId:(NSString*)userId { + if ([MXSDKOptions sharedInstance].disableIdenticonUseForUserAvatar) + { + return nil; + } + return [mxSession.mediaManager urlOfIdenticon:userId]; +} + + +#pragma mark - Events to strings conversion methods +- (NSString*)stringFromEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState error:(MXKEventFormatterError*)error +{ + NSString *stringFromEvent; + NSAttributedString *attributedStringFromEvent = [self attributedStringFromEvent:event withRoomState:roomState error:error]; + if (*error == MXKEventFormatterErrorNone) + { + stringFromEvent = attributedStringFromEvent.string; + } + + return stringFromEvent; +} + +- (NSAttributedString *)attributedStringFromEvent:(MXEvent *)event withRoomState:(MXRoomState *)roomState error:(MXKEventFormatterError *)error +{ + // Check we can output the error + NSParameterAssert(error); + + *error = MXKEventFormatterErrorNone; + + // Filter the events according to their type. + if (_eventTypesFilterForMessages && ([_eventTypesFilterForMessages indexOfObject:event.type] == NSNotFound)) + { + // Ignore this event + return nil; + } + + BOOL isEventSenderMyUser = [event.sender isEqualToString:mxSession.myUserId]; + + // Check first whether the event has been redacted + NSString *redactedInfo = nil; + BOOL isRedacted = (event.redactedBecause != nil); + if (isRedacted) + { + // Check whether redacted information is required + if (_settings.showRedactionsInRoomHistory) + { + MXLogDebug(@"[MXKEventFormatter] Redacted event %@ (%@)", event.description, event.redactedBecause); + + NSString *redactorId = event.redactedBecause[@"sender"]; + NSString *redactedBy = @""; + // Consider live room state to resolve redactor name if no roomState is provided + MXRoomState *aRoomState = roomState ? roomState : [mxSession roomWithRoomId:event.roomId].dangerousSyncState; + redactedBy = [aRoomState.members memberName:redactorId]; + + NSString *redactedReason = (event.redactedBecause[@"content"])[@"reason"]; + if (redactedReason.length) + { + if ([redactorId isEqualToString:mxSession.myUserId]) + { + redactedBy = [NSString stringWithFormat:@"%@%@", [MatrixKitL10n noticeEventRedactedByYou], [MatrixKitL10n noticeEventRedactedReason:redactedReason]]; + } + else if (redactedBy.length) + { + redactedBy = [NSString stringWithFormat:@"%@%@", [MatrixKitL10n noticeEventRedactedBy:redactedBy], [MatrixKitL10n noticeEventRedactedReason:redactedReason]]; + } + else + { + redactedBy = [MatrixKitL10n noticeEventRedactedReason:redactedReason]; + } + } + else if ([redactorId isEqualToString:mxSession.myUserId]) + { + redactedBy = [MatrixKitL10n noticeEventRedactedByYou]; + } + else if (redactedBy.length) + { + redactedBy = [MatrixKitL10n noticeEventRedactedBy:redactedBy]; + } + + redactedInfo = [MatrixKitL10n noticeEventRedacted:redactedBy]; + } + } + + // Prepare returned description + NSString *displayText = nil; + NSAttributedString *attributedDisplayText = nil; + BOOL isRoomDirect = [mxSession roomWithRoomId:event.roomId].isDirect; + + // Prepare the display name of the sender + NSString *senderDisplayName; + senderDisplayName = roomState ? [self senderDisplayNameForEvent:event withRoomState:roomState] : event.sender; + + switch (event.eventType) + { + case MXEventTypeRoomName: + { + NSString *roomName; + MXJSONModelSetString(roomName, event.content[@"name"]); + + if (isRedacted) + { + if (!redactedInfo) + { + // Here the event is ignored (no display) + return nil; + } + roomName = redactedInfo; + } + + if (roomName.length) + { + if (isEventSenderMyUser) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomNameChangedByYouForDm:roomName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomNameChangedByYou:roomName]; + } + } + else + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomNameChangedForDm:senderDisplayName :roomName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomNameChanged:senderDisplayName :roomName]; + } + } + } + else + { + if (isEventSenderMyUser) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomNameRemovedByYouForDm]; + } + else + { + displayText = [MatrixKitL10n noticeRoomNameRemovedByYou]; + } + } + else + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomNameRemovedForDm:senderDisplayName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomNameRemoved:senderDisplayName]; + } + } + } + break; + } + case MXEventTypeRoomTopic: + { + NSString *roomTopic; + MXJSONModelSetString(roomTopic, event.content[@"topic"]); + + if (isRedacted) + { + if (!redactedInfo) + { + // Here the event is ignored (no display) + return nil; + } + roomTopic = redactedInfo; + } + + if (roomTopic.length) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeTopicChangedByYou:roomTopic]; + } + else + { + displayText = [MatrixKitL10n noticeTopicChanged:senderDisplayName :roomTopic]; + } + } + else + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRoomTopicRemovedByYou]; + } + else + { + displayText = [MatrixKitL10n noticeRoomTopicRemoved:senderDisplayName]; + } + } + + break; + } + case MXEventTypeRoomMember: + { + // Presently only change on membership, display name and avatar are supported + + // Check whether the sender has updated his profile + if (event.isUserProfileChange) + { + // Is redacted event? + if (isRedacted) + { + if (!redactedInfo) + { + // Here the event is ignored (no display) + return nil; + } + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeProfileChangeRedactedByYou:redactedInfo]; + } + else + { + displayText = [MatrixKitL10n noticeProfileChangeRedacted:senderDisplayName :redactedInfo]; + } + } + else + { + // Check whether the display name has been changed + NSString *displayname; + MXJSONModelSetString(displayname, event.content[@"displayname"]); + NSString *prevDisplayname; + MXJSONModelSetString(prevDisplayname, event.prevContent[@"displayname"]); + + if (!displayname.length) + { + displayname = nil; + } + if (!prevDisplayname.length) + { + prevDisplayname = nil; + } + if ((displayname || prevDisplayname) && ([displayname isEqualToString:prevDisplayname] == NO)) + { + if (!prevDisplayname) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeDisplayNameSetByYou:displayname]; + } + else + { + displayText = [MatrixKitL10n noticeDisplayNameSet:event.sender :displayname]; + } + } + else if (!displayname) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeDisplayNameRemovedByYou]; + } + else + { + displayText = [MatrixKitL10n noticeDisplayNameRemoved:event.sender]; + } + } + else + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeDisplayNameChangedFromByYou:prevDisplayname :displayname]; + } + else + { + displayText = [MatrixKitL10n noticeDisplayNameChangedFrom:event.sender :prevDisplayname :displayname]; + } + } + } + + // Check whether the avatar has been changed + NSString *avatar; + MXJSONModelSetString(avatar, event.content[@"avatar_url"]); + NSString *prevAvatar; + MXJSONModelSetString(prevAvatar, event.prevContent[@"avatar_url"]); + + if (!avatar.length) + { + avatar = nil; + } + if (!prevAvatar.length) + { + prevAvatar = nil; + } + if ((prevAvatar || avatar) && ([avatar isEqualToString:prevAvatar] == NO)) + { + if (displayText) + { + displayText = [NSString stringWithFormat:@"%@ %@", displayText, [MatrixKitL10n noticeAvatarChangedToo]]; + } + else + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeAvatarUrlChangedByYou]; + } + else + { + displayText = [MatrixKitL10n noticeAvatarUrlChanged:senderDisplayName]; + } + } + } + } + } + else + { + // Retrieve membership + NSString* membership; + MXJSONModelSetString(membership, event.content[@"membership"]); + + // Prepare targeted member display name + NSString *targetDisplayName = event.stateKey; + + // Retrieve content displayname + NSString *contentDisplayname; + MXJSONModelSetString(contentDisplayname, event.content[@"displayname"]); + NSString *prevContentDisplayname; + MXJSONModelSetString(prevContentDisplayname, event.prevContent[@"displayname"]); + + // Consider here a membership change + if ([membership isEqualToString:@"invite"]) + { + if (event.content[@"third_party_invite"]) + { + if ([event.stateKey isEqualToString:mxSession.myUserId]) + { + displayText = [MatrixKitL10n noticeRoomThirdPartyRegisteredInviteByYou:event.content[@"third_party_invite"][@"display_name"]]; + } + else + { + displayText = [MatrixKitL10n noticeRoomThirdPartyRegisteredInvite:targetDisplayName :event.content[@"third_party_invite"][@"display_name"]]; + } + } + else + { + if ([MXCallManager isConferenceUser:event.stateKey]) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeConferenceCallRequestByYou]; + } + else + { + displayText = [MatrixKitL10n noticeConferenceCallRequest:senderDisplayName]; + } + } + else + { + // The targeted member display name (if any) is available in content + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRoomInviteByYou:targetDisplayName]; + } + else if ([targetDisplayName isEqualToString:mxSession.myUserId]) + { + displayText = [MatrixKitL10n noticeRoomInviteYou:senderDisplayName]; + } + else + { + if (contentDisplayname.length) + { + targetDisplayName = contentDisplayname; + } + + displayText = [MatrixKitL10n noticeRoomInvite:senderDisplayName :targetDisplayName]; + } + } + } + } + else if ([membership isEqualToString:@"join"]) + { + if ([MXCallManager isConferenceUser:event.stateKey]) + { + displayText = [MatrixKitL10n noticeConferenceCallStarted]; + } + else + { + // The targeted member display name (if any) is available in content + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRoomJoinByYou]; + } + else + { + if (contentDisplayname.length) + { + targetDisplayName = contentDisplayname; + } + + displayText = [MatrixKitL10n noticeRoomJoin:targetDisplayName]; + } + } + } + else if ([membership isEqualToString:@"leave"]) + { + NSString *prevMembership = nil; + if (event.prevContent) + { + MXJSONModelSetString(prevMembership, event.prevContent[@"membership"]); + } + + // The targeted member display name (if any) is available in prevContent + if (prevContentDisplayname.length) + { + targetDisplayName = prevContentDisplayname; + } + + if ([event.sender isEqualToString:event.stateKey]) + { + if ([MXCallManager isConferenceUser:event.stateKey]) + { + displayText = [MatrixKitL10n noticeConferenceCallFinished]; + } + else + { + if (prevMembership && [prevMembership isEqualToString:@"invite"]) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRoomRejectByYou]; + } + else + { + displayText = [MatrixKitL10n noticeRoomReject:targetDisplayName]; + } + } + else + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRoomLeaveByYou]; + } + else + { + displayText = [MatrixKitL10n noticeRoomLeave:targetDisplayName]; + } + } + } + } + else if (prevMembership) + { + if ([prevMembership isEqualToString:@"invite"]) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRoomWithdrawByYou:targetDisplayName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomWithdraw:senderDisplayName :targetDisplayName]; + } + if (event.content[@"reason"]) + { + displayText = [displayText stringByAppendingString:[MatrixKitL10n noticeRoomReason:event.content[@"reason"]]]; + } + + } + else if ([prevMembership isEqualToString:@"join"]) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRoomKickByYou:targetDisplayName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomKick:senderDisplayName :targetDisplayName]; + } + + // add reason if exists + if (event.content[@"reason"]) + { + displayText = [displayText stringByAppendingString:[MatrixKitL10n noticeRoomReason:event.content[@"reason"]]]; + } + } + else if ([prevMembership isEqualToString:@"ban"]) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRoomUnbanByYou:targetDisplayName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomUnban:senderDisplayName :targetDisplayName]; + } + } + } + } + else if ([membership isEqualToString:@"ban"]) + { + // The targeted member display name (if any) is available in prevContent + if (prevContentDisplayname.length) + { + targetDisplayName = prevContentDisplayname; + } + + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRoomBanByYou:targetDisplayName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomBan:senderDisplayName :targetDisplayName]; + } + if (event.content[@"reason"]) + { + displayText = [displayText stringByAppendingString:[MatrixKitL10n noticeRoomReason:event.content[@"reason"]]]; + } + } + + // Append redacted info if any + if (redactedInfo) + { + displayText = [NSString stringWithFormat:@"%@ %@", displayText, redactedInfo]; + } + } + + if (!displayText) + { + *error = MXKEventFormatterErrorUnexpected; + } + break; + } + case MXEventTypeRoomCreate: + { + NSString *creatorId; + MXJSONModelSetString(creatorId, event.content[@"creator"]); + + if (creatorId) + { + if ([creatorId isEqualToString:mxSession.myUserId]) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomCreatedByYouForDm]; + } + else + { + displayText = [MatrixKitL10n noticeRoomCreatedByYou]; + } + } + else + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomCreatedForDm:(roomState ? [roomState.members memberName:creatorId] : creatorId)]; + } + else + { + displayText = [MatrixKitL10n noticeRoomCreated:(roomState ? [roomState.members memberName:creatorId] : creatorId)]; + } + } + // Append redacted info if any + if (redactedInfo) + { + displayText = [NSString stringWithFormat:@"%@ %@", displayText, redactedInfo]; + } + } + break; + } + case MXEventTypeRoomJoinRules: + { + NSString *joinRule; + MXJSONModelSetString(joinRule, event.content[@"join_rule"]); + + if (joinRule) + { + if ([event.sender isEqualToString:mxSession.myUserId]) + { + if ([joinRule isEqualToString:kMXRoomJoinRulePublic]) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomJoinRulePublicByYouForDm]; + } + else + { + displayText = [MatrixKitL10n noticeRoomJoinRulePublicByYou]; + } + } + else if ([joinRule isEqualToString:kMXRoomJoinRuleInvite]) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomJoinRuleInviteByYouForDm]; + } + else + { + displayText = [MatrixKitL10n noticeRoomJoinRuleInviteByYou]; + } + } + } + else + { + NSString *displayName = roomState ? [roomState.members memberName:event.sender] : event.sender; + if ([joinRule isEqualToString:kMXRoomJoinRulePublic]) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomJoinRulePublicForDm:displayName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomJoinRulePublic:displayName]; + } + } + else if ([joinRule isEqualToString:kMXRoomJoinRuleInvite]) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomJoinRuleInviteForDm:displayName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomJoinRuleInvite:displayName]; + } + } + } + + if (!displayText) + { + // use old string for non-handled cases: "knock" and "private" + displayText = [MatrixKitL10n noticeRoomJoinRule:joinRule]; + } + + // Append redacted info if any + if (redactedInfo) + { + displayText = [NSString stringWithFormat:@"%@ %@", displayText, redactedInfo]; + } + } + break; + } + case MXEventTypeRoomPowerLevels: + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomPowerLevelIntroForDm]; + } + else + { + displayText = [MatrixKitL10n noticeRoomPowerLevelIntro]; + } + NSDictionary *users; + MXJSONModelSetDictionary(users, event.content[@"users"]); + + for (NSString *key in users.allKeys) + { + displayText = [NSString stringWithFormat:@"%@\n\u2022 %@: %@", displayText, key, [users objectForKey:key]]; + } + if (event.content[@"users_default"]) + { + displayText = [NSString stringWithFormat:@"%@\n\u2022 %@: %@", displayText, [MatrixKitL10n default], event.content[@"users_default"]]; + } + + displayText = [NSString stringWithFormat:@"%@\n%@", displayText, [MatrixKitL10n noticeRoomPowerLevelActingRequirement]]; + if (event.content[@"ban"]) + { + displayText = [NSString stringWithFormat:@"%@\n\u2022 ban: %@", displayText, event.content[@"ban"]]; + } + if (event.content[@"kick"]) + { + displayText = [NSString stringWithFormat:@"%@\n\u2022 kick: %@", displayText, event.content[@"kick"]]; + } + if (event.content[@"redact"]) + { + displayText = [NSString stringWithFormat:@"%@\n\u2022 redact: %@", displayText, event.content[@"redact"]]; + } + if (event.content[@"invite"]) + { + displayText = [NSString stringWithFormat:@"%@\n\u2022 invite: %@", displayText, event.content[@"invite"]]; + } + + displayText = [NSString stringWithFormat:@"%@\n%@", displayText, [MatrixKitL10n noticeRoomPowerLevelEventRequirement]]; + + NSDictionary *events; + MXJSONModelSetDictionary(events, event.content[@"events"]); + for (NSString *key in events.allKeys) + { + displayText = [NSString stringWithFormat:@"%@\n\u2022 %@: %@", displayText, key, [events objectForKey:key]]; + } + if (event.content[@"events_default"]) + { + displayText = [NSString stringWithFormat:@"%@\n\u2022 %@: %@", displayText, @"events_default", event.content[@"events_default"]]; + } + if (event.content[@"state_default"]) + { + displayText = [NSString stringWithFormat:@"%@\n\u2022 %@: %@", displayText, @"state_default", event.content[@"state_default"]]; + } + + // Append redacted info if any + if (redactedInfo) + { + displayText = [NSString stringWithFormat:@"%@\n %@", displayText, redactedInfo]; + } + break; + } + case MXEventTypeRoomAliases: + { + NSArray *aliases; + MXJSONModelSetArray(aliases, event.content[@"aliases"]); + if (aliases) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomAliasesForDm:[aliases componentsJoinedByString:@", "]]; + } + else + { + displayText = [MatrixKitL10n noticeRoomAliases:[aliases componentsJoinedByString:@", "]]; + } + // Append redacted info if any + if (redactedInfo) + { + displayText = [NSString stringWithFormat:@"%@\n %@", displayText, redactedInfo]; + } + } + break; + } + case MXEventTypeRoomRelatedGroups: + { + NSArray *groups; + MXJSONModelSetArray(groups, event.content[@"groups"]); + if (groups) + { + displayText = [MatrixKitL10n noticeRoomRelatedGroups:[groups componentsJoinedByString:@", "]]; + // Append redacted info if any + if (redactedInfo) + { + displayText = [NSString stringWithFormat:@"%@\n %@", displayText, redactedInfo]; + } + } + break; + } + case MXEventTypeRoomEncrypted: + { + // Is redacted? + if (isRedacted) + { + if (!redactedInfo) + { + // Here the event is ignored (no display) + return nil; + } + displayText = redactedInfo; + } + else + { + // If the message still appears as encrypted, there was propably an error for decryption + // Show this error + if (event.decryptionError) + { + NSString *errorDescription; + + if ([event.decryptionError.domain isEqualToString:MXDecryptingErrorDomain] + && [MXKAppSettings standardAppSettings].hideUndecryptableEvents) + { + // Hide this event, it cannot be decrypted + displayText = nil; + } + else if ([event.decryptionError.domain isEqualToString:MXDecryptingErrorDomain] + && event.decryptionError.code == MXDecryptingErrorUnknownInboundSessionIdCode) + { + // Make the unknown inbound session id error description more user friendly + errorDescription = [MatrixKitL10n noticeCryptoErrorUnknownInboundSessionId]; + } + else if ([event.decryptionError.domain isEqualToString:MXDecryptingErrorDomain] + && event.decryptionError.code == MXDecryptingErrorDuplicateMessageIndexCode) + { + // Hide duplicate message warnings + MXLogDebug(@"[MXKEventFormatter] Warning: Duplicate message with error description %@", event.decryptionError); + displayText = nil; + } + else + { + errorDescription = event.decryptionError.localizedDescription; + } + + if (errorDescription) + { + displayText = [MatrixKitL10n noticeCryptoUnableToDecrypt:errorDescription]; + } + } + else + { + displayText = [MatrixKitL10n noticeEncryptedMessage]; + } + } + + break; + } + case MXEventTypeRoomEncryption: + { + NSString *algorithm; + MXJSONModelSetString(algorithm, event.content[@"algorithm"]); + + if (isRedacted) + { + if (!redactedInfo) + { + // Here the event is ignored (no display) + return nil; + } + algorithm = redactedInfo; + } + + if ([algorithm isEqualToString:kMXCryptoMegolmAlgorithm]) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeEncryptionEnabledOkByYou]; + } + else + { + displayText = [MatrixKitL10n noticeEncryptionEnabledOk:senderDisplayName]; + } + } + else + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeEncryptionEnabledUnknownAlgorithmByYou:algorithm]; + } + else + { + displayText = [MatrixKitL10n noticeEncryptionEnabledUnknownAlgorithm:senderDisplayName :algorithm]; + } + } + + break; + } + case MXEventTypeRoomHistoryVisibility: + { + if (isRedacted) + { + displayText = redactedInfo; + } + else + { + MXRoomHistoryVisibility historyVisibility; + MXJSONModelSetString(historyVisibility, event.content[@"history_visibility"]); + + if (historyVisibility) + { + if ([historyVisibility isEqualToString:kMXRoomHistoryVisibilityWorldReadable]) + { + if (!isRoomDirect) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToAnyoneByYou]; + } + else + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToAnyone:senderDisplayName]; + } + } + } + else if ([historyVisibility isEqualToString:kMXRoomHistoryVisibilityShared]) + { + if (isEventSenderMyUser) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersByYouForDm]; + } + else + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersByYou]; + } + } + else + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersForDm:senderDisplayName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembers:senderDisplayName]; + } + } + } + else if ([historyVisibility isEqualToString:kMXRoomHistoryVisibilityInvited]) + { + if (isEventSenderMyUser) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersFromInvitedPointByYouForDm]; + } + else + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersFromInvitedPointByYou]; + } + } + else + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersFromInvitedPointForDm:senderDisplayName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersFromInvitedPoint:senderDisplayName]; + } + } + } + else if ([historyVisibility isEqualToString:kMXRoomHistoryVisibilityJoined]) + { + if (isEventSenderMyUser) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersFromJoinedPointByYouForDm]; + } + else + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersFromJoinedPointByYou]; + } + } + else + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersFromJoinedPointForDm:senderDisplayName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersFromJoinedPoint:senderDisplayName]; + } + } + } + } + } + break; + } + case MXEventTypeRoomMessage: + { + // Is redacted? + if (isRedacted) + { + if (!redactedInfo) + { + // Here the event is ignored (no display) + return nil; + } + displayText = redactedInfo; + } + else if (event.isEditEvent) + { + return nil; + } + else + { + NSString *msgtype; + MXJSONModelSetString(msgtype, event.content[@"msgtype"]); + + NSString *body; + BOOL isHTML = NO; + NSString *eventThreadIdentifier = event.threadIdentifier; + + // Use the HTML formatted string if provided + if ([event.content[@"format"] isEqualToString:kMXRoomMessageFormatHTML]) + { + isHTML =YES; + MXJSONModelSetString(body, event.content[@"formatted_body"]); + } + else if (eventThreadIdentifier) + { + isHTML = YES; + MXJSONModelSetString(body, event.content[@"body"]); + MXEvent *threadRootEvent = [mxSession.store eventWithEventId:eventThreadIdentifier + inRoom:event.roomId]; + + NSString *threadRootEventContent; + MXJSONModelSetString(threadRootEventContent, threadRootEvent.content[@"body"]); + body = [NSString stringWithFormat:@"
In reply to %@
%@
%@", + [MXTools permalinkToEvent:eventThreadIdentifier inRoom:event.roomId], + [MXTools permalinkToUserWithUserId:threadRootEvent.sender], + threadRootEvent.sender, + threadRootEventContent, + body]; + + } + else + { + MXJSONModelSetString(body, event.content[@"body"]); + } + + if (body) + { + if ([msgtype isEqualToString:kMXMessageTypeImage]) + { + body = body? body : [MatrixKitL10n noticeImageAttachment]; + // Check attachment validity + if (![self isSupportedAttachment:event]) + { + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); + body = [MatrixKitL10n noticeInvalidAttachment]; + *error = MXKEventFormatterErrorUnsupported; + } + } + else if ([msgtype isEqualToString:kMXMessageTypeAudio]) + { + body = body? body : [MatrixKitL10n noticeAudioAttachment]; + if (![self isSupportedAttachment:event]) + { + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); + if (_isForSubtitle || !_settings.showUnsupportedEventsInRoomHistory) + { + body = [MatrixKitL10n noticeInvalidAttachment]; + } + else + { + body = [MatrixKitL10n noticeUnsupportedAttachment:event.description]; + } + *error = MXKEventFormatterErrorUnsupported; + } + } + else if ([msgtype isEqualToString:kMXMessageTypeVideo]) + { + body = body? body : [MatrixKitL10n noticeVideoAttachment]; + if (![self isSupportedAttachment:event]) + { + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); + if (_isForSubtitle || !_settings.showUnsupportedEventsInRoomHistory) + { + body = [MatrixKitL10n noticeInvalidAttachment]; + } + else + { + body = [MatrixKitL10n noticeUnsupportedAttachment:event.description]; + } + *error = MXKEventFormatterErrorUnsupported; + } + } + else if ([msgtype isEqualToString:kMXMessageTypeLocation]) + { + body = body? body : [MatrixKitL10n noticeLocationAttachment]; + if (![self isSupportedAttachment:event]) + { + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); + if (_isForSubtitle || !_settings.showUnsupportedEventsInRoomHistory) + { + body = [MatrixKitL10n noticeInvalidAttachment]; + } + else + { + body = [MatrixKitL10n noticeUnsupportedAttachment:event.description]; + } + *error = MXKEventFormatterErrorUnsupported; + } + } + else if ([msgtype isEqualToString:kMXMessageTypeFile]) + { + body = body? body : [MatrixKitL10n noticeFileAttachment]; + // Check attachment validity + if (![self isSupportedAttachment:event]) + { + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); + body = [MatrixKitL10n noticeInvalidAttachment]; + *error = MXKEventFormatterErrorUnsupported; + } + } + + if (isHTML) + { + // Build the attributed string from the HTML string + attributedDisplayText = [self renderHTMLString:body forEvent:event withRoomState:roomState]; + } + else + { + // Build the attributed string with the right font and color for the event + attributedDisplayText = [self renderString:body forEvent:event]; + } + + // Build the full emote string after the body message formatting + if ([msgtype isEqualToString:kMXMessageTypeEmote]) + { + __block NSUInteger insertAt = 0; + + // For replies, look for the end of the parent message + // This helps us insert the emote prefix in the right place + NSDictionary *relatesTo; + MXJSONModelSetDictionary(relatesTo, event.content[@"m.relates_to"]); + if ([relatesTo[@"m.in_reply_to"] isKindOfClass:NSDictionary.class] || event.isInThread) + { + [attributedDisplayText enumerateAttribute:kMXKToolsBlockquoteMarkAttribute + inRange:NSMakeRange(0, attributedDisplayText.length) + options:(NSAttributedStringEnumerationReverse) + usingBlock:^(id value, NSRange range, BOOL *stop) { + insertAt = range.location; + *stop = YES; + }]; + } + + // Always use default font and color for the emote prefix + NSString *emotePrefix = [NSString stringWithFormat:@"* %@ ", senderDisplayName]; + NSAttributedString *attributedEmotePrefix = + [[NSAttributedString alloc] initWithString:emotePrefix + attributes:@{ + NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: _defaultTextFont + }]; + + // Then, insert the emote prefix at the start of the message + // (location varies depending on whether it was a reply) + NSMutableAttributedString *newAttributedDisplayText = + [[NSMutableAttributedString alloc] initWithAttributedString:attributedDisplayText]; + [newAttributedDisplayText insertAttributedString:attributedEmotePrefix + atIndex:insertAt]; + attributedDisplayText = newAttributedDisplayText; + } + } + } + break; + } + case MXEventTypeRoomMessageFeedback: + { + NSString *type; + MXJSONModelSetString(type, event.content[@"type"]); + NSString *eventId; + MXJSONModelSetString(eventId, event.content[@"target_event_id"]); + + if (type && eventId) + { + displayText = [MatrixKitL10n noticeFeedback:eventId :type]; + // Append redacted info if any + if (redactedInfo) + { + displayText = [NSString stringWithFormat:@"%@ %@", displayText, redactedInfo]; + } + } + break; + } + case MXEventTypeRoomRedaction: + { + NSString *eventId = event.redacts; + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRedactionByYou:eventId]; + } + else + { + displayText = [MatrixKitL10n noticeRedaction:senderDisplayName :eventId]; + } + break; + } + case MXEventTypeRoomThirdPartyInvite: + { + NSString *displayname; + MXJSONModelSetString(displayname, event.content[@"display_name"]); + if (displayname) + { + if (isEventSenderMyUser) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomThirdPartyInviteByYouForDm:displayname]; + } + else + { + displayText = [MatrixKitL10n noticeRoomThirdPartyInviteByYou:displayname]; + } + } + else + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomThirdPartyInviteForDm:senderDisplayName :displayname]; + } + else + { + displayText = [MatrixKitL10n noticeRoomThirdPartyInvite:senderDisplayName :displayname]; + } + } + } + else + { + // Consider the invite has been revoked + MXJSONModelSetString(displayname, event.prevContent[@"display_name"]); + if (isEventSenderMyUser) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomThirdPartyRevokedInviteByYouForDm:displayname]; + } + else + { + displayText = [MatrixKitL10n noticeRoomThirdPartyRevokedInviteByYou:displayname]; + } + } + else + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomThirdPartyRevokedInviteForDm:senderDisplayName :displayname]; + } + else + { + displayText = [MatrixKitL10n noticeRoomThirdPartyRevokedInvite:senderDisplayName :displayname]; + } + } + } + break; + } + case MXEventTypeCallInvite: + { + MXCallInviteEventContent *callInviteEventContent = [MXCallInviteEventContent modelFromJSON:event.content]; + + if (callInviteEventContent.isVideoCall) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticePlacedVideoCallByYou]; + } + else + { + displayText = [MatrixKitL10n noticePlacedVideoCall:senderDisplayName]; + } + } + else + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticePlacedVoiceCallByYou]; + } + else + { + displayText = [MatrixKitL10n noticePlacedVoiceCall:senderDisplayName]; + } + } + break; + } + case MXEventTypeCallAnswer: + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeAnsweredVideoCallByYou]; + } + else + { + displayText = [MatrixKitL10n noticeAnsweredVideoCall:senderDisplayName]; + } + break; + } + case MXEventTypeCallHangup: + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeEndedVideoCallByYou]; + } + else + { + displayText = [MatrixKitL10n noticeEndedVideoCall:senderDisplayName]; + } + break; + } + case MXEventTypeCallReject: + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeDeclinedVideoCallByYou]; + } + else + { + displayText = [MatrixKitL10n noticeDeclinedVideoCall:senderDisplayName]; + } + break; + } + case MXEventTypeSticker: + { + // Is redacted? + if (isRedacted) + { + if (!redactedInfo) + { + // Here the event is ignored (no display) + return nil; + } + displayText = redactedInfo; + } + else + { + NSString *body; + MXJSONModelSetString(body, event.content[@"body"]); + + // Check sticker validity + if (![self isSupportedAttachment:event]) + { + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported sticker %@", event.description); + body = [MatrixKitL10n noticeInvalidAttachment]; + *error = MXKEventFormatterErrorUnsupported; + } + + displayText = body? body : [MatrixKitL10n noticeSticker]; + } + break; + } + + default: + *error = MXKEventFormatterErrorUnknownEventType; + break; + } + + if (!attributedDisplayText && displayText) + { + // Build the attributed string with the right font and color for the event + attributedDisplayText = [self renderString:displayText forEvent:event]; + } + + if (!attributedDisplayText) + { + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported event %@)", event.description); + if (_settings.showUnsupportedEventsInRoomHistory) + { + if (MXKEventFormatterErrorNone == *error) + { + *error = MXKEventFormatterErrorUnsupported; + } + + NSString *shortDescription = nil; + + switch (*error) + { + case MXKEventFormatterErrorUnsupported: + shortDescription = [MatrixKitL10n noticeErrorUnsupportedEvent]; + break; + case MXKEventFormatterErrorUnexpected: + shortDescription = [MatrixKitL10n noticeErrorUnexpectedEvent]; + break; + case MXKEventFormatterErrorUnknownEventType: + shortDescription = [MatrixKitL10n noticeErrorUnknownEventType]; + break; + + default: + break; + } + + if (!_isForSubtitle) + { + // Return event content as unsupported event + displayText = [NSString stringWithFormat:@"%@: %@", shortDescription, event.description]; + } + else + { + // Return a short error description + displayText = shortDescription; + } + + // Build the attributed string with the right font for the event + attributedDisplayText = [self renderString:displayText forEvent:event]; + } + } + + return attributedDisplayText; +} + +- (NSAttributedString*)attributedStringFromEvents:(NSArray*)events withRoomState:(MXRoomState*)roomState error:(MXKEventFormatterError*)error +{ + // TODO: Do a full summary + return nil; +} + +- (NSAttributedString*)renderString:(NSString*)string forEvent:(MXEvent*)event +{ + // Sanity check + if (!string) + { + return nil; + } + + NSMutableAttributedString *str = [[NSMutableAttributedString alloc] initWithString:string]; + + NSRange wholeString = NSMakeRange(0, str.length); + + // Apply color and font corresponding to the event state + [str addAttribute:NSForegroundColorAttributeName value:[self textColorForEvent:event] range:wholeString]; + [str addAttribute:NSFontAttributeName value:[self fontForEvent:event] range:wholeString]; + + // If enabled, make links clickable + if (!([[_settings httpLinkScheme] isEqualToString: @"http"] && + [[_settings httpsLinkScheme] isEqualToString: @"https"])) + { + NSArray *matches = [linkDetector matchesInString:[str string] options:0 range:wholeString]; + for (NSTextCheckingResult *match in matches) + { + NSRange matchRange = [match range]; + NSURL *matchUrl = [match URL]; + NSURLComponents *url = [[NSURLComponents new] initWithURL:matchUrl resolvingAgainstBaseURL:NO]; + + if (url) + { + if ([url.scheme isEqualToString: @"http"]) + { + url.scheme = [_settings httpLinkScheme]; + } + else if ([url.scheme isEqualToString: @"https"]) + { + url.scheme = [_settings httpsLinkScheme]; + } + + if (url.URL) + { + [str addAttribute:NSLinkAttributeName value:url.URL range:matchRange]; + } + } + } + } + + // Apply additional treatments + return [self postRenderAttributedString:str]; +} + +- (NSAttributedString*)renderHTMLString:(NSString*)htmlString forEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState +{ + NSString *html = htmlString; + + // Special treatment for "In reply to" message + if (event.isReplyEvent || event.isInThread) + { + html = [self renderReplyTo:html withRoomState:roomState]; + } + + // Apply the css style that corresponds to the event state + UIFont *font = [self fontForEvent:event]; + + // Do some sanitisation before finalizing the string + MXWeakify(self); + DTHTMLAttributedStringBuilderWillFlushCallback sanitizeCallback = ^(DTHTMLElement *element) { + MXStrongifyAndReturnIfNil(self); + [element sanitizeWith:self.allowedHTMLTags bodyFont:font imageHandler:self.htmlImageHandler]; + }; + + NSDictionary *options = @{ + DTUseiOS6Attributes: @(YES), // Enable it to be able to display the attributed string in a UITextView + DTDefaultFontFamily: font.familyName, + DTDefaultFontName: font.fontName, + DTDefaultFontSize: @(font.pointSize), + DTDefaultTextColor: [self textColorForEvent:event], + DTDefaultLinkDecoration: @(NO), + DTDefaultStyleSheet: dtCSS, + DTWillFlushBlockCallBack: sanitizeCallback + }; + + // Do not use the default HTML renderer of NSAttributedString because this method + // runs on the UI thread which we want to avoid because renderHTMLString is called + // most of the time from a background thread. + // Use DTCoreText HTML renderer instead. + // Using DTCoreText, which renders static string, helps to avoid code injection attacks + // that could happen with the default HTML renderer of NSAttributedString which is a + // webview. + NSAttributedString *str = [[NSAttributedString alloc] initWithHTMLData:[html dataUsingEncoding:NSUTF8StringEncoding] options:options documentAttributes:NULL]; + + // Apply additional treatments + str = [self postRenderAttributedString:str]; + + // Finalize the attributed string by removing DTCoreText artifacts (Trim trailing newlines). + str = [MXKTools removeDTCoreTextArtifacts:str]; + + // Finalize HTML blockquote blocks marking + str = [MXKTools removeMarkedBlockquotesArtifacts:str]; + + return str; +} + +/** + Special treatment for "In reply to" message. + + According to https://docs.google.com/document/d/1BPd4lBrooZrWe_3s_lHw_e-Dydvc7bXbm02_sV2k6Sc/edit. + + @param htmlString an html string containing a reply-to message. + @param roomState the room state right before the event. + @return a displayable internationalised html string. + */ +- (NSString*)renderReplyTo:(NSString*)htmlString withRoomState:(MXRoomState*)roomState +{ + NSString *html = htmlString; + + static NSRegularExpression *htmlATagRegex; + + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + htmlATagRegex = [NSRegularExpression regularExpressionWithPattern:kHTMLATagRegexPattern options:NSRegularExpressionCaseInsensitive error:nil]; + }); + + __block NSUInteger hrefCount = 0; + + __block NSRange inReplyToLinkRange = NSMakeRange(NSNotFound, 0); + __block NSRange inReplyToTextRange = NSMakeRange(NSNotFound, 0); + __block NSRange userIdRange = NSMakeRange(NSNotFound, 0); + + [htmlATagRegex enumerateMatchesInString:html + options:0 + range:NSMakeRange(0, html.length) + usingBlock:^(NSTextCheckingResult *match, NSMatchingFlags flags, BOOL *stop) { + + if (hrefCount > 1) + { + *stop = YES; + } + else if (hrefCount == 0 && match.numberOfRanges >= 2) + { + inReplyToLinkRange = [match rangeAtIndex:1]; + inReplyToTextRange = [match rangeAtIndex:2]; + } + else if (hrefCount == 1 && match.numberOfRanges >= 2) + { + userIdRange = [match rangeAtIndex:2]; + } + + hrefCount++; + }]; + + // Note: Take care to replace text starting with the end + + // Replace mxid + // By Display name + // To replace the user Matrix ID by his display name when available. + // This link is the second HTML node of the html string + + if (userIdRange.location != NSNotFound) + { + NSString *userId = [html substringWithRange:userIdRange]; + + NSString *senderDisplayName = [roomState.members memberName:userId]; + + if (senderDisplayName) + { + html = [html stringByReplacingCharactersInRange:userIdRange withString:senderDisplayName]; + } + } + + // Replace
In reply to + // By
['In reply to' from resources] + // To disable the link and to localize the "In reply to" string + // This link is the first HTML node of the html string + + if (inReplyToTextRange.location != NSNotFound) + { + html = [html stringByReplacingCharactersInRange:inReplyToTextRange withString:[MatrixKitL10n noticeInReplyTo]]; + } + + if (inReplyToLinkRange.location != NSNotFound) + { + html = [html stringByReplacingCharactersInRange:inReplyToLinkRange withString:@"#"]; + } + + return html; +} + +- (NSAttributedString*)postRenderAttributedString:(NSAttributedString*)attributedString +{ + if (!attributedString) + { + return nil; + } + + NSInteger enabledMatrixIdsBitMask= 0; + + // If enabled, make user id clickable + if (_treatMatrixUserIdAsLink) + { + enabledMatrixIdsBitMask |= MXKTOOLS_USER_IDENTIFIER_BITWISE; + } + + // If enabled, make room id clickable + if (_treatMatrixRoomIdAsLink) + { + enabledMatrixIdsBitMask |= MXKTOOLS_ROOM_IDENTIFIER_BITWISE; + } + + // If enabled, make room alias clickable + if (_treatMatrixRoomAliasAsLink) + { + enabledMatrixIdsBitMask |= MXKTOOLS_ROOM_ALIAS_BITWISE; + } + + // If enabled, make event id clickable + if (_treatMatrixEventIdAsLink) + { + enabledMatrixIdsBitMask |= MXKTOOLS_EVENT_IDENTIFIER_BITWISE; + } + + // If enabled, make group id clickable + if (_treatMatrixGroupIdAsLink) + { + enabledMatrixIdsBitMask |= MXKTOOLS_GROUP_IDENTIFIER_BITWISE; + } + + return [MXKTools createLinksInAttributedString:attributedString forEnabledMatrixIds:enabledMatrixIdsBitMask]; +} + +- (NSAttributedString *)renderString:(NSString *)string withPrefix:(NSString *)prefix forEvent:(MXEvent *)event +{ + NSMutableAttributedString *str; + + if (prefix) + { + str = [[NSMutableAttributedString alloc] initWithString:prefix]; + + // Apply the prefix font and color on the prefix + NSRange prefixRange = NSMakeRange(0, prefix.length); + [str addAttribute:NSForegroundColorAttributeName value:_prefixTextColor range:prefixRange]; + [str addAttribute:NSFontAttributeName value:_prefixTextFont range:prefixRange]; + + // And append the string rendered according to event state + [str appendAttributedString:[self renderString:string forEvent:event]]; + + return str; + } + else + { + // Use the legacy method + return [self renderString:string forEvent:event]; + } +} + +- (void)setDefaultCSS:(NSString*)defaultCSS +{ + // Make sure we mark HTML blockquote blocks for later computation + _defaultCSS = [NSString stringWithFormat:@"%@%@", [MXKTools cssToMarkBlockquotes], defaultCSS]; + + dtCSS = [[DTCSSStylesheet alloc] initWithStyleBlock:_defaultCSS]; +} + +#pragma mark - MXRoomSummaryUpdating +- (BOOL)session:(MXSession *)session updateRoomSummary:(MXRoomSummary *)summary withStateEvents:(NSArray *)stateEvents roomState:(MXRoomState *)roomState +{ + // We build strings containing the sender displayname (ex: "Bob: Hello!") + // If a sender changes his displayname, we need to update the lastMessage. + MXRoomLastMessage *lastMessage; + for (MXEvent *event in stateEvents) + { + if (event.isUserProfileChange) + { + if (!lastMessage) + { + // Load lastMessageEvent on demand to save I/O + lastMessage = summary.lastMessage; + } + + if ([event.sender isEqualToString:lastMessage.sender]) + { + // The last message must be recomputed + [summary resetLastMessage:nil failure:nil commit:YES]; + break; + } + } + else if (event.eventType == MXEventTypeRoomJoinRules) + { + summary.joinRule = roomState.joinRule; + } + } + + return [defaultRoomSummaryUpdater session:session updateRoomSummary:summary withStateEvents:stateEvents roomState:roomState]; +} + +- (BOOL)session:(MXSession *)session updateRoomSummary:(MXRoomSummary *)summary withLastEvent:(MXEvent *)event eventState:(MXRoomState *)eventState roomState:(MXRoomState *)roomState +{ + // Use the default updater as first pass + MXRoomLastMessage *currentlastMessage = summary.lastMessage; + BOOL updated = [defaultRoomSummaryUpdater session:session updateRoomSummary:summary withLastEvent:event eventState:eventState roomState:roomState]; + if (updated) + { + // Then customise + + // Compute the text message + // Note that we use the current room state (roomState) because when we display + // users displaynames, we want current displaynames + MXKEventFormatterError error; + NSString *lastMessageString = [self stringFromEvent:event withRoomState:roomState error:&error]; + + if (0 == lastMessageString.length) + { + // @TODO: there is a conflict with what [defaultRoomSummaryUpdater updateRoomSummary] did :/ + updated = NO; + // Restore the previous lastMessageEvent + [summary updateLastMessage:currentlastMessage]; + } + else + { + summary.lastMessage.text = lastMessageString; + + if (summary.lastMessage.others == nil) + { + summary.lastMessage.others = [NSMutableDictionary dictionary]; + } + + // Store the potential error + summary.lastMessage.others[@"mxkEventFormatterError"] = @(error); + + summary.lastMessage.others[@"lastEventDate"] = [self dateStringFromEvent:event withTime:YES]; + + // Check whether the sender name has to be added + NSString *prefix = nil; + + if (event.eventType == MXEventTypeRoomMessage) + { + NSString *msgtype = event.content[@"msgtype"]; + if ([msgtype isEqualToString:kMXMessageTypeEmote] == NO) + { + NSString *senderDisplayName = [self senderDisplayNameForEvent:event withRoomState:roomState]; + prefix = [NSString stringWithFormat:@"%@: ", senderDisplayName]; + } + } + else if (event.eventType == MXEventTypeSticker) + { + NSString *senderDisplayName = [self senderDisplayNameForEvent:event withRoomState:roomState]; + prefix = [NSString stringWithFormat:@"%@: ", senderDisplayName]; + } + + // Compute the attribute text message + summary.lastMessage.attributedText = [self renderString:summary.lastMessage.text withPrefix:prefix forEvent:event]; + } + } + + return updated; +} + +- (BOOL)session:(MXSession *)session updateRoomSummary:(MXRoomSummary *)summary withServerRoomSummary:(MXRoomSyncSummary *)serverRoomSummary roomState:(MXRoomState *)roomState +{ + return [defaultRoomSummaryUpdater session:session updateRoomSummary:summary withServerRoomSummary:serverRoomSummary roomState:roomState]; +} + + +#pragma mark - Conversion private methods + +/** + Get the text color to use according to the event state. + + @param event the event. + @return the text color. + */ +- (UIColor*)textColorForEvent:(MXEvent*)event +{ + // Select the text color + UIColor *textColor; + + // Check whether an error occurred during event formatting. + if (event.mxkEventFormatterError != MXKEventFormatterErrorNone) + { + textColor = _errorTextColor; + } + // Check whether the message is highlighted. + else if (event.mxkIsHighlighted || (event.isInThread && ![event.sender isEqualToString:mxSession.myUserId])) + { + textColor = _bingTextColor; + } + else + { + // Consider here the sending state of the event, and the property `isForSubtitle`. + switch (event.sentState) + { + case MXEventSentStateSent: + if (_isForSubtitle) + { + textColor = _subTitleTextColor; + } + else + { + textColor = _defaultTextColor; + } + break; + case MXEventSentStateEncrypting: + textColor = _encryptingTextColor; + break; + case MXEventSentStatePreparing: + case MXEventSentStateUploading: + case MXEventSentStateSending: + textColor = _sendingTextColor; + break; + case MXEventSentStateFailed: + textColor = _errorTextColor; + break; + default: + if (_isForSubtitle) + { + textColor = _subTitleTextColor; + } + else + { + textColor = _defaultTextColor; + } + break; + } + } + + return textColor; +} + +/** + Get the text font to use according to the event state. + + @param event the event. + @return the text font. + */ +- (UIFont*)fontForEvent:(MXEvent*)event +{ + // Select text font + UIFont *font = _defaultTextFont; + if (event.isState) + { + font = _stateEventTextFont; + } + else if (event.eventType == MXEventTypeCallInvite || event.eventType == MXEventTypeCallAnswer || event.eventType == MXEventTypeCallHangup) + { + font = _callNoticesTextFont; + } + else if (event.mxkIsHighlighted || (event.isInThread && ![event.sender isEqualToString:mxSession.myUserId])) + { + font = _bingTextFont; + } + else if (event.eventType == MXEventTypeRoomEncrypted) + { + font = _encryptedMessagesTextFont; + } + else if (!_isForSubtitle && event.eventType == MXEventTypeRoomMessage && (_emojiOnlyTextFont || _singleEmojiTextFont)) + { + NSString *message; + MXJSONModelSetString(message, event.content[@"body"]); + + if (_emojiOnlyTextFont && [MXKTools isEmojiOnlyString:message]) + { + font = _emojiOnlyTextFont; + } + else if (_singleEmojiTextFont && [MXKTools isSingleEmojiString:message]) + { + font = _singleEmojiTextFont; + } + } + return font; +} + +#pragma mark - Conversion tools + +- (NSString *)htmlStringFromMarkdownString:(NSString *)markdownString +{ + NSString *htmlString = [_markdownToHTMLRenderer renderToHTMLWithMarkdown:markdownString]; + + // Strip off the trailing newline, if it exists. + if ([htmlString hasSuffix:@"\n"]) + { + htmlString = [htmlString substringToIndex:htmlString.length - 1]; + } + + // Strip start and end

tags else you get 'orrible spacing. + // But only do this if it's a single paragraph we're dealing with, + // otherwise we'll produce some garbage (`something

another`). + if ([htmlString hasPrefix:@"

"] && [htmlString hasSuffix:@"

"]) + { + NSArray *components = [htmlString componentsSeparatedByString:@"

"]; + NSUInteger paragrapsCount = components.count - 1; + + if (paragrapsCount == 1) { + htmlString = [htmlString substringFromIndex:3]; + htmlString = [htmlString substringToIndex:htmlString.length - 4]; + } + } + + return htmlString; +} + +#pragma mark - Timestamp formatting + +- (NSString*)dateStringFromDate:(NSDate *)date withTime:(BOOL)time +{ + // Get first date string without time (if a date format is defined, else only time string is returned) + NSString *dateString = nil; + if (dateFormatter.dateFormat) + { + dateString = [dateFormatter stringFromDate:date]; + } + + if (time) + { + NSString *timeString = [self timeStringFromDate:date]; + if (dateString.length) + { + // Add time string + dateString = [NSString stringWithFormat:@"%@ %@", dateString, timeString]; + } + else + { + dateString = timeString; + } + } + + return dateString; +} + +- (NSString*)dateStringFromTimestamp:(uint64_t)timestamp withTime:(BOOL)time +{ + NSDate *date = [NSDate dateWithTimeIntervalSince1970:timestamp / 1000]; + + return [self dateStringFromDate:date withTime:time]; +} + +- (NSString*)dateStringFromEvent:(MXEvent *)event withTime:(BOOL)time +{ + if (event.originServerTs != kMXUndefinedTimestamp) + { + return [self dateStringFromTimestamp:event.originServerTs withTime:time]; + } + + return nil; +} + +- (NSString*)timeStringFromDate:(NSDate *)date +{ + NSString *timeString = [timeFormatter stringFromDate:date]; + + return timeString.lowercaseString; +} + +@end diff --git a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKRoomNameStringLocalizer.h b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKRoomNameStringLocalizer.h new file mode 100644 index 000000000..3c1f419c8 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKRoomNameStringLocalizer.h @@ -0,0 +1,26 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +@import Foundation; + +#import + +/** + The `MXKRoomNameStringLocalizer` implements localization strings for `MXRoomNameStringLocalizerProtocol`. + */ +@interface MXKRoomNameStringLocalizer : NSObject + +@end diff --git a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKRoomNameStringLocalizer.m b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKRoomNameStringLocalizer.m new file mode 100644 index 000000000..904207536 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKRoomNameStringLocalizer.m @@ -0,0 +1,42 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomNameStringLocalizer.h" +#import "MXKSwiftHeader.h" + +@implementation MXKRoomNameStringLocalizer + +- (NSString *)emptyRoom +{ + return [MatrixKitL10n roomDisplaynameEmptyRoom]; +} + +- (NSString *)twoMembers:(NSString *)firstMember second:(NSString *)secondMember +{ + return [MatrixKitL10n roomDisplaynameTwoMembers:firstMember :secondMember]; +} + +- (NSString *)moreThanTwoMembers:(NSString *)firstMember count:(NSNumber *)memberCount +{ + return [MatrixKitL10n roomDisplaynameMoreThanTwoMembers:firstMember :memberCount.stringValue]; +} + +- (NSString *)allOtherMembersLeft:(NSString *)member +{ + return [MatrixKitL10n roomDisplaynameAllOtherMembersLeft:member]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Utils/EventFormatter/MarkdownToHTMLRenderer.swift b/Riot/Modules/MatrixKit/Utils/EventFormatter/MarkdownToHTMLRenderer.swift new file mode 100644 index 000000000..820928fb2 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/EventFormatter/MarkdownToHTMLRenderer.swift @@ -0,0 +1,52 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import Foundation +import Down +import libcmark + +@objc public protocol MarkdownToHTMLRendererProtocol: NSObjectProtocol { + func renderToHTML(markdown: String) -> String? +} + +@objcMembers +public class MarkdownToHTMLRenderer: NSObject { + + fileprivate var options: DownOptions = [] + + // Do not expose an initializer with options, because `DownOptions` is not ObjC compatible. + public override init() { + super.init() + } +} + +extension MarkdownToHTMLRenderer: MarkdownToHTMLRendererProtocol { + + public func renderToHTML(markdown: String) -> String? { + return try? Down(markdownString: markdown).toHTML(options) + } + +} + +@objcMembers +public class MarkdownToHTMLRendererHardBreaks: MarkdownToHTMLRenderer { + + public override init() { + super.init() + options = .hardBreaks + } + +} diff --git a/Riot/Modules/MatrixKit/Utils/MXKAnalyticsConstants.h b/Riot/Modules/MatrixKit/Utils/MXKAnalyticsConstants.h new file mode 100644 index 000000000..97d3c25f2 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKAnalyticsConstants.h @@ -0,0 +1,33 @@ +// +// Copyright 2020 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + + +typedef NSString *const MXKAnalyticsCategory NS_TYPED_EXTENSIBLE_ENUM; + +/** + The analytics category for local contacts. + */ +static MXKAnalyticsCategory const MXKAnalyticsCategoryContacts = @"localContacts"; + + +typedef NSString *const MXKAnalyticsName NS_TYPED_EXTENSIBLE_ENUM; + +/** + The analytics value for accept/decline of local contacts access. + */ +static MXKAnalyticsName const MXKAnalyticsNameContactsAccessGranted = @"accessGranted"; diff --git a/Riot/Modules/MatrixKit/Utils/MXKConstants.h b/Riot/Modules/MatrixKit/Utils/MXKConstants.h new file mode 100644 index 000000000..b580d234e --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKConstants.h @@ -0,0 +1,41 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#define MXK_DEPRECATED_ATTRIBUTE __attribute__((deprecated)) +#define MXK_DEPRECATED_ATTRIBUTE_WITH_MSG(msg) __attribute((deprecated((msg)))) + +/** + The Matrix iOS Kit version. + */ +FOUNDATION_EXPORT NSString *const MatrixKitVersion; + +/** + Posted when an error is observed at Matrix Kit level. + This notification may be used to inform user by showing the error as an alert. + The notification object is the NSError instance. + + The passed userInfo dictionary may contain: + - `kMXKErrorUserIdKey` the matrix identifier of the account concerned by this error. + */ +FOUNDATION_EXPORT NSString *const kMXKErrorNotification; + +/** + The key in notification userInfo dictionary representating the account userId. + */ +FOUNDATION_EXPORT NSString *const kMXKErrorUserIdKey; diff --git a/Riot/Modules/MatrixKit/Utils/MXKConstants.m b/Riot/Modules/MatrixKit/Utils/MXKConstants.m new file mode 100644 index 000000000..cf6c87873 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKConstants.m @@ -0,0 +1,23 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKConstants.h" + +NSString *const kMXKErrorNotification = @"kMXKErrorNotification"; + +NSString *const kMXKErrorUserIdKey = @"kMXKErrorUserIdKey"; diff --git a/Riot/Modules/MatrixKit/Utils/MXKDocumentPickerPresenter.swift b/Riot/Modules/MatrixKit/Utils/MXKDocumentPickerPresenter.swift new file mode 100644 index 000000000..1f9b9c89c --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKDocumentPickerPresenter.swift @@ -0,0 +1,80 @@ +/* + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import UIKit +import MobileCoreServices + +@objc public protocol MXKDocumentPickerPresenterDelegate { + func documentPickerPresenter(_ presenter: MXKDocumentPickerPresenter, didPickDocumentsAt url: URL) + func documentPickerPresenterWasCancelled(_ presenter: MXKDocumentPickerPresenter) +} + +/// MXKDocumentPickerPresenter presents a controller that provides access to documents or destinations outside the app’s sandbox. +/// Internally presents a UIDocumentPickerViewController in UIDocumentPickerMode.import. +/// Note: You must turn on the iCloud Documents capabilities in Xcode (see https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/DocumentPickerProgrammingGuide/Introduction/Introduction.html#//apple_ref/doc/uid/TP40014451) +@objcMembers +public class MXKDocumentPickerPresenter: NSObject { + + // MARK: - Properties + + // MARK: Private + + private weak var presentingViewController: UIViewController? + + // MARK: Public + + public weak var delegate: MXKDocumentPickerPresenterDelegate? + + public var isPresenting: Bool { + return self.presentingViewController?.parent != nil + } + + // MARK: - Public + + /// Presents a document picker view controller modally. + /// + /// - Parameters: + /// - allowedUTIs: Allowed pickable file UTIs. + /// - viewController: The view controller on which to present the document picker. + /// - animated: Indicate true to animate. + /// - completion: Animation completion. + public func presentDocumentPicker(with allowedUTIs: [MXKUTI], from viewController: UIViewController, animated: Bool, completion: (() -> Void)?) { + let documentTypes = allowedUTIs.map { return $0.rawValue } + let documentPicker = UIDocumentPickerViewController(documentTypes: documentTypes, in: .import) + documentPicker.delegate = self + viewController.present(documentPicker, animated: animated, completion: completion) + self.presentingViewController = viewController + } +} + +// MARK - UIDocumentPickerDelegate +extension MXKDocumentPickerPresenter: UIDocumentPickerDelegate { + + public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first else { + return + } + self.delegate?.documentPickerPresenter(self, didPickDocumentsAt: url) + } + + public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + self.delegate?.documentPickerPresenterWasCancelled(self) + } + + public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) { + self.delegate?.documentPickerPresenter(self, didPickDocumentsAt: url) + } +} diff --git a/Riot/Modules/MatrixKit/Utils/MXKResponderRageShaking.h b/Riot/Modules/MatrixKit/Utils/MXKResponderRageShaking.h new file mode 100644 index 000000000..7c77aad24 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKResponderRageShaking.h @@ -0,0 +1,47 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +/** + `MXKResponderRageShaking` defines a protocol an object must conform to handle rage shake + on view controllers or other kinds of `UIResponder`. + */ +@protocol MXKResponderRageShaking + +/** + Tells the receiver that a motion event has begun. + + @param responder the view controller (or another kind of `UIResponder`) which observed the motion. + */ +- (void)startShaking:(UIResponder*)responder; + +/** + Tells the receiver that a motion event has ended. + + @param responder the view controller (or another kind of `UIResponder`) which observed the motion. + */ +- (void)stopShaking:(UIResponder*)responder; + +/** + Ignore pending rage shake related to the provided responder. + + @param responder a view controller (or another kind of `UIResponder`). + */ +- (void)cancel:(UIResponder*)responder; + +@end + diff --git a/Riot/Modules/MatrixKit/Utils/MXKSoundPlayer.h b/Riot/Modules/MatrixKit/Utils/MXKSoundPlayer.h new file mode 100644 index 000000000..2643eeac7 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKSoundPlayer.h @@ -0,0 +1,36 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MXKSoundPlayer : NSObject + ++ (instancetype)sharedInstance; + ++ (instancetype)init NS_UNAVAILABLE; ++ (instancetype)new NS_UNAVAILABLE; + +- (void)playSoundAt:(NSURL *)url repeat:(BOOL)repeat vibrate:(BOOL)vibrate routeToBuiltInReceiver:(BOOL)useBuiltInReceiver; +- (void)stopPlayingWithAudioSessionDeactivation:(BOOL)deactivateAudioSession; + +- (void)vibrateWithRepeat:(BOOL)repeat; +- (void)stopVibrating; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/MatrixKit/Utils/MXKSoundPlayer.m b/Riot/Modules/MatrixKit/Utils/MXKSoundPlayer.m new file mode 100644 index 000000000..665e31f32 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKSoundPlayer.m @@ -0,0 +1,145 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKSoundPlayer.h" + +#import +#import + +static const NSTimeInterval kVibrationInterval = 1.24875; + +@interface MXKSoundPlayer () + +@property (nonatomic, nullable) AVAudioPlayer *audioPlayer; + +@property (nonatomic, getter=isVibrating) BOOL vibrating; + +@end + +@implementation MXKSoundPlayer + ++ (instancetype)sharedInstance +{ + static MXKSoundPlayer *soundPlayer; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + soundPlayer = [MXKSoundPlayer alloc]; + }); + return soundPlayer; +} + +- (void)playSoundAt:(NSURL *)url repeat:(BOOL)repeat vibrate:(BOOL)vibrate routeToBuiltInReceiver:(BOOL)useBuiltInReceiver +{ + if (self.audioPlayer) + { + [self stopPlayingWithAudioSessionDeactivation:NO]; + } + + self.audioPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:nil]; + + if (!self.audioPlayer) + return; + + self.audioPlayer.delegate = self; + self.audioPlayer.numberOfLoops = repeat ? -1 : 0; + [self.audioPlayer prepareToPlay]; + + // Setup AVAudioSession + // We use SoloAmbient instead of Playback category to respect silent mode + NSString *audioSessionCategory = useBuiltInReceiver ? AVAudioSessionCategoryPlayAndRecord : AVAudioSessionCategorySoloAmbient; + [[AVAudioSession sharedInstance] setCategory:audioSessionCategory error:nil]; + + if (vibrate) + [self vibrateWithRepeat:repeat]; + + [self.audioPlayer play]; +} + +- (void)stopPlayingWithAudioSessionDeactivation:(BOOL)deactivateAudioSession; +{ + if (self.audioPlayer) + { + [self.audioPlayer stop]; + self.audioPlayer = nil; + + if (deactivateAudioSession) + { + // Release the audio session to allow resuming of background music app + [[AVAudioSession sharedInstance] setActive:NO withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:nil]; + } + } + + if (self.isVibrating) + { + [self stopVibrating]; + } +} + +- (void)vibrateWithRepeat:(BOOL)repeat +{ + self.vibrating = YES; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kVibrationInterval * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + AudioServicesPlaySystemSound(kSystemSoundID_Vibrate); + + NSNumber *objRepeat = @(repeat); + AudioServicesAddSystemSoundCompletion(kSystemSoundID_Vibrate, + NULL, + kCFRunLoopCommonModes, + vibrationCompleted, + (__bridge_retained void * _Nullable)(objRepeat)); + }); +} + +- (void)stopVibrating +{ + self.vibrating = NO; + AudioServicesRemoveSystemSoundCompletion(kSystemSoundID_Vibrate); +} + +void vibrationCompleted(SystemSoundID ssID, void* __nullable clientData) +{ + NSNumber *objRepeat = (__bridge NSNumber *)clientData; + BOOL repeat = [objRepeat boolValue]; + CFRelease(clientData); + + MXKSoundPlayer *soundPlayer = [MXKSoundPlayer sharedInstance]; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kVibrationInterval * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + if (repeat && soundPlayer.isVibrating) + { + [soundPlayer vibrateWithRepeat:repeat]; + } + else + { + [soundPlayer stopVibrating]; + } + }); +} + +#pragma mark - AVAudioPlayerDelegate + +// This method is called only when the end of the player's track is reached. +// If you call `stop` or `pause` on player this method won't be called +- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag +{ + self.audioPlayer = nil; + + // Release the audio session to allow resuming of background music app + [[AVAudioSession sharedInstance] setActive:NO withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:nil]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Utils/MXKTools.h b/Riot/Modules/MatrixKit/Utils/MXKTools.h new file mode 100644 index 000000000..bf6f94a61 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKTools.h @@ -0,0 +1,426 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#define MXKTOOLS_LARGE_IMAGE_SIZE 1024 +#define MXKTOOLS_MEDIUM_IMAGE_SIZE 768 +#define MXKTOOLS_SMALL_IMAGE_SIZE 512 + +#define MXKTOOLS_USER_IDENTIFIER_BITWISE 0x01 +#define MXKTOOLS_ROOM_IDENTIFIER_BITWISE 0x02 +#define MXKTOOLS_ROOM_ALIAS_BITWISE 0x04 +#define MXKTOOLS_EVENT_IDENTIFIER_BITWISE 0x08 +#define MXKTOOLS_GROUP_IDENTIFIER_BITWISE 0x10 + +// Attribute in an NSAttributeString that marks a blockquote block that was in the original HTML string. +extern NSString *const kMXKToolsBlockquoteMarkAttribute; + +/** + Structure representing an the size of an image and its file size. + */ +typedef struct +{ + CGSize imageSize; + NSUInteger fileSize; + +} MXKImageCompressionSize; + +/** + Structure representing the sizes of image (image size and file size) according to + different level of compression. + */ + +typedef struct +{ + MXKImageCompressionSize small; + MXKImageCompressionSize medium; + MXKImageCompressionSize large; + MXKImageCompressionSize original; + + CGFloat actualLargeSize; + +} MXKImageCompressionSizes; + +@interface MXKTools : NSObject + +#pragma mark - Strings + +/** + Determine if a string contains one emoji and only one. + + @param string the string to check. + @return YES if YES. + */ ++ (BOOL)isSingleEmojiString:(NSString*)string; + +/** + Determine if a string contains only emojis. + + @param string the string to check. + @return YES if YES. + */ ++ (BOOL)isEmojiOnlyString:(NSString*)string; + +#pragma mark - Time + +/** + Format time interval. + ex: "5m 31s". + + @param secondsInterval time interval in seconds. + @return formatted string + */ ++ (NSString*)formatSecondsInterval:(CGFloat)secondsInterval; + +/** + Format time interval but rounded to the nearest time unit below. + ex: "5s", "1m", "2h" or "3d". + + @param secondsInterval time interval in seconds. + @return formatted string + */ ++ (NSString*)formatSecondsIntervalFloored:(CGFloat)secondsInterval; + +#pragma mark - Phone number + +/** + Return the number used to identify a mobile phone number internationally. + + The provided country code is ignored when the phone number is already internationalized, or when it + is a valid msisdn. + + @param phoneNumber the phone number. + @param countryCode the ISO 3166-1 country code representation (required when the phone number is in national format). + + @return a valid msisdn or nil if the provided phone number is invalid. + */ ++ (NSString*)msisdnWithPhoneNumber:(NSString *)phoneNumber andCountryCode:(NSString *)countryCode; + +/** + Format an MSISDN to a human readable international phone number. + + @param msisdn The MSISDN to format. + + @return Human readable international phone number. + */ ++ (NSString*)readableMSISDN:(NSString*)msisdn; + +#pragma mark - Hex color to UIColor conversion + +/** + Build a UIColor from an hexadecimal color value + + @param rgbValue the color expressed in hexa (0xRRGGBB) + @return the UIColor + */ ++ (UIColor*)colorWithRGBValue:(NSUInteger)rgbValue; + +/** + Build a UIColor from an hexadecimal color value with transparency + + @param argbValue the color expressed in hexa (0xAARRGGBB) + @return the UIColor + */ ++ (UIColor*)colorWithARGBValue:(NSUInteger)argbValue; + +/** + Return an hexadecimal color value from UIColor + + @param color the UIColor + @return rgbValue the color expressed in hexa (0xRRGGBB) + */ ++ (NSUInteger)rgbValueWithColor:(UIColor*)color; + +/** + Return an hexadecimal color value with transparency from UIColor + + @param color the UIColor + @return argbValue the color expressed in hexa (0xAARRGGBB) + */ ++ (NSUInteger)argbValueWithColor:(UIColor*)color; + +#pragma mark - Image processing + +/** + Force image orientation to up + + @param imageSrc the original image. + @return image with `UIImageOrientationUp` orientation. + */ ++ (UIImage*)forceImageOrientationUp:(UIImage*)imageSrc; + +/** + Return struct MXKImageCompressionSizes representing the available compression sizes for the image + + @param image the image to get available sizes for + @param originalFileSize the size in bytes of the original image file or the image data (0 if this value is unknown). + */ ++ (MXKImageCompressionSizes)availableCompressionSizesForImage:(UIImage*)image originalFileSize:(NSUInteger)originalFileSize; + +/** + Compute image size to fit in specific box size (in aspect fit mode) + + @param originalSize the original size + @param maxSize the box size + @param canExpand tell whether the image can be expand or not + @return the resized size. + */ ++ (CGSize)resizeImageSize:(CGSize)originalSize toFitInSize:(CGSize)maxSize canExpand:(BOOL)canExpand; + +/** + Compute image size to fill specific box size (in aspect fill mode) + + @param originalSize the original size + @param maxSize the box size + @param canExpand tell whether the image can be expand or not + @return the resized size. + */ ++ (CGSize)resizeImageSize:(CGSize)originalSize toFillWithSize:(CGSize)maxSize canExpand:(BOOL)canExpand; + +/** + Reduce image to fit in the provided size. + The aspect ratio is kept. + If the image is smaller than the provided size, the image is not recomputed. + + @discussion This method call `+ [reduceImage:toFitInSize:useMainScreenScale:]` with `useMainScreenScale` value to `NO`. + + @param image the image to modify. + @param size to fit in. + @return resized image. + + @see reduceImage:toFitInSize:useMainScreenScale: + */ ++ (UIImage *)reduceImage:(UIImage *)image toFitInSize:(CGSize)size; + +/** + Reduce image to fit in the provided size. + The aspect ratio is kept. + If the image is smaller than the provided size, the image is not recomputed. + + @param image the image to modify. + @param size to fit in. + @param useMainScreenScale Indicate true to use main screen scale. + @return resized image. + */ ++ (UIImage *)reduceImage:(UIImage *)image toFitInSize:(CGSize)size useMainScreenScale:(BOOL)useMainScreenScale; + +/** + Reduce image to fit in the provided size. + The aspect ratio is kept. + + @discussion This method use less memory than `+ [reduceImage:toFitInSize:useMainScreenScale:]`. + + @param imageData The image data. + @param size Size to fit in. + @return Resized image or nil if the data is not interpreted. + */ ++ (UIImage*)resizeImageWithData:(NSData*)imageData toFitInSize:(CGSize)size; + +/** + Resize image to a provided size. + + @param image the image to modify. + @param size the new size. + @return resized image. + */ ++ (UIImage*)resizeImage:(UIImage *)image toSize:(CGSize)size; + +/** + Resize image with rounded corners to a provided size. + + @param image the image to modify. + @param size the new size. + @return resized image. + */ ++ (UIImage*)resizeImageWithRoundedCorners:(UIImage *)image toSize:(CGSize)size; + +/** + Paint an image with a color. + + @discussion + All non fully transparent (alpha = 0) will be painted with the provided color. + + @param image the image to paint. + @param color the color to use. + @result a new UIImage object. + */ ++ (UIImage*)paintImage:(UIImage*)image withColor:(UIColor*)color; + +/** + Convert a rotation angle to the most suitable image orientation. + + @param angle rotation angle in degree. + @return image orientation. + */ ++ (UIImageOrientation)imageOrientationForRotationAngleInDegree:(NSInteger)angle; + +/** + Draw the image resource in a view and transforms it to a pattern color. + The view size is defined by patternSize and will have a "backgroundColor" backgroundColor. + The resource image is drawn with the resourceSize size and is centered into its parent view. + + @param reourceName the image resource name. + @param backgroundColor the pattern background color. + @param patternSize the pattern size. + @param resourceSize the resource size in the pattern. + @return the pattern color which can be used to define the background color of a view in order to display the provided image as its background. + */ ++ (UIColor*)convertImageToPatternColor:(NSString*)reourceName backgroundColor:(UIColor*)backgroundColor patternSize:(CGSize)patternSize resourceSize:(CGSize)resourceSize; + +#pragma mark - Video conversion +/** +Creates a `UIAlertController` with appropriate `AVAssetExportPreset` choices for the video passed in. + @param videoAsset The video to generate the choices for. + @param completion The block called when a preset has been chosen. `presetName` will contain the preset name or `nil` if cancelled. +*/ ++ (UIAlertController*)videoConversionPromptForVideoAsset:(AVAsset *)videoAsset + withCompletion:(void (^)(NSString * _Nullable presetName))completion; + +#pragma mark - App permissions + +/** + Check permission to access a media. + +@discussion + If the access was not yet granted, a dialog will be shown to the user. + If it is the first attempt to access the media, the dialog is the classic iOS one. + Else, the dialog will ask the user to manually change the permission in the app settings. + + @param mediaType the media type, either AVMediaTypeVideo or AVMediaTypeAudio. + @param manualChangeMessage the message to display if the end user must change the app settings manually. + @param viewController the view controller to attach the dialog displaying manualChangeMessage. + @param handler the block called with the result of requesting access + */ ++ (void)checkAccessForMediaType:(NSString *)mediaType + manualChangeMessage:(NSString*)manualChangeMessage + showPopUpInViewController:(UIViewController*)viewController + completionHandler:(void (^)(BOOL granted))handler; + +/** + Check required permission for the provided call. + + @param isVideoCall flag set to YES in case of video call. + @param manualChangeMessageForAudio the message to display if the end user must change the app settings manually for audio. + @param manualChangeMessageForVideo the message to display if the end user must change the app settings manually for video + @param viewController the view controller to attach the dialog displaying manualChangeMessage. + @param handler the block called with the result of requesting access + */ ++ (void)checkAccessForCall:(BOOL)isVideoCall +manualChangeMessageForAudio:(NSString*)manualChangeMessageForAudio +manualChangeMessageForVideo:(NSString*)manualChangeMessageForVideo + showPopUpInViewController:(UIViewController*)viewController + completionHandler:(void (^)(BOOL granted))handler; + +/** + Check permission to access Contacts. + + @discussion + If the access was not yet granted, a dialog will be shown to the user. + If it is the first attempt to access the media, the dialog is the classic iOS one. + Else, the dialog will ask the user to manually change the permission in the app settings. + + @param manualChangeMessage the message to display if the end user must change the app settings manually. + If nil, the dialog for displaying manualChangeMessage will not be shown. + @param viewController the view controller to attach the dialog displaying manualChangeMessage. + @param handler the block called with the result of requesting access + */ ++ (void)checkAccessForContacts:(NSString*)manualChangeMessage + showPopUpInViewController:(UIViewController*)viewController + completionHandler:(void (^)(BOOL granted))handler; + +/** + Check permission to access Contacts. + + @discussion + If the access was not yet granted, a dialog will be shown to the user. + If it is the first attempt to access the media, the dialog is the classic iOS one. + Else, the dialog will ask the user to manually change the permission in the app settings. + + @param manualChangeTitle the title to display if the end user must change the app settings manually. + @param manualChangeMessage the message to display if the end user must change the app settings manually. + If nil, the dialog for displaying manualChangeMessage will not be shown. + @param viewController the view controller to attach the dialog displaying manualChangeMessage. + @param handler the block called with the result of requesting access + */ ++ (void)checkAccessForContacts:(NSString *)manualChangeTitle + withManualChangeMessage:(NSString *)manualChangeMessage + showPopUpInViewController:(UIViewController *)viewController + completionHandler:(void (^)(BOOL granted))handler; + +#pragma mark - HTML processing + +/** + Removing DTCoreText artifacts: + - Trim trailing whitespace and newlines in the string content. + - Replace DTImageTextAttachments with a simple NSTextAttachment subclass. + + @param attributedString an attributed string. + @return the resulting string. + */ ++ (NSAttributedString*)removeDTCoreTextArtifacts:(NSAttributedString*)attributedString; + +/** + Make some matrix identifiers clickable in the string content. + + @param attributedString an attributed string. + @param enabledMatrixIdsBitMask the bitmask used to list the types of matrix id to process (see MXKTOOLS_XXX__BITWISE). + @return the resulting string. + */ ++ (NSAttributedString*)createLinksInAttributedString:(NSAttributedString*)attributedString forEnabledMatrixIds:(NSInteger)enabledMatrixIdsBitMask; + +#pragma mark - HTML processing - blockquote display handling + +/** + Return a CSS to make DTCoreText mark blockquote blocks in the `NSAttributedString` output. + + These blocks output will have a `DTTextBlocksAttribute` attribute in the `NSAttributedString` + that can be used for later computation (in `removeMarkedBlockquotesArtifacts`). + + @return a CSS string. + */ ++ (NSString*)cssToMarkBlockquotes; + +/** + Removing DTCoreText artifacts used to mark blockquote blocks. + + @param attributedString an attributed string. + @return the resulting string. + */ ++ (NSAttributedString*)removeMarkedBlockquotesArtifacts:(NSAttributedString*)attributedString; + +/** + Enumerate all sections of the attributed string that refer to an HTML blockquote block. + + Must be used with `cssToMarkBlockquotes` and `removeMarkedBlockquotesArtifacts`. + + @param attributedString the attributed string. + @param block a block called for each HTML blockquote blocks. + */ ++ (void)enumerateMarkedBlockquotesInAttributedString:(NSAttributedString*)attributedString usingBlock:(void (^)(NSRange range, BOOL *stop))block; + +#pragma mark - Push + +/** + Trim push token in order to log it. + + @param pushToken the token to trim. + @return a trimmed description. + */ ++ (NSString*)logForPushToken:(NSData*)pushToken; + +@end diff --git a/Riot/Modules/MatrixKit/Utils/MXKTools.m b/Riot/Modules/MatrixKit/Utils/MXKTools.m new file mode 100644 index 000000000..76753eed5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKTools.m @@ -0,0 +1,1230 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTools.h" + +@import MatrixSDK; +@import Contacts; +@import libPhoneNumber_iOS; +@import DTCoreText; + +#import "MXKConstants.h" +#import "NSBundle+MatrixKit.h" +#import "MXKAppSettings.h" +#import +#import "MXKSwiftHeader.h" +#import "MXKAnalyticsConstants.h" + +#pragma mark - Constants definitions + +// Temporary background color used to identify blockquote blocks with DTCoreText. +#define kMXKToolsBlockquoteMarkColor [UIColor magentaColor] + +// Attribute in an NSAttributeString that marks a blockquote block that was in the original HTML string. +NSString *const kMXKToolsBlockquoteMarkAttribute = @"kMXKToolsBlockquoteMarkAttribute"; + +#pragma mark - MXKTools static private members +// The regex used to find matrix ids. +static NSRegularExpression *userIdRegex; +static NSRegularExpression *roomIdRegex; +static NSRegularExpression *roomAliasRegex; +static NSRegularExpression *eventIdRegex; +static NSRegularExpression *groupIdRegex; +// A regex to find http URLs. +static NSRegularExpression *httpLinksRegex; +// A regex to find all HTML tags +static NSRegularExpression *htmlTagsRegex; + +@implementation MXKTools + ++ (void)initialize +{ + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + + userIdRegex = [NSRegularExpression regularExpressionWithPattern:kMXToolsRegexStringForMatrixUserIdentifier options:NSRegularExpressionCaseInsensitive error:nil]; + roomIdRegex = [NSRegularExpression regularExpressionWithPattern:kMXToolsRegexStringForMatrixRoomIdentifier options:NSRegularExpressionCaseInsensitive error:nil]; + roomAliasRegex = [NSRegularExpression regularExpressionWithPattern:kMXToolsRegexStringForMatrixRoomAlias options:NSRegularExpressionCaseInsensitive error:nil]; + eventIdRegex = [NSRegularExpression regularExpressionWithPattern:kMXToolsRegexStringForMatrixEventIdentifier options:NSRegularExpressionCaseInsensitive error:nil]; + groupIdRegex = [NSRegularExpression regularExpressionWithPattern:kMXToolsRegexStringForMatrixGroupIdentifier options:NSRegularExpressionCaseInsensitive error:nil]; + + httpLinksRegex = [NSRegularExpression regularExpressionWithPattern:@"(?i)\\b(https?://.*)\\b" options:NSRegularExpressionCaseInsensitive error:nil]; + htmlTagsRegex = [NSRegularExpression regularExpressionWithPattern:@"<(\\w+)[^>]*>" options:NSRegularExpressionCaseInsensitive error:nil]; + }); +} + +#pragma mark - Strings + ++ (BOOL)isSingleEmojiString:(NSString *)string +{ + return [MXKTools isEmojiString:string singleEmoji:YES]; +} + ++ (BOOL)isEmojiOnlyString:(NSString *)string +{ + return [MXKTools isEmojiString:string singleEmoji:NO]; +} + +// Highly inspired from https://stackoverflow.com/a/34659249 ++ (BOOL)isEmojiString:(NSString*)string singleEmoji:(BOOL)singleEmoji +{ + if (string.length == 0) + { + return NO; + } + + __block BOOL result = YES; + + NSRange stringRange = NSMakeRange(0, [string length]); + + [string enumerateSubstringsInRange:stringRange + options:NSStringEnumerationByComposedCharacterSequences + usingBlock:^(NSString *substring, + NSRange substringRange, + NSRange enclosingRange, + BOOL *stop) + { + BOOL isEmoji = NO; + + if (singleEmoji && !NSEqualRanges(stringRange, substringRange)) + { + // The string contains several characters. Go out + result = NO; + *stop = YES; + return; + } + + const unichar hs = [substring characterAtIndex:0]; + // Surrogate pair + if (0xd800 <= hs && + hs <= 0xdbff) + { + if (substring.length > 1) + { + const unichar ls = [substring characterAtIndex:1]; + const int uc = ((hs - 0xd800) * 0x400) + (ls - 0xdc00) + 0x10000; + if (0x1d000 <= uc && + uc <= 0x1f9ff) + { + isEmoji = YES; + } + } + } + else if (substring.length > 1) + { + const unichar ls = [substring characterAtIndex:1]; + if (ls == 0x20e3 || + ls == 0xfe0f || + ls == 0xd83c) + { + isEmoji = YES; + } + } + else + { + // Non surrogate + if (0x2100 <= hs && + hs <= 0x27ff) + { + isEmoji = YES; + } + else if (0x2B05 <= hs && + hs <= 0x2b07) + { + isEmoji = YES; + } + else if (0x2934 <= hs && + hs <= 0x2935) + { + isEmoji = YES; + } + else if (0x3297 <= hs && + hs <= 0x3299) + { + isEmoji = YES; + } + else if (hs == 0xa9 || + hs == 0xae || + hs == 0x303d || + hs == 0x3030 || + hs == 0x2b55 || + hs == 0x2b1c || + hs == 0x2b1b || + hs == 0x2b50) + { + isEmoji = YES; + } + } + + if (!isEmoji) + { + result = NO; + *stop = YES; + } + }]; + + return result; +} + +#pragma mark - Time interval + ++ (NSString*)formatSecondsInterval:(CGFloat)secondsInterval +{ + NSMutableString* formattedString = [[NSMutableString alloc] init]; + + if (secondsInterval < 1) + { + [formattedString appendFormat:@"< 1%@", [MatrixKitL10n formatTimeS]]; + } + else if (secondsInterval < 60) + { + [formattedString appendFormat:@"%d%@", (int)secondsInterval, [MatrixKitL10n formatTimeS]]; + } + else if (secondsInterval < 3600) + { + [formattedString appendFormat:@"%d%@ %2d%@", (int)(secondsInterval/60), [MatrixKitL10n formatTimeM], + ((int)secondsInterval) % 60, [MatrixKitL10n formatTimeS]]; + } + else if (secondsInterval >= 3600) + { + [formattedString appendFormat:@"%d%@ %d%@ %d%@", (int)(secondsInterval / 3600), [MatrixKitL10n formatTimeH], + ((int)(secondsInterval) % 3600) / 60, [MatrixKitL10n formatTimeM], + (int)(secondsInterval) % 60, [MatrixKitL10n formatTimeS]]; + } + [formattedString appendString:@" left"]; + + return formattedString; +} + ++ (NSString *)formatSecondsIntervalFloored:(CGFloat)secondsInterval +{ + NSString* formattedString; + + if (secondsInterval < 0) + { + formattedString = [NSString stringWithFormat:@"0%@", [MatrixKitL10n formatTimeS]]; + } + else + { + NSUInteger seconds = secondsInterval; + if (seconds < 60) + { + formattedString = [NSString stringWithFormat:@"%tu%@", seconds, [MatrixKitL10n formatTimeS]]; + } + else if (secondsInterval < 3600) + { + formattedString = [NSString stringWithFormat:@"%tu%@", seconds / 60, [MatrixKitL10n formatTimeM]]; + } + else if (secondsInterval < 86400) + { + formattedString = [NSString stringWithFormat:@"%tu%@", seconds / 3600, [MatrixKitL10n formatTimeH]]; + } + else + { + formattedString = [NSString stringWithFormat:@"%tu%@", seconds / 86400, [MatrixKitL10n formatTimeD]]; + } + } + + return formattedString; +} + +#pragma mark - Phone number + ++ (NSString*)msisdnWithPhoneNumber:(NSString *)phoneNumber andCountryCode:(NSString *)countryCode +{ + NSString *msisdn = nil; + NBPhoneNumber *phoneNb; + + if ([phoneNumber hasPrefix:@"+"] || [phoneNumber hasPrefix:@"00"]) + { + phoneNb = [[NBPhoneNumberUtil sharedInstance] parse:phoneNumber defaultRegion:nil error:nil]; + } + else + { + // Check whether the provided phone number is a valid msisdn. + NSString *e164 = [NSString stringWithFormat:@"+%@", phoneNumber]; + phoneNb = [[NBPhoneNumberUtil sharedInstance] parse:e164 defaultRegion:nil error:nil]; + + if (![[NBPhoneNumberUtil sharedInstance] isValidNumber:phoneNb]) + { + // Consider the phone number as a national one, and use the country code. + phoneNb = [[NBPhoneNumberUtil sharedInstance] parse:phoneNumber defaultRegion:countryCode error:nil]; + } + } + + if ([[NBPhoneNumberUtil sharedInstance] isValidNumber:phoneNb]) + { + NSString *e164 = [[NBPhoneNumberUtil sharedInstance] format:phoneNb numberFormat:NBEPhoneNumberFormatE164 error:nil]; + + if ([e164 hasPrefix:@"+"]) + { + msisdn = [e164 substringFromIndex:1]; + } + else if ([e164 hasPrefix:@"00"]) + { + msisdn = [e164 substringFromIndex:2]; + } + } + + return msisdn; +} + ++ (NSString*)readableMSISDN:(NSString*)msisdn +{ + NSString *e164; + + if (([e164 hasPrefix:@"+"])) + { + e164 = msisdn; + } + else + { + e164 = [NSString stringWithFormat:@"+%@", msisdn]; + } + + NBPhoneNumber *phoneNb = [[NBPhoneNumberUtil sharedInstance] parse:e164 defaultRegion:nil error:nil]; + return [[NBPhoneNumberUtil sharedInstance] format:phoneNb numberFormat:NBEPhoneNumberFormatINTERNATIONAL error:nil]; +} + +#pragma mark - Hex color to UIColor conversion + ++ (UIColor *)colorWithRGBValue:(NSUInteger)rgbValue +{ + return [UIColor colorWithRed:((float)((rgbValue & 0xFF0000) >> 16))/255.0 green:((float)((rgbValue & 0xFF00) >> 8))/255.0 blue:((float)(rgbValue & 0xFF))/255.0 alpha:1.0]; +} + ++ (UIColor *)colorWithARGBValue:(NSUInteger)argbValue +{ + return [UIColor colorWithRed:((float)((argbValue & 0xFF0000) >> 16))/255.0 green:((float)((argbValue & 0xFF00) >> 8))/255.0 blue:((float)(argbValue & 0xFF))/255.0 alpha:((float)((argbValue & 0xFF000000) >> 24))/255.0]; +} + ++ (NSUInteger)rgbValueWithColor:(UIColor*)color +{ + CGFloat red, green, blue, alpha; + + [color getRed:&red green:&green blue:&blue alpha:&alpha]; + + NSUInteger rgbValue = ((int)(red * 255) << 16) + ((int)(green * 255) << 8) + (blue * 255); + + return rgbValue; +} + ++ (NSUInteger)argbValueWithColor:(UIColor*)color +{ + CGFloat red, green, blue, alpha; + + [color getRed:&red green:&green blue:&blue alpha:&alpha]; + + NSUInteger argbValue = ((int)(alpha * 255) << 24) + ((int)(red * 255) << 16) + ((int)(green * 255) << 8) + (blue * 255); + + return argbValue; +} + +#pragma mark - Image + ++ (UIImage*)forceImageOrientationUp:(UIImage*)imageSrc +{ + if ((imageSrc.imageOrientation == UIImageOrientationUp) || (!imageSrc)) + { + // Nothing to do + return imageSrc; + } + + // Draw the entire image in a graphics context, respecting the image’s orientation setting + UIGraphicsBeginImageContext(imageSrc.size); + [imageSrc drawAtPoint:CGPointMake(0, 0)]; + UIImage *retImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + return retImage; +} + ++ (MXKImageCompressionSizes)availableCompressionSizesForImage:(UIImage*)image originalFileSize:(NSUInteger)originalFileSize +{ + MXKImageCompressionSizes compressionSizes; + memset(&compressionSizes, 0, sizeof(MXKImageCompressionSizes)); + + // Store the original + compressionSizes.original.imageSize = image.size; + compressionSizes.original.fileSize = originalFileSize ? originalFileSize : UIImageJPEGRepresentation(image, 0.9).length; + + MXLogDebug(@"[MXKTools] availableCompressionSizesForImage: %f %f - File size: %tu", compressionSizes.original.imageSize.width, compressionSizes.original.imageSize.height, compressionSizes.original.fileSize); + + compressionSizes.actualLargeSize = MXKTOOLS_LARGE_IMAGE_SIZE; + + // Compute the file size for each compression level + CGFloat maxSize = MAX(compressionSizes.original.imageSize.width, compressionSizes.original.imageSize.height); + if (maxSize >= MXKTOOLS_SMALL_IMAGE_SIZE) + { + compressionSizes.small.imageSize = [MXKTools resizeImageSize:compressionSizes.original.imageSize toFitInSize:CGSizeMake(MXKTOOLS_SMALL_IMAGE_SIZE, MXKTOOLS_SMALL_IMAGE_SIZE) canExpand:NO]; + + compressionSizes.small.fileSize = (NSUInteger)[MXTools roundFileSize:(long long)(compressionSizes.small.imageSize.width * compressionSizes.small.imageSize.height * 0.20)]; + + if (maxSize >= MXKTOOLS_MEDIUM_IMAGE_SIZE) + { + compressionSizes.medium.imageSize = [MXKTools resizeImageSize:compressionSizes.original.imageSize toFitInSize:CGSizeMake(MXKTOOLS_MEDIUM_IMAGE_SIZE, MXKTOOLS_MEDIUM_IMAGE_SIZE) canExpand:NO]; + + compressionSizes.medium.fileSize = (NSUInteger)[MXTools roundFileSize:(long long)(compressionSizes.medium.imageSize.width * compressionSizes.medium.imageSize.height * 0.20)]; + + if (maxSize >= MXKTOOLS_LARGE_IMAGE_SIZE) + { + // In case of panorama the large resolution (1024 x ...) is not relevant. We prefer consider the third of the panarama width. + compressionSizes.actualLargeSize = maxSize / 3; + if (compressionSizes.actualLargeSize < MXKTOOLS_LARGE_IMAGE_SIZE) + { + compressionSizes.actualLargeSize = MXKTOOLS_LARGE_IMAGE_SIZE; + } + else + { + // Keep a multiple of predefined large size + compressionSizes.actualLargeSize = floor(compressionSizes.actualLargeSize / MXKTOOLS_LARGE_IMAGE_SIZE) * MXKTOOLS_LARGE_IMAGE_SIZE; + } + + compressionSizes.large.imageSize = [MXKTools resizeImageSize:compressionSizes.original.imageSize toFitInSize:CGSizeMake(compressionSizes.actualLargeSize, compressionSizes.actualLargeSize) canExpand:NO]; + + compressionSizes.large.fileSize = (NSUInteger)[MXTools roundFileSize:(long long)(compressionSizes.large.imageSize.width * compressionSizes.large.imageSize.height * 0.20)]; + } + else + { + MXLogDebug(@" - too small to fit in %d", MXKTOOLS_LARGE_IMAGE_SIZE); + } + } + else + { + MXLogDebug(@" - too small to fit in %d", MXKTOOLS_MEDIUM_IMAGE_SIZE); + } + } + else + { + MXLogDebug(@" - too small to fit in %d", MXKTOOLS_SMALL_IMAGE_SIZE); + } + + return compressionSizes; +} + + ++ (CGSize)resizeImageSize:(CGSize)originalSize toFitInSize:(CGSize)maxSize canExpand:(BOOL)canExpand +{ + if ((originalSize.width == 0) || (originalSize.height == 0)) + { + return CGSizeZero; + } + + CGSize resized = originalSize; + + if ((maxSize.width > 0) && (maxSize.height > 0) && (canExpand || ((originalSize.width > maxSize.width) || (originalSize.height > maxSize.height)))) + { + CGFloat ratioX = maxSize.width / originalSize.width; + CGFloat ratioY = maxSize.height / originalSize.height; + + CGFloat scale = MIN(ratioX, ratioY); + resized.width *= scale; + resized.height *= scale; + + // padding + resized.width = floorf(resized.width / 2) * 2; + resized.height = floorf(resized.height / 2) * 2; + } + + return resized; +} + ++ (CGSize)resizeImageSize:(CGSize)originalSize toFillWithSize:(CGSize)maxSize canExpand:(BOOL)canExpand +{ + CGSize resized = originalSize; + + if ((maxSize.width > 0) && (maxSize.height > 0) && (canExpand || ((originalSize.width > maxSize.width) && (originalSize.height > maxSize.height)))) + { + CGFloat ratioX = maxSize.width / originalSize.width; + CGFloat ratioY = maxSize.height / originalSize.height; + + CGFloat scale = MAX(ratioX, ratioY); + resized.width *= scale; + resized.height *= scale; + + // padding + resized.width = floorf(resized.width / 2) * 2; + resized.height = floorf(resized.height / 2) * 2; + } + + return resized; +} + ++ (UIImage *)reduceImage:(UIImage *)image toFitInSize:(CGSize)size +{ + return [self reduceImage:image toFitInSize:size useMainScreenScale:NO]; +} + ++ (UIImage *)reduceImage:(UIImage *)image toFitInSize:(CGSize)size useMainScreenScale:(BOOL)useMainScreenScale +{ + UIImage *resizedImage; + + // Check whether resize is required + if (size.width && size.height) + { + CGFloat width = image.size.width; + CGFloat height = image.size.height; + + if (width > size.width) + { + height = (height * size.width) / width; + height = floorf(height / 2) * 2; + width = size.width; + } + if (height > size.height) + { + width = (width * size.height) / height; + width = floorf(width / 2) * 2; + height = size.height; + } + + if (width != image.size.width || height != image.size.height) + { + // Create the thumbnail + CGSize imageSize = CGSizeMake(width, height); + + // Convert first the provided size in pixels + + // The scale factor is set to 0.0 to use the scale factor of the device’s main screen. + CGFloat scale = useMainScreenScale ? 0.0 : 1.0; + + UIGraphicsBeginImageContextWithOptions(imageSize, NO, scale); + + // // set to the top quality + // CGContextRef context = UIGraphicsGetCurrentContext(); + // CGContextSetInterpolationQuality(context, kCGInterpolationHigh); + + CGRect thumbnailRect = CGRectMake(0, 0, 0, 0); + thumbnailRect.origin = CGPointMake(0.0,0.0); + thumbnailRect.size.width = imageSize.width; + thumbnailRect.size.height = imageSize.height; + + [image drawInRect:thumbnailRect]; + resizedImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + } + } + else + { + resizedImage = image; + } + + return resizedImage; +} + ++ (UIImage*)resizeImageWithData:(NSData*)imageData toFitInSize:(CGSize)size +{ + // Create the image source + CGImageSourceRef imageSource = CGImageSourceCreateWithData((__bridge CFDataRef)imageData, NULL); + + // Take the max dimension of size to fit in + CGFloat maxPixelSize = fmax(size.width, size.height); + + //Create thumbnail options + CFDictionaryRef options = (__bridge CFDictionaryRef) @{ + (id) kCGImageSourceCreateThumbnailWithTransform : (id)kCFBooleanTrue, + (id) kCGImageSourceCreateThumbnailFromImageAlways : (id)kCFBooleanTrue, + (id) kCGImageSourceThumbnailMaxPixelSize : @(maxPixelSize) + }; + + // Generate the thumbnail + CGImageRef resizedImageRef = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options); + + UIImage *resizedImage = [[UIImage alloc] initWithCGImage:resizedImageRef]; + + CGImageRelease(resizedImageRef); + CFRelease(imageSource); + + return resizedImage; +} + ++ (UIImage*)resizeImage:(UIImage *)image toSize:(CGSize)size +{ + UIImage *resizedImage = image; + + // Check whether resize is required + if (size.width && size.height) + { + // Convert first the provided size in pixels + // The scale factor is set to 0.0 to use the scale factor of the device’s main screen. + UIGraphicsBeginImageContextWithOptions(size, NO, 0.0); + + CGContextRef context = UIGraphicsGetCurrentContext(); + CGContextSetInterpolationQuality(context, kCGInterpolationHigh); + + [image drawInRect:CGRectMake(0, 0, size.width, size.height)]; + resizedImage = UIGraphicsGetImageFromCurrentImageContext(); + + UIGraphicsEndImageContext(); + } + + return resizedImage; +} + ++ (UIImage*)resizeImageWithRoundedCorners:(UIImage *)image toSize:(CGSize)size +{ + UIImage *resizedImage = image; + + // Check whether resize is required + if (size.width && size.height) + { + // Convert first the provided size in pixels + // The scale factor is set to 0.0 to use the scale factor of the device’s main screen. + UIGraphicsBeginImageContextWithOptions(size, NO, 0.0); + + CGContextRef context = UIGraphicsGetCurrentContext(); + CGContextSetInterpolationQuality(context, kCGInterpolationHigh); + + // Add a clip to round corners + [[UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 0, size.width, size.height) cornerRadius:size.width/2] addClip]; + + [image drawInRect:CGRectMake(0, 0, size.width, size.height)]; + resizedImage = UIGraphicsGetImageFromCurrentImageContext(); + + UIGraphicsEndImageContext(); + } + + return resizedImage; +} + ++ (UIImage*)paintImage:(UIImage*)image withColor:(UIColor*)color +{ + UIImage *newImage; + + const CGFloat *colorComponents = CGColorGetComponents(color.CGColor); + + // Create a new image with the same size + UIGraphicsBeginImageContextWithOptions(image.size, 0, 0); + + CGContextRef gc = UIGraphicsGetCurrentContext(); + + CGRect rect = (CGRect){ .size = image.size}; + + [image drawInRect:rect + blendMode:kCGBlendModeNormal + alpha:1]; + + // Binarize the image: Transform all colors into the provided color but keep the alpha + CGContextSetBlendMode(gc, kCGBlendModeSourceIn); + CGContextSetRGBFillColor(gc, colorComponents[0], colorComponents[1], colorComponents[2], colorComponents[3]); + CGContextFillRect(gc, rect); + + // Retrieve the result into an UIImage + newImage = UIGraphicsGetImageFromCurrentImageContext(); + + UIGraphicsEndImageContext(); + + return newImage; +} + ++ (UIImageOrientation)imageOrientationForRotationAngleInDegree:(NSInteger)angle +{ + NSInteger modAngle = angle % 360; + + UIImageOrientation orientation = UIImageOrientationUp; + if (45 <= modAngle && modAngle < 135) + { + return UIImageOrientationRight; + } + else if (135 <= modAngle && modAngle < 225) + { + return UIImageOrientationDown; + } + else if (225 <= modAngle && modAngle < 315) + { + return UIImageOrientationLeft; + } + + return orientation; +} + +static NSMutableDictionary* backgroundByImageNameDict; + ++ (UIColor*)convertImageToPatternColor:(NSString*)resourceName backgroundColor:(UIColor*)backgroundColor patternSize:(CGSize)patternSize resourceSize:(CGSize)resourceSize +{ + if (!resourceName) + { + return backgroundColor; + } + + if (!backgroundByImageNameDict) + { + backgroundByImageNameDict = [[NSMutableDictionary alloc] init]; + } + + NSString* key = [NSString stringWithFormat:@"%@ %f %f", resourceName, patternSize.width, resourceSize.width]; + + UIColor* bgColor = [backgroundByImageNameDict objectForKey:key]; + + if (!bgColor) + { + UIImageView* backgroundView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, patternSize.width, patternSize.height)]; + backgroundView.backgroundColor = backgroundColor; + + CGFloat offsetX = (patternSize.width - resourceSize.width) / 2.0f; + CGFloat offsetY = (patternSize.height - resourceSize.height) / 2.0f; + + UIImageView* resourceImageView = [[UIImageView alloc] initWithFrame:CGRectMake(offsetX, offsetY, resourceSize.width, resourceSize.height)]; + resourceImageView.backgroundColor = [UIColor clearColor]; + UIImage *resImage = [UIImage imageNamed:resourceName]; + if (CGSizeEqualToSize(resImage.size, resourceSize)) + { + resourceImageView.image = resImage; + } + else + { + resourceImageView.image = [MXKTools resizeImage:resImage toSize:resourceSize]; + } + + + [backgroundView addSubview:resourceImageView]; + + // Create a "canvas" (image context) to draw in. + UIGraphicsBeginImageContextWithOptions(backgroundView.frame.size, NO, 0); + + // set to the top quality + CGContextRef context = UIGraphicsGetCurrentContext(); + CGContextSetInterpolationQuality(context, kCGInterpolationHigh); + [[backgroundView layer] renderInContext: UIGraphicsGetCurrentContext()]; + UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + + bgColor = [[UIColor alloc] initWithPatternImage:image]; + [backgroundByImageNameDict setObject:bgColor forKey:key]; + } + + return bgColor; +} + +#pragma mark - Video Conversion + ++ (UIAlertController*)videoConversionPromptForVideoAsset:(AVAsset *)videoAsset + withCompletion:(void (^)(NSString * _Nullable presetName))completion +{ + UIAlertController *compressionPrompt = [UIAlertController alertControllerWithTitle:[MatrixKitL10n attachmentSizePromptTitle] + message:[MatrixKitL10n attachmentSizePromptMessage] + preferredStyle:UIAlertControllerStyleActionSheet]; + + CGSize naturalSize = [videoAsset tracksWithMediaType:AVMediaTypeVideo].firstObject.naturalSize; + + // Provide 480p as the baseline preset. + NSString *fileSizeString = [MXKTools estimatedFileSizeStringForVideoAsset:videoAsset withPresetName:AVAssetExportPreset640x480]; + NSString *title = [MatrixKitL10n attachmentSmallWithResolution:@"480p" :fileSizeString]; + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + // Call the completion with 480p preset. + completion(AVAssetExportPreset640x480); + }]]; + + // Allow 720p when the video exceeds 480p. + if (naturalSize.height > 480) + { + NSString *fileSizeString = [MXKTools estimatedFileSizeStringForVideoAsset:videoAsset withPresetName:AVAssetExportPreset1280x720]; + NSString *title = [MatrixKitL10n attachmentMediumWithResolution:@"720p" :fileSizeString]; + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + // Call the completion with 720p preset. + completion(AVAssetExportPreset1280x720); + }]]; + } + + // Allow 1080p when the video exceeds 720p. + if (naturalSize.height > 720) + { + NSString *fileSizeString = [MXKTools estimatedFileSizeStringForVideoAsset:videoAsset withPresetName:AVAssetExportPreset1920x1080]; + NSString *title = [MatrixKitL10n attachmentLargeWithResolution:@"1080p" :fileSizeString]; + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + // Call the completion with 1080p preset. + completion(AVAssetExportPreset1920x1080); + }]]; + } + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleCancel + handler:^(UIAlertAction * action) { + // Cancelled. Call the completion with nil. + completion(nil); + }]]; + + return compressionPrompt; +} + ++ (NSString *)estimatedFileSizeStringForVideoAsset:(AVAsset *)videoAsset withPresetName:(NSString *)presetName +{ + AVAssetExportSession *exportSession = [AVAssetExportSession exportSessionWithAsset:videoAsset presetName:presetName]; + exportSession.timeRange = CMTimeRangeMake(kCMTimeZero, videoAsset.duration); + + return [MXTools fileSizeToString:exportSession.estimatedOutputFileLength]; +} + +#pragma mark - App permissions + ++ (void)checkAccessForMediaType:(NSString *)mediaType + manualChangeMessage:(NSString *)manualChangeMessage + showPopUpInViewController:(UIViewController *)viewController + completionHandler:(void (^)(BOOL))handler +{ + [AVCaptureDevice requestAccessForMediaType:mediaType completionHandler:^(BOOL granted) { + + dispatch_async(dispatch_get_main_queue(), ^{ + + if (granted) + { + handler(YES); + } + else + { + // Access not granted to mediaType + // Display manualChangeMessage + UIAlertController *alert = [UIAlertController alertControllerWithTitle:nil message:manualChangeMessage preferredStyle:UIAlertControllerStyleAlert]; + + // On iOS >= 8, add a shortcut to the app settings (This requires the shared application instance) + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication && UIApplicationOpenSettingsURLString) + { + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n settings] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + NSURL *url = [NSURL URLWithString:UIApplicationOpenSettingsURLString]; + [sharedApplication performSelector:@selector(openURL:) withObject:url]; + + // Note: it does not worth to check if the user changes the permission + // because iOS restarts the app in case of change of app privacy settings + handler(NO); + + }]]; + } + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + handler(NO); + + }]]; + + [viewController presentViewController:alert animated:YES completion:nil]; + } + + }); + }]; +} + ++ (void)checkAccessForCall:(BOOL)isVideoCall +manualChangeMessageForAudio:(NSString*)manualChangeMessageForAudio +manualChangeMessageForVideo:(NSString*)manualChangeMessageForVideo + showPopUpInViewController:(UIViewController*)viewController + completionHandler:(void (^)(BOOL granted))handler +{ + // Check first microphone permission + [MXKTools checkAccessForMediaType:AVMediaTypeAudio manualChangeMessage:manualChangeMessageForAudio showPopUpInViewController:viewController completionHandler:^(BOOL granted) { + + if (granted) + { + // Check camera permission in case of video call + if (isVideoCall) + { + [MXKTools checkAccessForMediaType:AVMediaTypeVideo manualChangeMessage:manualChangeMessageForVideo showPopUpInViewController:viewController completionHandler:^(BOOL granted) { + + handler(granted); + }]; + } + else + { + handler(YES); + } + } + else + { + handler(NO); + } + }]; +} + ++ (void)checkAccessForContacts:(NSString *)manualChangeMessage + showPopUpInViewController:(UIViewController *)viewController + completionHandler:(void (^)(BOOL granted))handler +{ + [self checkAccessForContacts:nil withManualChangeMessage:manualChangeMessage showPopUpInViewController:viewController completionHandler:handler]; +} + ++ (void)checkAccessForContacts:(NSString *)manualChangeTitle + withManualChangeMessage:(NSString *)manualChangeMessage + showPopUpInViewController:(UIViewController *)viewController + completionHandler:(void (^)(BOOL granted))handler +{ + // Check if the application is allowed to list the contacts + CNAuthorizationStatus authStatus = [CNContactStore authorizationStatusForEntityType:CNEntityTypeContacts]; + if (authStatus == CNAuthorizationStatusAuthorized) + { + handler(YES); + } + else if (authStatus == CNAuthorizationStatusNotDetermined) + { + // Request address book access + [[CNContactStore new] requestAccessForEntityType:CNEntityTypeContacts completionHandler:^(BOOL granted, NSError * _Nullable error) { + + [MXSDKOptions.sharedInstance.analyticsDelegate trackValue:[NSNumber numberWithBool:granted] + category:MXKAnalyticsCategoryContacts + name:MXKAnalyticsNameContactsAccessGranted]; + + dispatch_async(dispatch_get_main_queue(), ^{ + + handler(granted); + + }); + }]; + } + else if (authStatus == CNAuthorizationStatusDenied && viewController && manualChangeMessage) + { + // Access not granted to the local contacts + // Display manualChangeMessage + UIAlertController *alert = [UIAlertController alertControllerWithTitle:manualChangeTitle message:manualChangeMessage preferredStyle:UIAlertControllerStyleAlert]; + + [alert addAction:[UIAlertAction actionWithTitle:MatrixKitL10n.cancel + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + handler(NO); + + }]]; + + // Add a shortcut to the app settings (This requires the shared application instance) + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication) + { + UIAlertAction *settingsAction = [UIAlertAction actionWithTitle:MatrixKitL10n.settings + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + [MXKAppSettings standardAppSettings].syncLocalContactsPermissionOpenedSystemSettings = YES; + // Wait for the setting to be saved as the app could be killed imminently. + [[NSUserDefaults standardUserDefaults] synchronize]; + + NSURL *url = [NSURL URLWithString:UIApplicationOpenSettingsURLString]; + [sharedApplication performSelector:@selector(openURL:) withObject:url]; + + // Note: it does not worth to check if the user changes the permission + // because iOS restarts the app in case of change of app privacy settings + handler(NO); + }]; + + [alert addAction: settingsAction]; + alert.preferredAction = settingsAction; + } + + [viewController presentViewController:alert animated:YES completion:nil]; + } + else + { + handler(NO); + } +} + +#pragma mark - HTML processing + ++ (NSAttributedString*)removeDTCoreTextArtifacts:(NSAttributedString*)attributedString +{ + NSMutableAttributedString *mutableAttributedString = [[NSMutableAttributedString alloc] initWithAttributedString:attributedString]; + + // DTCoreText adds a newline at the end of plain text ( https://github.com/Cocoanetics/DTCoreText/issues/779 ) + // or after a blockquote section. + // Trim trailing whitespace and newlines in the string content + while ([mutableAttributedString.string hasSuffixCharacterFromSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]) + { + [mutableAttributedString deleteCharactersInRange:NSMakeRange(mutableAttributedString.length - 1, 1)]; + } + + // New lines may have also been introduced by the paragraph style + // Make sure the last paragraph style has no spacing + [mutableAttributedString enumerateAttributesInRange:NSMakeRange(0, mutableAttributedString.length) options:(NSAttributedStringEnumerationReverse) usingBlock:^(NSDictionary *attrs, NSRange range, BOOL *stop) { + + if (attrs[NSParagraphStyleAttributeName]) + { + NSString *subString = [mutableAttributedString.string substringWithRange:range]; + NSArray *components = [subString componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]]; + + NSMutableDictionary *updatedAttrs = [NSMutableDictionary dictionaryWithDictionary:attrs]; + NSMutableParagraphStyle *paragraphStyle = [updatedAttrs[NSParagraphStyleAttributeName] mutableCopy]; + paragraphStyle.paragraphSpacing = 0; + updatedAttrs[NSParagraphStyleAttributeName] = paragraphStyle; + + if (components.count > 1) + { + NSString *lastComponent = components.lastObject; + + NSRange range2 = NSMakeRange(range.location, range.length - lastComponent.length); + [mutableAttributedString setAttributes:attrs range:range2]; + + range2 = NSMakeRange(range2.location + range2.length, lastComponent.length); + [mutableAttributedString setAttributes:updatedAttrs range:range2]; + } + else + { + [mutableAttributedString setAttributes:updatedAttrs range:range]; + } + } + + // Check only the last paragraph + *stop = YES; + }]; + + // Image rendering failed on an exception until we replace the DTImageTextAttachments with a simple NSTextAttachment subclass + // (thanks to https://github.com/Cocoanetics/DTCoreText/issues/863). + [mutableAttributedString enumerateAttribute:NSAttachmentAttributeName + inRange:NSMakeRange(0, mutableAttributedString.length) + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + + if ([value isKindOfClass:DTImageTextAttachment.class]) + { + DTImageTextAttachment *attachment = (DTImageTextAttachment*)value; + NSTextAttachment *textAttachment = [[NSTextAttachment alloc] init]; + if (attachment.image) + { + textAttachment.image = attachment.image; + + CGRect frame = textAttachment.bounds; + frame.size = attachment.displaySize; + textAttachment.bounds = frame; + } + // Note we remove here attachment without image. + NSAttributedString *attrStringWithImage = [NSAttributedString attributedStringWithAttachment:textAttachment]; + [mutableAttributedString replaceCharactersInRange:range withAttributedString:attrStringWithImage]; + } + }]; + + return mutableAttributedString; +} + ++ (NSAttributedString*)createLinksInAttributedString:(NSAttributedString*)attributedString forEnabledMatrixIds:(NSInteger)enabledMatrixIdsBitMask +{ + if (!attributedString) + { + return nil; + } + + NSMutableAttributedString *postRenderAttributedString; + + // If enabled, make user id clickable + if (enabledMatrixIdsBitMask & MXKTOOLS_USER_IDENTIFIER_BITWISE) + { + [MXKTools createLinksInAttributedString:attributedString matchingRegex:userIdRegex withWorkingAttributedString:&postRenderAttributedString]; + } + + // If enabled, make room id clickable + if (enabledMatrixIdsBitMask & MXKTOOLS_ROOM_IDENTIFIER_BITWISE) + { + [MXKTools createLinksInAttributedString:attributedString matchingRegex:roomIdRegex withWorkingAttributedString:&postRenderAttributedString]; + } + + // If enabled, make room alias clickable + if (enabledMatrixIdsBitMask & MXKTOOLS_ROOM_ALIAS_BITWISE) + { + [MXKTools createLinksInAttributedString:attributedString matchingRegex:roomAliasRegex withWorkingAttributedString:&postRenderAttributedString]; + } + + // If enabled, make event id clickable + if (enabledMatrixIdsBitMask & MXKTOOLS_EVENT_IDENTIFIER_BITWISE) + { + [MXKTools createLinksInAttributedString:attributedString matchingRegex:eventIdRegex withWorkingAttributedString:&postRenderAttributedString]; + } + + // If enabled, make group id clickable + if (enabledMatrixIdsBitMask & MXKTOOLS_GROUP_IDENTIFIER_BITWISE) + { + [MXKTools createLinksInAttributedString:attributedString matchingRegex:groupIdRegex withWorkingAttributedString:&postRenderAttributedString]; + } + + return postRenderAttributedString ? postRenderAttributedString : attributedString; +} + ++ (void)createLinksInAttributedString:(NSAttributedString*)attributedString matchingRegex:(NSRegularExpression*)regex withWorkingAttributedString:(NSMutableAttributedString* __autoreleasing *)mutableAttributedString +{ + __block NSArray *linkMatches; + + // Enumerate each string matching the regex + [regex enumerateMatchesInString:attributedString.string options:0 range:NSMakeRange(0, attributedString.length) usingBlock:^(NSTextCheckingResult *match, NSMatchingFlags flags, BOOL *stop) { + + // Do not create a link if there is already one on the found match + __block BOOL hasAlreadyLink = NO; + [attributedString enumerateAttributesInRange:match.range options:0 usingBlock:^(NSDictionary * _Nonnull attrs, NSRange range, BOOL * _Nonnull stop) { + + if (attrs[NSLinkAttributeName]) + { + hasAlreadyLink = YES; + *stop = YES; + } + }]; + + // Do not create a link if the match is part of an http link. + // The http link will be automatically generated by the UI afterwards. + // So, do not break it now by adding a link on a subset of this http link. + if (!hasAlreadyLink) + { + if (!linkMatches) + { + // Search for the links in the string only once + // Do not use NSDataDetector with NSTextCheckingTypeLink because is not able to + // manage URLs with 2 hashes like "https://matrix.to/#/#matrix:matrix.org" + // Such URL is not valid but web browsers can open them and users C+P them... + // NSDataDetector does not support it but UITextView and UIDataDetectorTypeLink + // detect them when they are displayed. So let the UI create the link at display. + linkMatches = [httpLinksRegex matchesInString:attributedString.string options:0 range:NSMakeRange(0, attributedString.length)]; + } + + for (NSTextCheckingResult *linkMatch in linkMatches) + { + // If the match is fully in the link, skip it + if (NSIntersectionRange(match.range, linkMatch.range).length == match.range.length) + { + hasAlreadyLink = YES; + break; + } + } + } + + if (!hasAlreadyLink) + { + // Create the output string only if it is necessary because attributed strings cost CPU + if (!*mutableAttributedString) + { + *mutableAttributedString = [[NSMutableAttributedString alloc] initWithAttributedString:attributedString]; + } + + // Make the link clickable + // Caution: We need here to escape the non-ASCII characters (like '#' in room alias) + // to convert the link into a legal URL string. + NSString *link = [attributedString.string substringWithRange:match.range]; + link = [link stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding]; + [*mutableAttributedString addAttribute:NSLinkAttributeName value:link range:match.range]; + } + }]; +} + +#pragma mark - HTML processing - blockquote display handling + ++ (NSString*)cssToMarkBlockquotes +{ + return [NSString stringWithFormat:@"blockquote {background: #%lX; display: block;}", (unsigned long)[MXKTools rgbValueWithColor:kMXKToolsBlockquoteMarkColor]]; +} + ++ (NSAttributedString*)removeMarkedBlockquotesArtifacts:(NSAttributedString*)attributedString +{ + NSMutableAttributedString *mutableAttributedString = [[NSMutableAttributedString alloc] initWithAttributedString:attributedString]; + + // Enumerate all sections marked thanks to `cssToMarkBlockquotes` + // and apply our own attribute instead. + + // According to blockquotes in the string, DTCoreText can apply 2 policies: + // - define a `DTTextBlocksAttribute` attribute on a

block + // - or, just define a `NSBackgroundColorAttributeName` attribute + + // `DTTextBlocksAttribute` case + [attributedString enumerateAttribute:DTTextBlocksAttribute + inRange:NSMakeRange(0, attributedString.length) + options:0 + usingBlock:^(id _Nullable value, NSRange range, BOOL * _Nonnull stop) + { + if ([value isKindOfClass:NSArray.class]) + { + NSArray *array = (NSArray*)value; + if (array.count > 0 && [array[0] isKindOfClass:DTTextBlock.class]) + { + DTTextBlock *dtTextBlock = (DTTextBlock *)array[0]; + if ([dtTextBlock.backgroundColor isEqual:kMXKToolsBlockquoteMarkColor]) + { + // Apply our own attribute + [mutableAttributedString addAttribute:kMXKToolsBlockquoteMarkAttribute value:@(YES) range:range]; + + // Fix a boring behaviour where DTCoreText add a " " string before a string corresponding + // to an HTML blockquote. This " " string has ParagraphStyle.headIndent = 0 which breaks + // the blockquote block indentation + if (range.location > 0) + { + NSRange prevRange = NSMakeRange(range.location - 1, 1); + + NSRange effectiveRange; + NSParagraphStyle *paragraphStyle = [attributedString attribute:NSParagraphStyleAttributeName + atIndex:prevRange.location + effectiveRange:&effectiveRange]; + + // Check if this is the " " string + if (paragraphStyle && effectiveRange.length == 1 && paragraphStyle.firstLineHeadIndent != 25) + { + // Fix its paragraph style + NSMutableParagraphStyle *newParagraphStyle = [paragraphStyle mutableCopy]; + newParagraphStyle.firstLineHeadIndent = 25.0; + newParagraphStyle.headIndent = 25.0; + + [mutableAttributedString addAttribute:NSParagraphStyleAttributeName value:newParagraphStyle range:prevRange]; + } + } + } + } + } + }]; + + // `NSBackgroundColorAttributeName` case + [mutableAttributedString enumerateAttribute:NSBackgroundColorAttributeName + inRange:NSMakeRange(0, mutableAttributedString.length) + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) + { + + if ([value isKindOfClass:UIColor.class] && [(UIColor*)value isEqual:[UIColor magentaColor]]) + { + // Remove the marked background + [mutableAttributedString removeAttribute:NSBackgroundColorAttributeName range:range]; + + // And apply our own attribute + [mutableAttributedString addAttribute:kMXKToolsBlockquoteMarkAttribute value:@(YES) range:range]; + } + }]; + + return mutableAttributedString; +} + ++ (void)enumerateMarkedBlockquotesInAttributedString:(NSAttributedString*)attributedString usingBlock:(void (^)(NSRange range, BOOL *stop))block +{ + [attributedString enumerateAttribute:kMXKToolsBlockquoteMarkAttribute + inRange:NSMakeRange(0, attributedString.length) + options:0 + usingBlock:^(id _Nullable value, NSRange range, BOOL * _Nonnull stop) + { + if ([value isKindOfClass:NSNumber.class] && ((NSNumber*)value).boolValue) + { + block(range, stop); + } + }]; +} + +#pragma mark - Push + +// Trim push token before printing it in logs ++ (NSString*)logForPushToken:(NSData*)pushToken +{ + NSUInteger len = ((pushToken.length > 8) ? 8 : pushToken.length / 2); + return [NSString stringWithFormat:@"%@...", [pushToken subdataWithRange:NSMakeRange(0, len)]]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Utils/MXKUTI.swift b/Riot/Modules/MatrixKit/Utils/MXKUTI.swift new file mode 100644 index 000000000..911beec65 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKUTI.swift @@ -0,0 +1,203 @@ +/* + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import ImageIO +import MobileCoreServices + +// We do not use the SwiftUTI pod anymore +// The library is embedded in MatrixKit. See Libs/SwiftUTI/README.md for more details +//import SwiftUTI + +/// MXKUTI represents a Universal Type Identifier (e.g. kUTTypePNG). +/// See https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/understanding_utis/understand_utis_conc/understand_utis_conc.html#//apple_ref/doc/uid/TP40001319-CH202-SW5 for more information. +/// MXKUTI wraps UTI class from SwiftUTI library (https://github.com/mkeiser/SwiftUTI) to make it available for Objective-C. +@objcMembers +open class MXKUTI: NSObject, RawRepresentable { + + public typealias RawValue = String + + // MARK: - Properties + + // MARK: Private + + private let utiWrapper: UTI + + // MARK: Public + + /// UTI string + public var rawValue: String { + return utiWrapper.rawValue + } + + /// Return associated prefered file extension (e.g. "png"). + public var fileExtension: String? { + return utiWrapper.fileExtension + } + + /// Return associated prefered mime-type (e.g. "image/png"). + public var mimeType: String? { + return utiWrapper.mimeType + } + + // MARK: - Setup + + // MARK: Private + + private init(utiWrapper: UTI) { + self.utiWrapper = utiWrapper + super.init() + } + + // MARK: Public + + /// Initialize with UTI String. + /// Note: Although this initializer is marked as failable, due to RawRepresentable conformity, it cannot fail. + /// + /// - Parameter rawValue: UTI String (e.g. "public.png"). + public required init?(rawValue: String) { + let utiWrapper = UTI(rawValue: rawValue) + self.utiWrapper = utiWrapper + super.init() + } + + /// Initialize with UTI CFString. + /// + /// - Parameter cfRawValue: UTI CFString (e.g. kUTTypePNG). + public convenience init?(cfRawValue: CFString) { + self.init(rawValue: cfRawValue as String) + } + + /// Initialize with file extension. + /// + /// - Parameter fileExtension: A file extesion (e.g. "png"). + public convenience init(fileExtension: String) { + let utiWrapper = UTI(withExtension: fileExtension) + self.init(utiWrapper: utiWrapper) + } + + /// Initialize with MIME type. + /// + /// - Parameter mimeType: A MIME type (e.g. "image/png"). + public convenience init?(mimeType: String) { + let utiWrapper = UTI(withMimeType: mimeType) + self.init(utiWrapper: utiWrapper) + } + + /// Check current UTI conformance with another UTI. + /// + /// - Parameter otherUTI: UTI which to conform with. + /// - Returns: true if self conforms to other UTI. + public func conforms(to otherUTI: MXKUTI) -> Bool { + return self.utiWrapper.conforms(to: otherUTI.utiWrapper) + } + + /// Check whether the current UTI conforms to any UTIs within an array. + /// + /// - Parameter otherUTIs: UTI which to conform with. + /// - Returns: true if self conforms to any of the other UTIs. + public func conformsToAny(of otherUTIs: [MXKUTI]) -> Bool { + for uti in otherUTIs { + if conforms(to: uti) { + return true + } + } + + return false + } +} + +// MARK: - Other convenients initializers +extension MXKUTI { + + /// Initialize with image data. + /// + /// - Parameter imageData: Image data. + convenience init?(imageData: Data) { + guard let imageSource = CGImageSourceCreateWithData(imageData as CFData, nil), + let uti = CGImageSourceGetType(imageSource) else { + return nil + } + self.init(rawValue: uti as String) + } + + /// Initialize with local file URL. + /// This method is currently applicable only to URLs for file system resources. + /// + /// - Parameters: + /// - localFileURL: Local file URL. + /// - loadResourceValues: Indicate true to prefetch `typeIdentifierKey` URLResourceKey + convenience init?(localFileURL: URL, loadResourceValues: Bool = true) { + if loadResourceValues, + let _ = try? FileManager.default.contentsOfDirectory(at: localFileURL.deletingLastPathComponent(), includingPropertiesForKeys: [.typeIdentifierKey], options: []), + let uti = try? localFileURL.resourceValues(forKeys: [.typeIdentifierKey]).typeIdentifier { + self.init(rawValue: uti) + } else if localFileURL.pathExtension.isEmpty == false { + let fileExtension = localFileURL.pathExtension + self.init(fileExtension: fileExtension) + } else { + return nil + } + } + + public convenience init?(localFileURL: URL) { + self.init(localFileURL: localFileURL, loadResourceValues: true) + } +} + +// MARK: - Convenients conformance UTIs methods +extension MXKUTI { + public var isImage: Bool { + return self.conforms(to: MXKUTI.image) + } + + public var isVideo: Bool { + return self.conforms(to: MXKUTI.movie) + } + + public var isFile: Bool { + return self.conforms(to: MXKUTI.data) + } +} + +// MARK: - Some system defined UTIs +extension MXKUTI { + public static let data = MXKUTI(cfRawValue: kUTTypeData)! + public static let text = MXKUTI(cfRawValue: kUTTypeText)! + public static let audio = MXKUTI(cfRawValue: kUTTypeAudio)! + public static let video = MXKUTI(cfRawValue: kUTTypeVideo)! + public static let movie = MXKUTI(cfRawValue: kUTTypeMovie)! + public static let image = MXKUTI(cfRawValue: kUTTypeImage)! + public static let png = MXKUTI(cfRawValue: kUTTypePNG)! + public static let jpeg = MXKUTI(cfRawValue: kUTTypeJPEG)! + public static let svg = MXKUTI(cfRawValue: kUTTypeScalableVectorGraphics)! + public static let url = MXKUTI(cfRawValue: kUTTypeURL)! + public static let fileUrl = MXKUTI(cfRawValue: kUTTypeFileURL)! + public static let html = MXKUTI(cfRawValue: kUTTypeHTML)! + public static let xml = MXKUTI(cfRawValue: kUTTypeXML)! +} + +// MARK: - Convenience static methods +extension MXKUTI { + + public static func mimeType(from fileExtension: String) -> String? { + return MXKUTI(fileExtension: fileExtension).mimeType + } + + public static func fileExtension(from mimeType: String) -> String? { + return MXKUTI(mimeType: mimeType)?.fileExtension + } +} diff --git a/Riot/Modules/MatrixKit/Utils/MXKVideoThumbnailGenerator.swift b/Riot/Modules/MatrixKit/Utils/MXKVideoThumbnailGenerator.swift new file mode 100644 index 000000000..9f999294b --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKVideoThumbnailGenerator.swift @@ -0,0 +1,76 @@ +/* + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import UIKit +import AVFoundation + +/// MXKVideoThumbnailGenerator is a utility class to generate a thumbnail image from a video file. +@objcMembers +public class MXKVideoThumbnailGenerator: NSObject { + + public static let shared = MXKVideoThumbnailGenerator() + + // MARK - Public + + /// Generate thumbnail image from a video URL. + /// Note: Do not make `maximumSize` optional with default nil value for Objective-C compatibility. + /// + /// - Parameters: + /// - url: Video URL. + /// - maximumSize: Maximum dimension for generated thumbnail image. + /// - Returns: Thumbnail image or nil. + public func generateThumbnail(from url: URL, with maximumSize: CGSize) -> UIImage? { + let finalSize: CGSize? = maximumSize != .zero ? maximumSize : nil + return self.generateThumbnail(from: url, with: finalSize) + } + + /// Generate thumbnail image from a video URL. + /// + /// - Parameter url: Video URL. + /// - Returns: Thumbnail image or nil. + public func generateThumbnail(from url: URL) -> UIImage? { + return generateThumbnail(from: url, with: nil) + } + + // MARK - Private + + /// Generate thumbnail image from a video URL. + /// + /// - Parameters: + /// - url: Video URL. + /// - maximumSize: Maximum dimension for generated thumbnail image or nil to keep video dimension. + /// - Returns: Thumbnail image or nil. + private func generateThumbnail(from url: URL, with maximumSize: CGSize?) -> UIImage? { + let thumbnailImage: UIImage? + + let asset = AVAsset(url: url) + let assetImageGenerator = AVAssetImageGenerator(asset: asset) + assetImageGenerator.appliesPreferredTrackTransform = true + if let maximumSize = maximumSize { + assetImageGenerator.maximumSize = maximumSize + } + do { + // Generate thumbnail from first video image + let image = try assetImageGenerator.copyCGImage(at: .zero, actualTime: nil) + thumbnailImage = UIImage(cgImage: image) + } catch { + MXLog.error(error.localizedDescription) + thumbnailImage = nil + } + + return thumbnailImage + } +} diff --git a/Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.h b/Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.h new file mode 100644 index 000000000..466872958 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.h @@ -0,0 +1,43 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +#import "MXKImageView.h" +#import "MXKAccount.h" + +/** + MXKAccountTableViewCell instance is a table view cell used to display a matrix user. + */ +@interface MXKAccountTableViewCell : MXKTableViewCell + +/** + The displayed account + */ +@property (nonatomic) MXKAccount* mxAccount; + +/** + The default account picture displayed when no picture is defined. + */ +@property (nonatomic) UIImage *picturePlaceholder; + +@property (strong, nonatomic) IBOutlet MXKImageView* accountPicture; + +@property (strong, nonatomic) IBOutlet UILabel* accountDisplayName; + +@property (strong, nonatomic) IBOutlet UISwitch* accountSwitchToggle; + +@end diff --git a/Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.m b/Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.m new file mode 100644 index 000000000..c47f5e4e5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.m @@ -0,0 +1,96 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAccountTableViewCell.h" + +@import MatrixSDK.MXMediaManager; + +#import "NSBundle+MatrixKit.h" + +@implementation MXKAccountTableViewCell + +- (void)customizeTableViewCellRendering +{ + [super customizeTableViewCellRendering]; + + self.accountPicture.defaultBackgroundColor = [UIColor clearColor]; +} + +- (void)setMxAccount:(MXKAccount *)mxAccount +{ + UIColor *presenceColor = nil; + + _accountDisplayName.text = mxAccount.fullDisplayName; + + if (mxAccount.mxSession) + { + _accountPicture.mediaFolder = kMXMediaManagerAvatarThumbnailFolder; + _accountPicture.enableInMemoryCache = YES; + [_accountPicture setImageURI:mxAccount.userAvatarUrl + withType:nil + andImageOrientation:UIImageOrientationUp + toFitViewSize:_accountPicture.frame.size + withMethod:MXThumbnailingMethodCrop + previewImage:self.picturePlaceholder + mediaManager:mxAccount.mxSession.mediaManager]; + + presenceColor = [MXKAccount presenceColor:mxAccount.userPresence]; + } + else + { + _accountPicture.image = self.picturePlaceholder; + } + + if (presenceColor) + { + _accountPicture.layer.borderWidth = 2; + _accountPicture.layer.borderColor = presenceColor.CGColor; + } + else + { + _accountPicture.layer.borderWidth = 0; + } + + _accountSwitchToggle.on = !mxAccount.disabled; + if (mxAccount.disabled) + { + _accountDisplayName.textColor = [UIColor lightGrayColor]; + } + else + { + _accountDisplayName.textColor = [UIColor blackColor]; + } + + _mxAccount = mxAccount; +} + +- (UIImage*)picturePlaceholder +{ + return [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"default-profile"]; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + // Round image view + [_accountPicture.layer setCornerRadius:_accountPicture.frame.size.width / 2]; + _accountPicture.clipsToBounds = YES; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.xib b/Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.xib new file mode 100644 index 000000000..f5696126d --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.xib @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.h b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.h new file mode 100644 index 000000000..d5406f4d5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.h @@ -0,0 +1,41 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAuthInputsView.h" + +@interface MXKAuthInputsEmailCodeBasedView : MXKAuthInputsView + +/** + The input text field related to user id or user login. + */ +@property (weak, nonatomic) IBOutlet UITextField *userLoginTextField; + +/** + The input text field used to fill an email or the related token. + */ +@property (weak, nonatomic) IBOutlet UITextField *emailAndTokenTextField; + +/** + Label used to prompt user to fill the email token. + */ +@property (weak, nonatomic) IBOutlet UILabel *promptEmailTokenLabel; + +/** + The text field related to the display name. This item is displayed in case of registration. + */ +@property (weak, nonatomic) IBOutlet UITextField *displayNameTextField; + +@end diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.m b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.m new file mode 100644 index 000000000..f43aada0d --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.m @@ -0,0 +1,202 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAuthInputsEmailCodeBasedView.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@implementation MXKAuthInputsEmailCodeBasedView + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKAuthInputsEmailCodeBasedView class]) + bundle:[NSBundle bundleForClass:[MXKAuthInputsEmailCodeBasedView class]]]; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + _userLoginTextField.placeholder = [MatrixKitL10n loginUserIdPlaceholder]; + _emailAndTokenTextField.placeholder = [MatrixKitL10n loginEmailPlaceholder]; + _promptEmailTokenLabel.text = [MatrixKitL10n loginPromptEmailToken]; + + _displayNameTextField.placeholder = [MatrixKitL10n loginDisplayNamePlaceholder]; +} + +#pragma mark - + +- (BOOL)setAuthSession:(MXAuthenticationSession *)authSession withAuthType:(MXKAuthenticationType)authType; +{ + if (type == MXKAuthenticationTypeLogin || type == MXKAuthenticationTypeRegister) + { + // Validate first the provided session + MXAuthenticationSession *validSession = [self validateAuthenticationSession:authSession]; + + if ([super setAuthSession:validSession withAuthType:authType]) + { + // Set initial layout + self.userLoginTextField.hidden = NO; + self.promptEmailTokenLabel.hidden = YES; + + if (type == MXKAuthenticationTypeLogin) + { + self.emailAndTokenTextField.returnKeyType = UIReturnKeyDone; + self.displayNameTextField.hidden = YES; + + self.viewHeightConstraint.constant = self.displayNameTextField.frame.origin.y; + } + else + { + self.emailAndTokenTextField.returnKeyType = UIReturnKeyNext; + self.displayNameTextField.hidden = NO; + + self.viewHeightConstraint.constant = 122; + } + + return YES; + } + } + + return NO; +} + +- (NSString*)validateParameters +{ + NSString *errorMsg = [super validateParameters]; + + if (!errorMsg) + { + if (!self.areAllRequiredFieldsSet) + { + errorMsg = [MatrixKitL10n loginInvalidParam]; + } + } + + return errorMsg; +} + +- (BOOL)areAllRequiredFieldsSet +{ + BOOL ret = [super areAllRequiredFieldsSet]; + + // Check required fields //FIXME what are required fields in this authentication flow? + ret = (ret && self.userLoginTextField.text.length && self.emailAndTokenTextField.text.length); + + return ret; +} + +- (void)dismissKeyboard +{ + [self.userLoginTextField resignFirstResponder]; + [self.emailAndTokenTextField resignFirstResponder]; + [self.displayNameTextField resignFirstResponder]; + + [super dismissKeyboard]; +} + +- (void)nextStep +{ + // Consider here the email token has been requested with success + [super nextStep]; + + self.userLoginTextField.hidden = YES; + self.promptEmailTokenLabel.hidden = NO; + self.emailAndTokenTextField.placeholder = nil; + self.emailAndTokenTextField.returnKeyType = UIReturnKeyDone; + + self.displayNameTextField.hidden = YES; +} + +- (NSString*)userId +{ + return self.userLoginTextField.text; +} + +#pragma mark UITextField delegate + +- (BOOL)textFieldShouldReturn:(UITextField*)textField +{ + if (textField.returnKeyType == UIReturnKeyDone) + { + // "Done" key has been pressed + [textField resignFirstResponder]; + + // Launch authentication now + [self.delegate authInputsViewDidPressDoneKey:self]; + } + else + { + //"Next" key has been pressed + if (textField == self.userLoginTextField) + { + [self.emailAndTokenTextField becomeFirstResponder]; + } + else if (textField == self.emailAndTokenTextField) + { + [self.displayNameTextField becomeFirstResponder]; + } + } + + return YES; +} + +#pragma mark - + +- (MXAuthenticationSession*)validateAuthenticationSession:(MXAuthenticationSession*)authSession +{ + // Check whether at least one of the listed flow is supported. + BOOL isSupported = NO; + + for (MXLoginFlow *loginFlow in authSession.flows) + { + // Check whether flow type is defined + if ([loginFlow.type isEqualToString:kMXLoginFlowTypeEmailCode]) + { + isSupported = YES; + break; + } + else if (loginFlow.stages.count == 1 && [loginFlow.stages.firstObject isEqualToString:kMXLoginFlowTypeEmailCode]) + { + isSupported = YES; + break; + } + } + + if (isSupported) + { + if (authSession.flows.count == 1) + { + // Return the original session. + return authSession; + } + else + { + // Keep only the supported flow. + MXAuthenticationSession *updatedAuthSession = [[MXAuthenticationSession alloc] init]; + updatedAuthSession.session = authSession.session; + updatedAuthSession.params = authSession.params; + updatedAuthSession.flows = @[[MXLoginFlow modelFromJSON:@{@"stages":@[kMXLoginFlowTypeEmailCode]}]]; + return updatedAuthSession; + } + } + + return nil; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.xib b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.xib new file mode 100644 index 000000000..e70ee341a --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.xib @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.h b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.h new file mode 100644 index 000000000..d5d62ace6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.h @@ -0,0 +1,46 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAuthInputsView.h" + +@interface MXKAuthInputsPasswordBasedView : MXKAuthInputsView + +/** + The input text field related to user id or user login. + */ +@property (weak, nonatomic) IBOutlet UITextField *userLoginTextField; + +/** + The input text field used to fill the password. + */ +@property (weak, nonatomic) IBOutlet UITextField *passWordTextField; + +/** + The input text field used to fill an email. This item is optional, it is added in case of registration. + */ +@property (weak, nonatomic) IBOutlet UITextField *emailTextField; + +/** + Label used to display email field information. + */ +@property (weak, nonatomic) IBOutlet UILabel *emailInfoLabel; + +/** + The text field related to the display name. This item is displayed in case of registration. + */ +@property (weak, nonatomic) IBOutlet UITextField *displayNameTextField; + +@end diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.m b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.m new file mode 100644 index 000000000..237a7a1cc --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.m @@ -0,0 +1,253 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAuthInputsPasswordBasedView.h" + +#import "MXKTools.h" + +#import "MXKAppSettings.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@implementation MXKAuthInputsPasswordBasedView + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKAuthInputsPasswordBasedView class]) + bundle:[NSBundle bundleForClass:[MXKAuthInputsPasswordBasedView class]]]; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + _userLoginTextField.placeholder = [MatrixKitL10n loginUserIdPlaceholder]; + _passWordTextField.placeholder = [MatrixKitL10n loginPasswordPlaceholder]; + _emailTextField.placeholder = [NSString stringWithFormat:@"%@ (%@)", [MatrixKitL10n loginEmailPlaceholder], [MatrixKitL10n loginOptionalField]]; + _emailInfoLabel.text = [MatrixKitL10n loginEmailInfo]; + + _displayNameTextField.placeholder = [MatrixKitL10n loginDisplayNamePlaceholder]; +} + +#pragma mark - + +- (BOOL)setAuthSession:(MXAuthenticationSession *)authSession withAuthType:(MXKAuthenticationType)authType; +{ + if (type == MXKAuthenticationTypeLogin || type == MXKAuthenticationTypeRegister) + { + // Validate first the provided session + MXAuthenticationSession *validSession = [self validateAuthenticationSession:authSession]; + + if ([super setAuthSession:validSession withAuthType:authType]) + { + if (type == MXKAuthenticationTypeLogin) + { + self.passWordTextField.returnKeyType = UIReturnKeyDone; + self.emailTextField.hidden = YES; + self.emailInfoLabel.hidden = YES; + self.displayNameTextField.hidden = YES; + + self.viewHeightConstraint.constant = self.displayNameTextField.frame.origin.y; + } + else + { + self.passWordTextField.returnKeyType = UIReturnKeyNext; + self.emailTextField.hidden = NO; + self.emailInfoLabel.hidden = NO; + self.displayNameTextField.hidden = NO; + + self.viewHeightConstraint.constant = 179; + } + + return YES; + } + } + + return NO; +} + +- (NSString*)validateParameters +{ + NSString *errorMsg = [super validateParameters]; + + if (!errorMsg) + { + // Check user login and pass fields + if (!self.areAllRequiredFieldsSet) + { + errorMsg = [MatrixKitL10n loginInvalidParam]; + } + } + + return errorMsg; +} + +- (void)prepareParameters:(void (^)(NSDictionary *parameters, NSError *error))callback +{ + if (callback) + { + // Sanity check on required fields + if (!self.areAllRequiredFieldsSet) + { + callback(nil, [NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:[MatrixKitL10n loginInvalidParam]}]); + return; + } + + // Retrieve the user login and check whether it is an email or a username. + // TODO: Update the UI view to support the login based on a mobile phone number. + NSString *user = self.userLoginTextField.text; + user = [user stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + BOOL isEmailAddress = [MXTools isEmailAddress:user]; + + NSDictionary *parameters; + + if (isEmailAddress) + { + parameters = @{ + @"type": kMXLoginFlowTypePassword, + @"identifier": @{ + @"type": kMXLoginIdentifierTypeThirdParty, + @"medium": kMX3PIDMediumEmail, + @"address": user + }, + @"password": self.passWordTextField.text + }; + } + else + { + parameters = @{ + @"type": kMXLoginFlowTypePassword, + @"identifier": @{ + @"type": kMXLoginIdentifierTypeUser, + @"user": user + }, + @"password": self.passWordTextField.text + }; + } + + callback(parameters, nil); + } +} + +- (BOOL)areAllRequiredFieldsSet +{ + BOOL ret = [super areAllRequiredFieldsSet]; + + // Check user login and pass fields + ret = (ret && self.userLoginTextField.text.length && self.passWordTextField.text.length); + + return ret; +} + +- (void)dismissKeyboard +{ + [self.userLoginTextField resignFirstResponder]; + [self.passWordTextField resignFirstResponder]; + [self.emailTextField resignFirstResponder]; + [self.displayNameTextField resignFirstResponder]; + + [super dismissKeyboard]; +} + +- (NSString*)userId +{ + return self.userLoginTextField.text; +} + +- (NSString*)password +{ + return self.passWordTextField.text; +} + +#pragma mark UITextField delegate + +- (BOOL)textFieldShouldReturn:(UITextField*)textField +{ + if (textField.returnKeyType == UIReturnKeyDone) + { + // "Done" key has been pressed + [textField resignFirstResponder]; + + // Launch authentication now + [self.delegate authInputsViewDidPressDoneKey:self]; + } + else + { + //"Next" key has been pressed + if (textField == self.userLoginTextField) + { + [self.passWordTextField becomeFirstResponder]; + } + else if (textField == self.passWordTextField) + { + [self.displayNameTextField becomeFirstResponder]; + } + else if (textField == self.displayNameTextField) + { + [self.emailTextField becomeFirstResponder]; + } + } + + return YES; +} + +#pragma mark - + +- (MXAuthenticationSession*)validateAuthenticationSession:(MXAuthenticationSession*)authSession +{ + // Check whether at least one of the listed flow is supported. + BOOL isSupported = NO; + + for (MXLoginFlow *loginFlow in authSession.flows) + { + // Check whether flow type is defined + if ([loginFlow.type isEqualToString:kMXLoginFlowTypePassword]) + { + isSupported = YES; + break; + } + else if (loginFlow.stages.count == 1 && [loginFlow.stages.firstObject isEqualToString:kMXLoginFlowTypePassword]) + { + isSupported = YES; + break; + } + } + + if (isSupported) + { + if (authSession.flows.count == 1) + { + // Return the original session. + return authSession; + } + else + { + // Keep only the supported flow. + MXAuthenticationSession *updatedAuthSession = [[MXAuthenticationSession alloc] init]; + updatedAuthSession.session = authSession.session; + updatedAuthSession.params = authSession.params; + updatedAuthSession.flows = @[[MXLoginFlow modelFromJSON:@{@"stages":@[kMXLoginFlowTypePassword]}]]; + return updatedAuthSession; + } + } + + return nil; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.xib b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.xib new file mode 100644 index 000000000..f19346925 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.xib @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsView.h b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsView.h new file mode 100644 index 000000000..c71b022ae --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsView.h @@ -0,0 +1,242 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKView.h" + +extern NSString *const MXKAuthErrorDomain; + +/** + Authentication types + */ +typedef enum { + /** + Type used to sign up. + */ + MXKAuthenticationTypeRegister, + /** + Type used to sign in. + */ + MXKAuthenticationTypeLogin, + /** + Type used to restore an existing account by reseting the password. + */ + MXKAuthenticationTypeForgotPassword + +} MXKAuthenticationType; + +@class MXKAuthInputsView; + +/** + `MXKAuthInputsView` delegate + */ +@protocol MXKAuthInputsViewDelegate + +/** + Tells the delegate that an alert must be presented. + + @param authInputsView the authentication inputs view. + @param alert the alert to present. + */ +- (void)authInputsView:(MXKAuthInputsView*)authInputsView presentAlertController:(UIAlertController*)alert; + +/** + For some input fields, the return key of the keyboard is defined as `Done` key. + By this method, the delegate is notified when this key is pressed. + */ +- (void)authInputsViewDidPressDoneKey:(MXKAuthInputsView *)authInputsView; + +@optional + +/** + The matrix REST Client used to validate third-party identifiers. + */ +- (MXRestClient *)authInputsViewThirdPartyIdValidationRestClient:(MXKAuthInputsView *)authInputsView; + +/** + The identity service used to validate third-party identifiers. + */ +- (MXIdentityService *)authInputsViewThirdPartyIdValidationIdentityService:(MXKAuthInputsView *)authInputsView; + +/** + Tell the delegate to present a view controller modally. + + Note: This method is used to display the countries list during the phone number handling. + + @param authInputsView the authentication inputs view. + @param viewControllerToPresent the view controller to present. + @param animated YES to animate the presentation. + */ +- (void)authInputsView:(MXKAuthInputsView *)authInputsView presentViewController:(UIViewController*)viewControllerToPresent animated:(BOOL)animated; + +/** + Tell the delegate to cancel the current operation. + */ +- (void)authInputsViewDidCancelOperation:(MXKAuthInputsView *)authInputsView; + +/** + Tell the delegate to autodiscover the server configuration. + */ +- (void)authInputsView:(MXKAuthInputsView *)authInputsView autoDiscoverServerWithDomain:(NSString*)domain; + +@end + +/** + `MXKAuthInputsView` is a base class to handle authentication inputs. + */ +@interface MXKAuthInputsView : MXKView +{ +@protected + /** + The authentication type (`MXKAuthenticationTypeLogin` by default). + */ + MXKAuthenticationType type; + + /** + The authentication session (nil by default). + */ + MXAuthenticationSession *currentSession; + + /** + Alert used to display inputs error. + */ + UIAlertController *inputsAlert; +} + +/** + The view delegate. + */ +@property (nonatomic, weak) id delegate; + +/** + The current authentication type (`MXKAuthenticationTypeLogin` by default). + */ +@property (nonatomic, readonly) MXKAuthenticationType authType; + +/** + The current authentication session if any. + */ +@property (nonatomic, readonly) MXAuthenticationSession *authSession; + +/** + The current filled user identifier (nil by default). + */ +@property (nonatomic, readonly) NSString *userId; + +/** + The current filled password (nil by default). + */ +@property (nonatomic, readonly) NSString *password; + +/** + The layout constraint defined on the view height. This height takes into account shown/hidden fields. + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *viewHeightConstraint; + +/** + Returns the `UINib` object initialized for the auth inputs view. + + @return The initialized `UINib` object or `nil` if there were errors during + initialization or the nib file could not be located. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKAuthInputsView` object. + + @discussion This is the designated initializer for programmatic instantiation. + + @return An initialized `MXKAuthInputsView` object if successful, `nil` otherwise. + */ ++ (instancetype)authInputsView; + +/** + Finalize the authentication inputs view with a session and a type. + Use this method to restore the view in its initial step. + + @discussion You may override this method to check/update the flows listed in the provided authentication session. + + @param authSession the authentication session returned by the homeserver. + @param authType the authentication type (see 'MXKAuthenticationType'). + @return YES if the provided session and type are supported by the MXKAuthInputsView-inherited class. Note the unsupported flows should be here removed from the stored authentication session (see the resulting session in the property named 'authSession'). + */ +- (BOOL)setAuthSession:(MXAuthenticationSession *)authSession withAuthType:(MXKAuthenticationType)authType; + +/** + Check the validity of the required parameters. + + @return an error message in case of wrong parameters (nil by default). + */ +- (NSString*)validateParameters; + +/** + Prepare the set of the inputs in order to launch an authentication process. + + @param callback the block called when the parameters are prepared. The resulting parameter dictionary is nil + if something fails (for example when a parameter or a required input is missing). The failure reason is provided in error parameter (nil by default). + */ +- (void)prepareParameters:(void (^)(NSDictionary *parameters, NSError *error))callback; + +/** + Update the current authentication session by providing the list of successful stages. + + @param completedStages the list of stages the client has completed successfully. This is an array of MXLoginFlowType. + @param callback the block called when the parameters have been updated for the next stage. The resulting parameter dictionary is nil + if something fails (for example when a parameter or a required input is missing). The failure reason is provided in error parameter (nil by default). + */ +- (void)updateAuthSessionWithCompletedStages:(NSArray *)completedStages didUpdateParameters:(void (^)(NSDictionary *parameters, NSError *error))callback; + +/** + Update the current authentication session by providing a set of registration parameters. + + @discussion This operation failed if the current authentication type is MXKAuthenticationTypeLogin. + + @param registrationParameters a set of parameters to use during the current registration process. + @return YES if the provided set of parameters is supported. + */ +- (BOOL)setExternalRegistrationParameters:(NSDictionary *)registrationParameters; + +/** + Update the current authentication session by providing soft logout credentials. + */ +@property (nonatomic) MXCredentials *softLogoutCredentials; + +/** + Tell whether all required fields are set + */ +- (BOOL)areAllRequiredFieldsSet; + +/** + Force dismiss keyboard + */ +- (void)dismissKeyboard; + +/** + Switch in next authentication flow step by updating the layout. + + @discussion This method is supposed to be called only if the current operation succeeds. + */ +- (void)nextStep; + +/** + Dispose any resources and listener. + */ +- (void)destroy; + +@end diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsView.m b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsView.m new file mode 100644 index 000000000..3977b6355 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsView.m @@ -0,0 +1,156 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAuthInputsView.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +NSString *const MXKAuthErrorDomain = @"MXKAuthErrorDomain"; + +@implementation MXKAuthInputsView + ++ (UINib *)nib +{ + // By default, no nib is available. + return nil; +} + ++ (instancetype)authInputsView +{ + // Check whether a xib is defined + if ([[self class] nib]) + { + return [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + } + + return [[[self class] alloc] init]; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + [self setTranslatesAutoresizingMaskIntoConstraints: NO]; + + type = MXKAuthenticationTypeLogin; +} + +- (instancetype)init +{ + self = [super init]; + if (self) + { + type = MXKAuthenticationTypeLogin; + } + return self; +} + +#pragma mark - + +- (BOOL)setAuthSession:(MXAuthenticationSession *)authSession withAuthType:(MXKAuthenticationType)authType +{ + if (authSession) + { + type = authType; + currentSession = authSession; + + return YES; + } + + return NO; +} + +- (NSString *)validateParameters +{ + // Currently no field to check here + return nil; +} + +- (void)prepareParameters:(void (^)(NSDictionary *parameters, NSError *error))callback +{ + // Do nothing by default + if (callback) + { + callback (nil, [NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:[MatrixKitL10n notSupportedYet]}]); + } +} + +- (void)updateAuthSessionWithCompletedStages:(NSArray *)completedStages didUpdateParameters:(void (^)(NSDictionary *parameters, NSError *error))callback +{ + // Do nothing by default + if (callback) + { + callback (nil, [NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:[MatrixKitL10n notSupportedYet]}]); + } +} + +- (BOOL)setExternalRegistrationParameters:(NSDictionary *)registrationParameters +{ + // Not supported by default + return NO; +} + +- (BOOL)areAllRequiredFieldsSet +{ + // Currently no field to check here + return YES; +} + +- (void)dismissKeyboard +{ + +} + +- (void)nextStep +{ + +} + +- (void)destroy +{ + if (inputsAlert) + { + [inputsAlert dismissViewControllerAnimated:NO completion:nil]; + inputsAlert = nil; + } +} + +#pragma mark - + +- (MXKAuthenticationType)authType +{ + return type; +} + +- (MXAuthenticationSession*)authSession +{ + return currentSession; +} + +- (NSString*)userId +{ + return nil; +} + +- (NSString*)password +{ + return nil; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationFallbackWebView.h b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationFallbackWebView.h new file mode 100644 index 000000000..404beb42b --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationFallbackWebView.h @@ -0,0 +1,30 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +@interface MXKAuthenticationFallbackWebView : WKWebView + +/** + Open authentication fallback page into the webview. + + @param fallbackPage the fallback page hosted by a homeserver. + @param success the block called when the user has been successfully logged in or registered. + */ +- (void)openFallbackPage:(NSString*)fallbackPage success:(void (^)(MXLoginResponse *loginResponse))success; + +@end diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationFallbackWebView.m b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationFallbackWebView.m new file mode 100644 index 000000000..09c50ffc6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationFallbackWebView.m @@ -0,0 +1,181 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAuthenticationFallbackWebView.h" + +// Generic method to make a bridge between JS and the WKWebView +NSString *kMXKJavascriptSendObjectMessage = @"window.sendObjectMessage = function(parameters) { \ +var iframe = document.createElement('iframe'); \ +iframe.setAttribute('src', 'js:' + JSON.stringify(parameters)); \ +\ +document.documentElement.appendChild(iframe); \ +iframe.parentNode.removeChild(iframe); \ +iframe = null; \ +};"; + +// The function the fallback page calls when the registration is complete +NSString *kMXKJavascriptOnRegistered = @"window.matrixRegistration.onRegistered = function(homeserverUrl, userId, accessToken) { \ +sendObjectMessage({ \ +'action': 'onRegistered', \ +'homeServer': homeserverUrl, \ +'userId': userId, \ +'accessToken': accessToken \ +}); \ +};"; + +// The function the fallback page calls when the login is complete +NSString *kMXKJavascriptOnLogin = @"window.matrixLogin.onLogin = function(response) { \ +sendObjectMessage({ \ +'action': 'onLogin', \ +'response': response \ +}); \ +};"; + +@interface MXKAuthenticationFallbackWebView () +{ + // The block called when the login or the registration is successful + void (^onSuccess)(MXLoginResponse *); + + // Activity indicator + UIActivityIndicatorView *activityIndicator; +} +@end + +@implementation MXKAuthenticationFallbackWebView + +- (void)dealloc +{ + if (activityIndicator) + { + [activityIndicator removeFromSuperview]; + activityIndicator = nil; + } +} + +- (void)openFallbackPage:(NSString *)fallbackPage success:(void (^)(MXLoginResponse *))success +{ + self.navigationDelegate = self; + + onSuccess = success; + + // Add activity indicator + activityIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray]; + activityIndicator.center = self.center; + [self addSubview:activityIndicator]; + [activityIndicator startAnimating]; + + // Delete cookies to launch login process from scratch + for(NSHTTPCookie *cookie in [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies]) + { + [[NSHTTPCookieStorage sharedHTTPCookieStorage] deleteCookie:cookie]; + } + + [self loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:fallbackPage]]]; +} + +#pragma mark - WKNavigationDelegate + +- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation +{ + if (activityIndicator) + { + [activityIndicator stopAnimating]; + [activityIndicator removeFromSuperview]; + activityIndicator = nil; + } + + [self evaluateJavaScript:kMXKJavascriptSendObjectMessage completionHandler:^(id _Nullable response, NSError * _Nullable error) { + + }]; + [self evaluateJavaScript:kMXKJavascriptOnRegistered completionHandler:^(id _Nullable response, NSError * _Nullable error) { + + }]; + [self evaluateJavaScript:kMXKJavascriptOnLogin completionHandler:^(id _Nullable response, NSError * _Nullable error) { + + }]; +} + +- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler +{ + MXLogDebug(@"[MXKAuthenticationFallbackWebView] decidePolicyForNavigationAction"); + + NSString *urlString = navigationAction.request.URL.absoluteString; + + if ([urlString hasPrefix:@"js:"]) + { + // do not log urlString, it may have an access token + MXLogDebug(@"[MXKAuthenticationFallbackWebView] URL has js: prefix"); + + // Listen only to scheme of the JS-WKWebView bridge + NSString *jsonString = [[[urlString componentsSeparatedByString:@"js:"] lastObject] stringByReplacingPercentEscapesUsingEncoding:NSASCIIStringEncoding]; + NSData *jsonData = [jsonString dataUsingEncoding:NSUTF8StringEncoding]; + + NSError *error; + NSDictionary *parameters = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingMutableContainers + error:&error]; + + if (error) + { + MXLogDebug(@"[MXKAuthenticationFallbackWebView] Error when parsing json: %@", error); + } + else + { + if ([@"onRegistered" isEqualToString:parameters[@"action"]]) + { + // Translate the JS registration event to MXLoginResponse + // We cannot use [MXLoginResponse modelFromJSON:] because of https://github.com/matrix-org/synapse/issues/4756 + // Because of this issue, we cannot get the device_id allocated by the homeserver + // TODO: Fix it once the homeserver issue is fixed (filed at https://github.com/vector-im/riot-meta/issues/273). + MXLoginResponse *loginResponse = [MXLoginResponse new]; + loginResponse.homeserver = parameters[@"homeServer"]; + loginResponse.userId = parameters[@"userId"]; + loginResponse.accessToken = parameters[@"accessToken"]; + + MXLogDebug(@"[MXKAuthenticationFallbackWebView] Registered on homeserver: %@", loginResponse.homeserver); + + // Sanity check + if (loginResponse.homeserver.length && loginResponse.userId.length && loginResponse.accessToken.length) + { + MXLogDebug(@"[MXKAuthenticationFallbackWebView] Call success block"); + // And inform the client + onSuccess(loginResponse); + } + } + else if ([@"onLogin" isEqualToString:parameters[@"action"]]) + { + // Translate the JS login event to MXLoginResponse + MXLoginResponse *loginResponse; + MXJSONModelSetMXJSONModel(loginResponse, MXLoginResponse, parameters[@"response"]); + + MXLogDebug(@"[MXKAuthenticationFallbackWebView] Logged in on homeserver: %@", loginResponse.homeserver); + + // Sanity check + if (loginResponse.homeserver.length && loginResponse.userId.length && loginResponse.accessToken.length) + { + MXLogDebug(@"[MXKAuthenticationFallbackWebView] Call success block"); + // And inform the client + onSuccess(loginResponse); + } + } + } + + decisionHandler(WKNavigationActionPolicyCancel); + return; + } + decisionHandler(WKNavigationActionPolicyAllow); +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationRecaptchaWebView.h b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationRecaptchaWebView.h new file mode 100644 index 000000000..c1beeb558 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationRecaptchaWebView.h @@ -0,0 +1,33 @@ +/* + Copyright 2016 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import + +@interface MXKAuthenticationRecaptchaWebView : WKWebView + +/** + Open reCAPTCHA widget into a webview. + + @param siteKey the site key. + @param homeServer the homeserver URL. + @param callback the block called when the user has received reCAPTCHA response. + */ +- (void)openRecaptchaWidgetWithSiteKey:(NSString*)siteKey fromHomeServer:(NSString*)homeServer callback:(void (^)(NSString *response))callback; + +@end diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationRecaptchaWebView.m b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationRecaptchaWebView.m new file mode 100644 index 000000000..8e3bfa6fa --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationRecaptchaWebView.m @@ -0,0 +1,126 @@ +/* + Copyright 2016 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAuthenticationRecaptchaWebView.h" + +NSString *kMXKRecaptchaHTMLString = @" \ + \ + \ + \ + \ + \ +
\ + \ + \ +"; + +@interface MXKAuthenticationRecaptchaWebView () +{ + // The block called when the reCAPTCHA response is received + void (^onResponse)(NSString *); + + // Activity indicator + UIActivityIndicatorView *activityIndicator; +} +@end + +@implementation MXKAuthenticationRecaptchaWebView + +- (void)dealloc +{ + if (activityIndicator) + { + [activityIndicator removeFromSuperview]; + activityIndicator = nil; + } +} + +- (void)openRecaptchaWidgetWithSiteKey:(NSString*)siteKey fromHomeServer:(NSString*)homeServer callback:(void (^)(NSString *response))callback +{ + self.navigationDelegate = self; + + onResponse = callback; + + // Add activity indicator + activityIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray]; + activityIndicator.center = self.center; + [self addSubview:activityIndicator]; + [activityIndicator startAnimating]; + + NSString *htmlString = [NSString stringWithFormat:kMXKRecaptchaHTMLString, siteKey]; + + [self loadHTMLString:htmlString baseURL:[NSURL URLWithString:homeServer]]; +} + +#pragma mark - WKNavigationDelegate + +- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation +{ + if (activityIndicator) + { + [activityIndicator stopAnimating]; + [activityIndicator removeFromSuperview]; + activityIndicator = nil; + } +} + +- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler +{ + NSString *urlString = navigationAction.request.URL.absoluteString; + + if ([urlString hasPrefix:@"js:"]) + { + // Listen only to scheme of the JS-WKWebView bridge + NSString *jsonString = [[[urlString componentsSeparatedByString:@"js:"] lastObject] stringByReplacingPercentEscapesUsingEncoding:NSASCIIStringEncoding]; + NSData *jsonData = [jsonString dataUsingEncoding:NSUTF8StringEncoding]; + + NSError *error; + NSDictionary *parameters = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingMutableContainers + error:&error]; + + if (!error) + { + if ([@"verifyCallback" isEqualToString:parameters[@"action"]]) + { + // Transfer the reCAPTCHA response + onResponse(parameters[@"response"]); + } + } + decisionHandler(WKNavigationActionPolicyCancel); + return; + } + decisionHandler(WKNavigationActionPolicyAllow); +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/BarButtonItem/MXKBarButtonItem.h b/Riot/Modules/MatrixKit/Views/BarButtonItem/MXKBarButtonItem.h new file mode 100644 index 000000000..47549ed8b --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/BarButtonItem/MXKBarButtonItem.h @@ -0,0 +1,38 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#pragma mark - Imports + +@import Foundation; +@import UIKit; + +#pragma mark - Types + +typedef void (^MXKBarButtonItemAction)(void); + +#pragma mark - Interface + +/** + `MXKBarButtonItem` is a subclass of UIBarButtonItem allowing to use convenient action block instead of action selector. + */ +@interface MXKBarButtonItem : UIBarButtonItem + +#pragma mark - Instance Methods + +- (instancetype)initWithImage:(UIImage *)image style:(UIBarButtonItemStyle)style action:(MXKBarButtonItemAction)action; +- (instancetype)initWithTitle:(NSString *)title style:(UIBarButtonItemStyle)style action:(MXKBarButtonItemAction)action; + +@end diff --git a/Riot/Modules/MatrixKit/Views/BarButtonItem/MXKBarButtonItem.m b/Riot/Modules/MatrixKit/Views/BarButtonItem/MXKBarButtonItem.m new file mode 100644 index 000000000..e04fce515 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/BarButtonItem/MXKBarButtonItem.m @@ -0,0 +1,67 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#pragma mark - Imports + +#import "MXKBarButtonItem.h" + +#pragma mark - Private Interface + +@interface MXKBarButtonItem () + +#pragma mark - Private Properties + +@property (nonatomic, copy) MXKBarButtonItemAction actionBlock; + +@end + +#pragma mark - Implementation + +@implementation MXKBarButtonItem + +#pragma mark - Public methods + +- (instancetype)initWithImage:(UIImage *)image style:(UIBarButtonItemStyle)style action:(MXKBarButtonItemAction)action +{ + self = [self initWithImage:image style:style target:self action:@selector(executeAction:)]; + if (self) + { + self.actionBlock = action; + } + return self; +} + +- (instancetype)initWithTitle:(NSString *)title style:(UIBarButtonItemStyle)style action:(MXKBarButtonItemAction)action +{ + self = [self initWithTitle:title style:style target:self action:@selector(executeAction:)]; + if (self) + { + self.actionBlock = action; + } + return self; +} + +#pragma mark - Private methods + +- (void)executeAction:(id)sender +{ + if (self.actionBlock) + { + self.actionBlock(); + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.h b/Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.h new file mode 100644 index 000000000..96b2586ba --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.h @@ -0,0 +1,90 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +#import "MXKCellRendering.h" +#import "MXKImageView.h" + +/** + List the accessory view types for a 'MXKContactTableCell' instance. + */ +typedef enum : NSUInteger { + /** + Don't show accessory view by default. + */ + MXKContactTableCellAccessoryCustom, + /** + The accessory view is automatically handled. It shown only for contact with matrix identifier(s). + */ + MXKContactTableCellAccessoryMatrixIcon + +} MXKContactTableCellAccessoryType; + + +#pragma mark - MXKCellRenderingDelegate cell tap locations + +/** + Action identifier used when the user tapped on contact thumbnail view. + + The `userInfo` dictionary contains an `NSString` object under the `kMXKContactCellContactIdKey` key, representing the contact id of the tapped avatar. + */ +extern NSString *const kMXKContactCellTapOnThumbnailView; + +/** + Notifications `userInfo` keys + */ +extern NSString *const kMXKContactCellContactIdKey; + +/** + 'MXKContactTableCell' is a base class for displaying a contact. + */ +@interface MXKContactTableCell : MXKTableViewCell + +@property (strong, nonatomic) IBOutlet MXKImageView *thumbnailView; + +@property (strong, nonatomic) IBOutlet UILabel *contactDisplayNameLabel; +@property (strong, nonatomic) IBOutlet UILabel *matrixDisplayNameLabel; +@property (strong, nonatomic) IBOutlet UILabel *matrixIDLabel; + +@property (strong, nonatomic) IBOutlet UIView *contactAccessoryView; +@property (unsafe_unretained, nonatomic) IBOutlet NSLayoutConstraint *contactAccessoryViewHeightConstraint; +@property (unsafe_unretained, nonatomic) IBOutlet NSLayoutConstraint *contactAccessoryViewWidthConstraint; +@property (strong, nonatomic) IBOutlet UIImageView *contactAccessoryImageView; +@property (strong, nonatomic) IBOutlet UIButton *contactAccessoryButton; + +/** + The default picture displayed when no picture is available. + */ +@property (nonatomic) UIImage *picturePlaceholder; + +/** + The thumbnail display box type ('MXKTableViewCellDisplayBoxTypeDefault' by default) + */ +@property (nonatomic) MXKTableViewCellDisplayBoxType thumbnailDisplayBoxType; + +/** + The accessory view type ('MXKContactTableCellAccessoryCustom' by default) + */ +@property (nonatomic) MXKContactTableCellAccessoryType contactAccessoryViewType; + +/** + Tell whether the matrix presence of the contact is displayed or not (NO by default) + */ +@property (nonatomic) BOOL hideMatrixPresence; + +@end + diff --git a/Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.m b/Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.m new file mode 100644 index 000000000..471c8f5af --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.m @@ -0,0 +1,349 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKContactTableCell.h" + +@import MatrixSDK.MXTools; + +#import "MXKContactManager.h" +#import "MXKAppSettings.h" + +#import "NSBundle+MatrixKit.h" + +#pragma mark - Constant definitions +NSString *const kMXKContactCellTapOnThumbnailView = @"kMXKContactCellTapOnThumbnailView"; + +NSString *const kMXKContactCellContactIdKey = @"kMXKContactCellContactIdKey"; + +@interface MXKContactTableCell() +{ + /** + The current displayed contact. + */ + MXKContact *contact; + + /** + The observer of the presence for matrix user. + */ + id mxPresenceObserver; +} +@end + +@implementation MXKContactTableCell +@synthesize delegate; + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + self.thumbnailDisplayBoxType = MXKTableViewCellDisplayBoxTypeDefault; + + // No accessory view by default + self.contactAccessoryViewType = MXKContactTableCellAccessoryCustom; + + self.hideMatrixPresence = NO; +} + +- (void)customizeTableViewCellRendering +{ + [super customizeTableViewCellRendering]; + + self.thumbnailView.defaultBackgroundColor = [UIColor clearColor]; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + if (self.thumbnailDisplayBoxType == MXKTableViewCellDisplayBoxTypeCircle) + { + // Round image view for thumbnail + self.thumbnailView.layer.cornerRadius = self.thumbnailView.frame.size.width / 2; + self.thumbnailView.clipsToBounds = YES; + } + else if (self.thumbnailDisplayBoxType == MXKTableViewCellDisplayBoxTypeRoundedCorner) + { + self.thumbnailView.layer.cornerRadius = 5; + self.thumbnailView.clipsToBounds = YES; + } + else + { + self.thumbnailView.layer.cornerRadius = 0; + self.thumbnailView.clipsToBounds = NO; + } +} + +- (UIImage*)picturePlaceholder +{ + return [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"default-profile"]; +} + +- (void)setContactAccessoryViewType:(MXKContactTableCellAccessoryType)contactAccessoryViewType +{ + _contactAccessoryViewType = contactAccessoryViewType; + + if (contactAccessoryViewType == MXKContactTableCellAccessoryMatrixIcon) + { + // Load default matrix icon + self.contactAccessoryImageView.image = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"matrixUser"]; + self.contactAccessoryImageView.hidden = NO; + self.contactAccessoryButton.hidden = YES; + + // Update accessory view visibility + [self refreshMatrixIdentifiers]; + } + else + { + // Hide accessory view by default + self.contactAccessoryView.hidden = YES; + self.contactAccessoryImageView.hidden = YES; + self.contactAccessoryButton.hidden = YES; + } +} + +#pragma mark - MXKCellRendering + +- (void)render:(MXKCellData *)cellData +{ + // Sanity check: accept only object of MXKContact classes or sub-classes + NSParameterAssert([cellData isKindOfClass:[MXKContact class]]); + + contact = (MXKContact*)cellData; + + // remove any pending observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + if (mxPresenceObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:mxPresenceObserver]; + mxPresenceObserver = nil; + } + + self.thumbnailView.layer.borderWidth = 0; + + if (contact) + { + // Be warned when the thumbnail is updated + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onThumbnailUpdate:) name:kMXKContactThumbnailUpdateNotification object:nil]; + + if (! self.hideMatrixPresence) + { + // Observe contact presence change + mxPresenceObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXKContactManagerMatrixUserPresenceChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + // get the matrix identifiers + NSArray* matrixIdentifiers = self->contact.matrixIdentifiers; + if (matrixIdentifiers.count > 0) + { + // Consider only the first id + NSString *matrixUserID = matrixIdentifiers.firstObject; + if ([matrixUserID isEqualToString:notif.object]) + { + [self refreshPresenceUserRing:[MXTools presence:[notif.userInfo objectForKey:kMXKContactManagerMatrixPresenceKey]]]; + } + } + }]; + } + + if (!contact.isMatrixContact) + { + // Be warned when the linked matrix IDs are updated + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMatrixIdUpdate:) name:kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification object:nil]; + } + + NSArray* matrixIDs = contact.matrixIdentifiers; + + if (matrixIDs.count) + { + self.contactDisplayNameLabel.hidden = YES; + + self.matrixDisplayNameLabel.hidden = NO; + self.matrixDisplayNameLabel.text = contact.displayName; + self.matrixIDLabel.hidden = NO; + self.matrixIDLabel.text = [matrixIDs firstObject]; + } + else + { + self.contactDisplayNameLabel.hidden = NO; + self.contactDisplayNameLabel.text = contact.displayName; + + self.matrixDisplayNameLabel.hidden = YES; + self.matrixIDLabel.hidden = YES; + } + + UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onContactThumbnailTap:)]; + [tap setNumberOfTouchesRequired:1]; + [tap setNumberOfTapsRequired:1]; + [tap setDelegate:self]; + [self.thumbnailView addGestureRecognizer:tap]; + } + + [self refreshContactThumbnail]; + [self manageMatrixIcon]; +} + ++ (CGFloat)heightForCellData:(MXKCellData*)cellData withMaximumWidth:(CGFloat)maxWidth +{ + // The height is fixed + return 50; +} + +- (void)didEndDisplay +{ + // remove any pending observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + if (mxPresenceObserver) { + [[NSNotificationCenter defaultCenter] removeObserver:mxPresenceObserver]; + mxPresenceObserver = nil; + } + + // Remove all gesture recognizer + while (self.thumbnailView.gestureRecognizers.count) + { + [self.thumbnailView removeGestureRecognizer:self.thumbnailView.gestureRecognizers[0]]; + } + + self.delegate = nil; + contact = nil; +} + +#pragma mark - + +- (void)refreshMatrixIdentifiers +{ + // Look for a potential matrix user linked with this contact + NSArray* matrixIdentifiers = contact.matrixIdentifiers; + + if ((matrixIdentifiers.count > 0) && (! self.hideMatrixPresence)) + { + // Consider only the first matrix identifier + NSString* matrixUserID = matrixIdentifiers.firstObject; + + // Consider here all sessions reported into contact manager + NSArray* mxSessions = [MXKContactManager sharedManager].mxSessions; + for (MXSession *mxSession in mxSessions) + { + MXUser *mxUser = [mxSession userWithUserId:matrixUserID]; + if (mxUser) + { + [self refreshPresenceUserRing:mxUser.presence]; + break; + } + } + } + + // Update accessory view visibility + if (self.contactAccessoryViewType == MXKContactTableCellAccessoryMatrixIcon) + { + self.contactAccessoryView.hidden = (!matrixIdentifiers.count); + } +} + +- (void)refreshContactThumbnail +{ + self.thumbnailView.image = [contact thumbnailWithPreferedSize:self.thumbnailView.frame.size]; + + if (!self.thumbnailView.image) + { + self.thumbnailView.image = self.picturePlaceholder; + } +} + +- (void)refreshPresenceUserRing:(MXPresence)presenceStatus +{ + UIColor* ringColor; + + switch (presenceStatus) + { + case MXPresenceOnline: + ringColor = [[MXKAppSettings standardAppSettings] presenceColorForOnlineUser]; + break; + case MXPresenceUnavailable: + ringColor = [[MXKAppSettings standardAppSettings] presenceColorForUnavailableUser]; + break; + case MXPresenceOffline: + ringColor = [[MXKAppSettings standardAppSettings] presenceColorForOfflineUser]; + break; + default: + ringColor = nil; + } + + // if the thumbnail is defined + if (ringColor && (! self.hideMatrixPresence)) + { + self.thumbnailView.layer.borderWidth = 2; + self.thumbnailView.layer.borderColor = ringColor.CGColor; + } + else + { + // remove the border + // else it draws black border + self.thumbnailView.layer.borderWidth = 0; + } +} + +- (void)manageMatrixIcon +{ + // try to update the thumbnail with the matrix thumbnail + if (contact.matrixIdentifiers) + { + [self refreshContactThumbnail]; + } + + [self refreshMatrixIdentifiers]; +} + +- (void)onMatrixIdUpdate:(NSNotification *)notif +{ + // sanity check + if ([notif.object isKindOfClass:[NSString class]]) + { + NSString* contactID = notif.object; + + if ([contactID isEqualToString:contact.contactID]) + { + [self manageMatrixIcon]; + } + } +} + +- (void)onThumbnailUpdate:(NSNotification *)notif +{ + // sanity check + if ([notif.object isKindOfClass:[NSString class]]) + { + NSString* contactID = notif.object; + + if ([contactID isEqualToString:contact.contactID]) + { + [self refreshContactThumbnail]; + + [self refreshMatrixIdentifiers]; + } + } +} + +#pragma mark - Action + +- (IBAction)onContactThumbnailTap:(id)sender +{ + if (self.delegate) + { + [self.delegate cell:self didRecognizeAction:kMXKContactCellTapOnThumbnailView userInfo:@{kMXKContactCellContactIdKey: contact.contactID}]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.xib b/Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.xib new file mode 100644 index 000000000..5173d1b2f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.xib @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.h b/Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.h new file mode 100644 index 000000000..9e69cc1a7 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.h @@ -0,0 +1,85 @@ +/* + Copyright 2016 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKView.h" + +/** + MXKDeviceView class may be used to display the information of a user's device. + The displayed device may be renamed or removed. + */ + +@class MXKDeviceView; +@protocol MXKDeviceViewDelegate + +/** + Tells the delegate that an alert must be presented. + + @param deviceView the device view. + @param alert the alert to present. + */ +- (void)deviceView:(MXKDeviceView*)deviceView presentAlertController:(UIAlertController*)alert; + +@optional + +/** + Tells the delegate to dismiss the device view. + + @param deviceView the device view. + @param isUpdated tell whether the device was updated (renamed, removed...). + */ +- (void)dismissDeviceView:(MXKDeviceView*)deviceView didUpdate:(BOOL)isUpdated; + +@end + +@interface MXKDeviceView : MXKView + +@property (weak, nonatomic) IBOutlet UIView *bgView; +@property (weak, nonatomic) IBOutlet UIView *containerView; +@property (weak, nonatomic) IBOutlet UITextView *textView; +@property (weak, nonatomic) IBOutlet UIButton *cancelButton; +@property (weak, nonatomic) IBOutlet UIButton *renameButton; +@property (weak, nonatomic) IBOutlet UIButton *deleteButton; +@property (weak, nonatomic) IBOutlet UIActivityIndicatorView *activityIndicator; + +/** + Initialize a device view to display the information of a user's device. + + @param device a user's device. + @param session the matrix session. + @return the newly created instance. + */ +- (instancetype)initWithDevice:(MXDevice*)device andMatrixSession:(MXSession*)session; + +/** + The delegate. + */ +@property (nonatomic, weak) id delegate; + +/** + The default text color in the text view. [UIColor blackColor] by default. + */ +@property (nonatomic) UIColor *defaultTextColor; + +/** + Action registered on 'UIControlEventTouchUpInside' event for each UIButton instance. + */ +- (IBAction)onButtonPressed:(id)sender; + +@end + diff --git a/Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.m b/Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.m new file mode 100644 index 000000000..3551e08e2 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.m @@ -0,0 +1,481 @@ +/* + Copyright 2016 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKDeviceView.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKConstants.h" + +#import "MXKSwiftHeader.h" + +static NSAttributedString *verticalWhitespace = nil; + +@interface MXKDeviceView () +{ + /** + The displayed device + */ + MXDevice *mxDevice; + + /** + The matrix session. + */ + MXSession *mxSession; + + /** + The current alert + */ + UIAlertController *currentAlert; + + /** + Current request in progress. + */ + MXHTTPOperation *mxCurrentOperation; +} +@end + +@implementation MXKDeviceView + ++ (UINib *)nib +{ + // Check whether a nib file is available + NSBundle *mainBundle = [NSBundle mxk_bundleForClass:self.class]; + + NSString *path = [mainBundle pathForResource:NSStringFromClass([self class]) ofType:@"nib"]; + if (path) + { + return [UINib nibWithNibName:NSStringFromClass([self class]) bundle:mainBundle]; + } + return [UINib nibWithNibName:NSStringFromClass([MXKDeviceView class]) bundle:[NSBundle mxk_bundleForClass:[MXKDeviceView class]]]; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + // Add tap recognizer to discard the view on bg view tap + UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onBgViewTap:)]; + [tap setNumberOfTouchesRequired:1]; + [tap setNumberOfTapsRequired:1]; + [tap setDelegate:self]; + [self.bgView addGestureRecognizer:tap]; + + // Localize string + [_cancelButton setTitle:[MatrixKitL10n ok] forState:UIControlStateNormal]; + [_cancelButton setTitle:[MatrixKitL10n ok] forState:UIControlStateHighlighted]; + + [_renameButton setTitle:[MatrixKitL10n rename] forState:UIControlStateNormal]; + [_renameButton setTitle:[MatrixKitL10n rename] forState:UIControlStateHighlighted]; + + [_deleteButton setTitle:[MatrixKitL10n delete] forState:UIControlStateNormal]; + [_deleteButton setTitle:[MatrixKitL10n delete] forState:UIControlStateHighlighted]; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + // Scroll to the top the text view content + self.textView.contentOffset = CGPointZero; +} + +#pragma mark - Override MXKView + +-(void)customizeViewRendering +{ + [super customizeViewRendering]; + + _defaultTextColor = [UIColor blackColor]; + + // Add shadow on added view + _containerView.layer.cornerRadius = 5; + _containerView.layer.shadowOffset = CGSizeMake(0, 1); + _containerView.layer.shadowOpacity = 0.5f; +} + +#pragma mark - + +- (void)removeFromSuperviewDidUpdate:(BOOL)isUpdated +{ + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + currentAlert = nil; + } + + if (mxCurrentOperation) + { + [mxCurrentOperation cancel]; + mxCurrentOperation = nil; + } + + if (self.delegate && [self.delegate respondsToSelector:@selector(dismissDeviceView:didUpdate:)]) + { + [self.delegate dismissDeviceView:self didUpdate:isUpdated]; + } + else + { + [self removeFromSuperview]; + } +} + +- (instancetype)initWithDevice:(MXDevice*)device andMatrixSession:(MXSession*)session +{ + self = [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + if (self) + { + mxDevice = device; + mxSession = session; + + [self setTranslatesAutoresizingMaskIntoConstraints: NO]; + + if (mxDevice) + { + // Device information + NSMutableAttributedString *deviceInformationString = [[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n deviceDetailsTitle] + attributes:@{NSForegroundColorAttributeName : _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:15]}]; + [deviceInformationString appendAttributedString:[MXKDeviceView verticalWhitespace]]; + + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n deviceDetailsName] + attributes:@{NSForegroundColorAttributeName : _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:device.displayName.length ? device.displayName : @"" + attributes:@{NSForegroundColorAttributeName : _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[MXKDeviceView verticalWhitespace]]; + + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n deviceDetailsIdentifier] + attributes:@{NSForegroundColorAttributeName : _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:device.deviceId + attributes:@{NSForegroundColorAttributeName : _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[MXKDeviceView verticalWhitespace]]; + + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n deviceDetailsLastSeen] + attributes:@{NSForegroundColorAttributeName : _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + + NSDate *lastSeenDate = [NSDate dateWithTimeIntervalSince1970:device.lastSeenTs/1000]; + + NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init]; + [dateFormatter setLocale:[[NSLocale alloc] initWithLocaleIdentifier:[[[NSBundle mainBundle] preferredLocalizations] objectAtIndex:0]]]; + [dateFormatter setDateStyle:NSDateFormatterShortStyle]; + [dateFormatter setTimeStyle:NSDateFormatterShortStyle]; + [dateFormatter setFormatterBehavior:NSDateFormatterBehavior10_4]; + + NSString *lastSeen = [MatrixKitL10n deviceDetailsLastSeenFormat:device.lastSeenIp :[dateFormatter stringFromDate:lastSeenDate]]; + + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:lastSeen + attributes:@{NSForegroundColorAttributeName : _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[MXKDeviceView verticalWhitespace]]; + + self.textView.attributedText = deviceInformationString; + } + else + { + _textView.text = nil; + } + + // Hide potential activity indicator + [_activityIndicator stopAnimating]; + } + + return self; +} + +- (void)dealloc +{ + mxDevice = nil; + mxSession = nil; +} + ++ (NSAttributedString *)verticalWhitespace +{ + if (verticalWhitespace == nil) + { + verticalWhitespace = [[NSAttributedString alloc] initWithString:@"\n\n" attributes:@{NSFontAttributeName: [UIFont systemFontOfSize:4]}]; + } + return verticalWhitespace; +} + +#pragma mark - Actions + +- (IBAction)onBgViewTap:(UITapGestureRecognizer*)sender +{ + [self removeFromSuperviewDidUpdate:NO]; +} + +- (IBAction)onButtonPressed:(id)sender +{ + if (sender == _cancelButton) + { + [self removeFromSuperviewDidUpdate:NO]; + } + else if (sender == _renameButton) + { + [self renameDevice]; + } + else if (sender == _deleteButton) + { + [self deleteDevice]; + } +} + +#pragma mark - + +- (void)renameDevice +{ + if (!self.delegate) + { + // Ignore + MXLogDebug(@"[MXKDeviceView] Rename device failed, delegate is missing"); + return; + } + + // Prompt the user to enter a device name. + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + __weak typeof(self) weakSelf = self; + + currentAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n deviceDetailsRenamePromptTitle] + message:[MatrixKitL10n deviceDetailsRenamePromptMessage] preferredStyle:UIAlertControllerStyleAlert]; + + [currentAlert addTextFieldWithConfigurationHandler:^(UITextField *textField) { + + textField.secureTextEntry = NO; + textField.placeholder = nil; + textField.keyboardType = UIKeyboardTypeDefault; + if (weakSelf) + { + typeof(self) self = weakSelf; + textField.text = self->mxDevice.displayName; + } + }]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + } + + }]]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + NSString *text = [self->currentAlert textFields].firstObject.text; + self->currentAlert = nil; + + [self.activityIndicator startAnimating]; + + self->mxCurrentOperation = [self->mxSession.matrixRestClient setDeviceName:text forDeviceId:self->mxDevice.deviceId success:^{ + + if (weakSelf) + { + typeof(self) self = weakSelf; + + self->mxCurrentOperation = nil; + [self.activityIndicator stopAnimating]; + + [self removeFromSuperviewDidUpdate:YES]; + } + + } failure:^(NSError *error) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Notify MatrixKit user + NSString *myUserId = self->mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + self->mxCurrentOperation = nil; + + MXLogDebug(@"[MXKDeviceView] Rename device (%@) failed", self->mxDevice.deviceId); + + [self.activityIndicator stopAnimating]; + + [self removeFromSuperviewDidUpdate:NO]; + } + + }]; + } + + }]]; + + [self.delegate deviceView:self presentAlertController:currentAlert]; +} + +- (void)deleteDevice +{ + if (!self.delegate) + { + // Ignore + MXLogDebug(@"[MXKDeviceView] Delete device failed, delegate is missing"); + return; + } + + // Get an authentication session to prepare device deletion + [self.activityIndicator startAnimating]; + + mxCurrentOperation = [mxSession.matrixRestClient getSessionToDeleteDeviceByDeviceId:mxDevice.deviceId success:^(MXAuthenticationSession *authSession) { + + self->mxCurrentOperation = nil; + + // Check whether the password based type is supported + BOOL isPasswordBasedTypeSupported = NO; + for (MXLoginFlow *loginFlow in authSession.flows) + { + if ([loginFlow.type isEqualToString:kMXLoginFlowTypePassword] || [loginFlow.stages indexOfObject:kMXLoginFlowTypePassword] != NSNotFound) + { + isPasswordBasedTypeSupported = YES; + break; + } + } + + if (isPasswordBasedTypeSupported && authSession.session) + { + // Prompt for a password + [self->currentAlert dismissViewControllerAnimated:NO completion:nil]; + + __weak typeof(self) weakSelf = self; + + // Prompt the user before deleting the device. + self->currentAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n deviceDetailsDeletePromptTitle] message:[MatrixKitL10n deviceDetailsDeletePromptMessage] preferredStyle:UIAlertControllerStyleAlert]; + + + [self->currentAlert addTextFieldWithConfigurationHandler:^(UITextField *textField) { + + textField.secureTextEntry = YES; + textField.placeholder = nil; + textField.keyboardType = UIKeyboardTypeDefault; + }]; + + [self->currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + [self.activityIndicator stopAnimating]; + } + + }]]; + + [self->currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n submit] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + UITextField *textField = [self->currentAlert textFields].firstObject; + self->currentAlert = nil; + + NSString *userId = self->mxSession.myUser.userId; + NSDictionary *authParams; + + // Sanity check + if (userId) + { + authParams = @{@"session":authSession.session, + @"user": userId, + @"password": textField.text, + @"type": kMXLoginFlowTypePassword}; + + } + + self->mxCurrentOperation = [self->mxSession.matrixRestClient deleteDeviceByDeviceId:self->mxDevice.deviceId authParams:authParams success:^{ + + if (weakSelf) + { + typeof(self) self = weakSelf; + + self->mxCurrentOperation = nil; + [self.activityIndicator stopAnimating]; + + [self removeFromSuperviewDidUpdate:YES]; + } + + } failure:^(NSError *error) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Notify MatrixKit user + NSString *myUserId = self->mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + self->mxCurrentOperation = nil; + + MXLogDebug(@"[MXKDeviceView] Delete device (%@) failed", self->mxDevice.deviceId); + + [self.activityIndicator stopAnimating]; + + [self removeFromSuperviewDidUpdate:NO]; + } + + }]; + } + + }]]; + + [self.delegate deviceView:self presentAlertController:self->currentAlert]; + } + else + { + MXLogDebug(@"[MXKDeviceView] Delete device (%@) failed, auth session flow type is not supported", self->mxDevice.deviceId); + [self.activityIndicator stopAnimating]; + } + + } failure:^(NSError *error) { + + self->mxCurrentOperation = nil; + + MXLogDebug(@"[MXKDeviceView] Delete device (%@) failed, unable to get auth session", self->mxDevice.deviceId); + [self.activityIndicator stopAnimating]; + + // Notify MatrixKit user + NSString *myUserId = self->mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + }]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.xib b/Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.xib new file mode 100644 index 000000000..17e9bfa40 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.xib @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.h b/Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.h new file mode 100644 index 000000000..80afc81e1 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.h @@ -0,0 +1,103 @@ +/* + Copyright 2016 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKView.h" + +@protocol MXKEncryptionInfoViewDelegate; + +/** + MXKEncryptionInfoView class may be used to display the available information on a encrypted event. + The event sender device may be verified, unverified, blocked or unblocked from this view. + */ +@interface MXKEncryptionInfoView : MXKView + +/** + The displayed event + */ +@property (nonatomic, readonly) MXEvent *mxEvent; + +/** + The matrix session. + */ +@property (nonatomic, readonly) MXSession *mxSession; + +/** + The event device info + */ +@property (nonatomic, readonly) MXDeviceInfo *mxDeviceInfo; + +@property (weak, nonatomic) IBOutlet UITextView *textView; +@property (weak, nonatomic) IBOutlet UIButton *cancelButton; +@property (weak, nonatomic) IBOutlet UIButton *verifyButton; +@property (weak, nonatomic) IBOutlet UIButton *blockButton; +@property (weak, nonatomic) IBOutlet UIButton *confirmVerifyButton; + +@property (nonatomic, weak) id delegate; + +/** + Initialise an `MXKEncryptionInfoView` instance based on an encrypted event + + @param event the encrypted event. + @param session the related matrix session. + @return the newly created instance. + */ +- (instancetype)initWithEvent:(MXEvent*)event andMatrixSession:(MXSession*)session; + +/** + Initialise an `MXKEncryptionInfoView` instance based only on a device information. + + @param deviceInfo the device information. + @param session the related matrix session. + @return the newly created instance. + */ +- (instancetype)initWithDeviceInfo:(MXDeviceInfo*)deviceInfo andMatrixSession:(MXSession*)session; + +/** + The default text color in the text view. [UIColor blackColor] by default. + */ +@property (nonatomic) UIColor *defaultTextColor; + +/** + Action registered on 'UIControlEventTouchUpInside' event for each UIButton instance. +*/ +- (IBAction)onButtonPressed:(id)sender; + +@end + + +@protocol MXKEncryptionInfoViewDelegate + +/** + Called when the user changes the verified state of a device. + + @param encryptionInfoView the view. + @param deviceInfo the device that has changed. + */ +- (void)encryptionInfoView:(MXKEncryptionInfoView*)encryptionInfoView didDeviceInfoVerifiedChange:(MXDeviceInfo*)deviceInfo; + +@optional + +/** + Called when the user close the view without changing value. + + @param encryptionInfoView the view. + */ +- (void)encryptionInfoViewDidClose:(MXKEncryptionInfoView*)encryptionInfoView; + +@end diff --git a/Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.m b/Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.m new file mode 100644 index 000000000..a6537cbd5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.m @@ -0,0 +1,492 @@ +/* + Copyright 2016 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKEncryptionInfoView.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKConstants.h" + +#import "MXKSwiftHeader.h" + +static NSAttributedString *verticalWhitespace = nil; + +@interface MXKEncryptionInfoView () +{ + /** + Current request in progress. + */ + MXHTTPOperation *mxCurrentOperation; + +} +@end + +@implementation MXKEncryptionInfoView + ++ (UINib *)nib +{ + // Check whether a nib file is available + NSBundle *mainBundle = [NSBundle mxk_bundleForClass:self.class]; + + NSString *path = [mainBundle pathForResource:NSStringFromClass([self class]) ofType:@"nib"]; + if (path) + { + return [UINib nibWithNibName:NSStringFromClass([self class]) bundle:mainBundle]; + } + return [UINib nibWithNibName:NSStringFromClass([MXKEncryptionInfoView class]) bundle:[NSBundle mxk_bundleForClass:[MXKEncryptionInfoView class]]]; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + // Localize string + [_cancelButton setTitle:[MatrixKitL10n ok] forState:UIControlStateNormal]; + [_cancelButton setTitle:[MatrixKitL10n ok] forState:UIControlStateHighlighted]; + + [_confirmVerifyButton setTitle:[MatrixKitL10n roomEventEncryptionVerifyOk] forState:UIControlStateNormal]; + [_confirmVerifyButton setTitle:[MatrixKitL10n roomEventEncryptionVerifyOk] forState:UIControlStateHighlighted]; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + // Scroll to the top the text view content + self.textView.contentOffset = CGPointZero; +} + +- (void)removeFromSuperview +{ + if (mxCurrentOperation) + { + [mxCurrentOperation cancel]; + mxCurrentOperation = nil; + } + + [super removeFromSuperview]; +} + +#pragma mark - Override MXKView + +-(void)customizeViewRendering +{ + [super customizeViewRendering]; + + _defaultTextColor = [UIColor blackColor]; +} + +#pragma mark - + +- (instancetype)initWithEvent:(MXEvent*)event andMatrixSession:(MXSession*)session +{ + self = [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + if (self) + { + _mxEvent = event; + _mxSession = session; + _mxDeviceInfo = nil; + + [self setTranslatesAutoresizingMaskIntoConstraints: NO]; + + [self updateTextViewText]; + } + + return self; +} + +- (instancetype)initWithDeviceInfo:(MXDeviceInfo*)deviceInfo andMatrixSession:(MXSession*)session +{ + self = [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + if (self) + { + _mxEvent = nil; + _mxDeviceInfo = deviceInfo; + _mxSession = session; + + [self setTranslatesAutoresizingMaskIntoConstraints: NO]; + + [self updateTextViewText]; + } + + return self; +} + +- (void)dealloc +{ + _mxEvent = nil; + _mxSession = nil; + _mxDeviceInfo = nil; +} + +#pragma mark - + +- (void)updateTextViewText +{ + // Prepare the text view content + NSMutableAttributedString *textViewAttributedString = [[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoTitle] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:17]}]; + + if (_mxEvent) + { + NSString *senderId = _mxEvent.sender; + + if (_mxSession && _mxSession.crypto && !_mxDeviceInfo) + { + _mxDeviceInfo = [_mxSession.crypto eventDeviceInfo:_mxEvent]; + + if (!_mxDeviceInfo) + { + // Trigger a server request to get the device information for the event sender + mxCurrentOperation = [_mxSession.crypto downloadKeys:@[senderId] forceDownload:NO success:^(MXUsersDevicesMap *usersDevicesInfoMap, NSDictionary *crossSigningKeysMap) { + + self->mxCurrentOperation = nil; + + // Sanity check: check whether some device information has been retrieved. + self->_mxDeviceInfo = [self.mxSession.crypto eventDeviceInfo:self.mxEvent]; + if (self.mxDeviceInfo) + { + [self updateTextViewText]; + } + + } failure:^(NSError *error) { + + self->mxCurrentOperation = nil; + + MXLogDebug(@"[MXKEncryptionInfoView] Crypto failed to download device info for user: %@", self.mxEvent.sender); + + // Notify MatrixKit user + NSString *myUserId = self.mxSession.myUser.userId; + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + } + + // Event information + NSMutableAttributedString *eventInformationString = [[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoEvent] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:15]}]; + [eventInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + + NSString *senderKey = _mxEvent.senderKey; + NSString *claimedKey = _mxEvent.keysClaimed[@"ed25519"]; + NSString *algorithm = _mxEvent.wireContent[@"algorithm"]; + NSString *sessionId = _mxEvent.wireContent[@"session_id"]; + + NSString *decryptionError; + if (_mxEvent.decryptionError) + { + decryptionError = [NSString stringWithFormat:@"** %@ **", _mxEvent.decryptionError.localizedDescription]; + } + + if (!senderKey.length) + { + senderKey = [MatrixKitL10n roomEventEncryptionInfoEventNone]; + } + if (!claimedKey.length) + { + claimedKey = [MatrixKitL10n roomEventEncryptionInfoEventNone]; + } + if (!algorithm.length) + { + algorithm = [MatrixKitL10n roomEventEncryptionInfoEventUnencrypted]; + } + if (!sessionId.length) + { + sessionId = [MatrixKitL10n roomEventEncryptionInfoEventNone]; + } + + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoEventUserId] attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:senderId + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoEventIdentityKey] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:senderKey + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoEventFingerprintKey] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:claimedKey + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoEventAlgorithm] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:algorithm + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + + if (decryptionError.length) + { + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoEventDecryptionError] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:decryptionError + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + } + + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoEventSessionId] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:sessionId + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + + [textViewAttributedString appendAttributedString:eventInformationString]; + } + + // Device information + NSMutableAttributedString *deviceInformationString = [[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoDevice] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:15]}]; + [deviceInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + + if (_mxDeviceInfo) + { + NSString *name = _mxDeviceInfo.displayName; + NSString *deviceId = _mxDeviceInfo.deviceId; + NSMutableAttributedString *verification; + NSString *fingerprint = _mxDeviceInfo.fingerprint; + + // Display here the Verify and Block buttons except if the device is the current one. + _verifyButton.hidden = _blockButton.hidden = [_mxDeviceInfo.deviceId isEqualToString:_mxSession.matrixRestClient.credentials.deviceId]; + + switch (_mxDeviceInfo.trustLevel.localVerificationStatus) + { + case MXDeviceUnknown: + case MXDeviceUnverified: + { + verification = [[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoDeviceNotVerified] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]; + + [_verifyButton setTitle:[MatrixKitL10n roomEventEncryptionInfoVerify] forState:UIControlStateNormal]; + [_verifyButton setTitle:[MatrixKitL10n roomEventEncryptionInfoVerify] forState:UIControlStateHighlighted]; + [_blockButton setTitle:[MatrixKitL10n roomEventEncryptionInfoBlock] forState:UIControlStateNormal]; + [_blockButton setTitle:[MatrixKitL10n roomEventEncryptionInfoBlock] forState:UIControlStateHighlighted]; + break; + } + case MXDeviceVerified: + { + verification = [[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoDeviceVerified] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]; + + [_verifyButton setTitle:[MatrixKitL10n roomEventEncryptionInfoUnverify] forState:UIControlStateNormal]; + [_verifyButton setTitle:[MatrixKitL10n roomEventEncryptionInfoUnverify] forState:UIControlStateHighlighted]; + [_blockButton setTitle:[MatrixKitL10n roomEventEncryptionInfoBlock] forState:UIControlStateNormal]; + [_blockButton setTitle:[MatrixKitL10n roomEventEncryptionInfoBlock] forState:UIControlStateHighlighted]; + + break; + } + case MXDeviceBlocked: + { + verification = [[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoDeviceBlocked] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]; + + [_verifyButton setTitle:[MatrixKitL10n roomEventEncryptionInfoVerify] forState:UIControlStateNormal]; + [_verifyButton setTitle:[MatrixKitL10n roomEventEncryptionInfoVerify] forState:UIControlStateHighlighted]; + [_blockButton setTitle:[MatrixKitL10n roomEventEncryptionInfoUnblock] forState:UIControlStateNormal]; + [_blockButton setTitle:[MatrixKitL10n roomEventEncryptionInfoUnblock] forState:UIControlStateHighlighted]; + + break; + } + default: + break; + } + + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoDeviceName] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:(name.length ? name : @"") + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoDeviceId] attributes:@{NSForegroundColorAttributeName: _defaultTextColor, NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:deviceId + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoDeviceVerification] attributes:@{NSForegroundColorAttributeName: _defaultTextColor, NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:verification]; + [deviceInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoDeviceFingerprint] attributes:@{NSForegroundColorAttributeName: _defaultTextColor, NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:fingerprint + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + } + else + { + // Unknown device + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoDeviceUnknown] attributes:@{NSForegroundColorAttributeName: _defaultTextColor, NSFontAttributeName: [UIFont italicSystemFontOfSize:14]}]]; + } + + [textViewAttributedString appendAttributedString:deviceInformationString]; + + self.textView.attributedText = textViewAttributedString; +} + ++ (NSAttributedString *)verticalWhitespace +{ + if (verticalWhitespace == nil) + { + verticalWhitespace = [[NSAttributedString alloc] initWithString:@"\n\n" attributes:@{NSFontAttributeName: [UIFont systemFontOfSize:4]}]; + } + return verticalWhitespace; +} + +#pragma mark - Actions + +- (IBAction)onButtonPressed:(id)sender +{ + if (sender == _cancelButton) + { + [self removeFromSuperview]; + + if ([_delegate respondsToSelector:@selector(encryptionInfoViewDidClose:)]) + { + [_delegate encryptionInfoViewDidClose:self]; + } + } + // Note: Verify and Block buttons are hidden when the deviceInfo is not available + else if (sender == _confirmVerifyButton && _mxDeviceInfo) + { + [_mxSession.crypto setDeviceVerification:MXDeviceVerified forDevice:_mxDeviceInfo.deviceId ofUser:_mxDeviceInfo.userId success:^{ + + // Refresh data + _mxDeviceInfo = [self.mxSession.crypto eventDeviceInfo:self.mxEvent]; + if (self->_delegate) + { + [self->_delegate encryptionInfoView:self didDeviceInfoVerifiedChange:self.mxDeviceInfo]; + } + [self removeFromSuperview]; + + } failure:^(NSError *error) { + [self removeFromSuperview]; + }]; + } + else if (_mxDeviceInfo) + { + MXDeviceVerification verificationStatus; + + if (sender == _verifyButton) + { + verificationStatus = ((_mxDeviceInfo.trustLevel.localVerificationStatus == MXDeviceVerified) ? MXDeviceUnverified : MXDeviceVerified); + } + else if (sender == _blockButton) + { + verificationStatus = ((_mxDeviceInfo.trustLevel.localVerificationStatus == MXDeviceBlocked) ? MXDeviceUnverified : MXDeviceBlocked); + } + else + { + // Unexpected case + MXLogDebug(@"[MXKEncryptionInfoView] Invalid button pressed."); + return; + } + + if (verificationStatus == MXDeviceVerified) + { + // Prompt user + NSMutableAttributedString *textViewAttributedString = [[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionVerifyTitle] attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:17]}]; + + NSString *message = [MatrixKitL10n roomEventEncryptionVerifyMessage:_mxDeviceInfo.displayName :_mxDeviceInfo.deviceId :_mxDeviceInfo.fingerprint]; + + [textViewAttributedString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:message + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + + self.textView.attributedText = textViewAttributedString; + + [_cancelButton setTitle:[MatrixKitL10n cancel] forState:UIControlStateNormal]; + [_cancelButton setTitle:[MatrixKitL10n cancel] forState:UIControlStateHighlighted]; + _verifyButton.hidden = _blockButton.hidden = YES; + _confirmVerifyButton.hidden = NO; + } + else + { + [_mxSession.crypto setDeviceVerification:verificationStatus forDevice:_mxDeviceInfo.deviceId ofUser:_mxDeviceInfo.userId success:^{ + + // Refresh data + _mxDeviceInfo = [self.mxSession.crypto eventDeviceInfo:self.mxEvent]; + + if (self->_delegate) + { + [self->_delegate encryptionInfoView:self didDeviceInfoVerifiedChange:self.mxDeviceInfo]; + } + + [self removeFromSuperview]; + + } failure:^(NSError *error) { + [self removeFromSuperview]; + }]; + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.xib b/Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.xib new file mode 100644 index 000000000..a51c3ee77 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.xib @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysExportView.h b/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysExportView.h new file mode 100644 index 000000000..10b6529a6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysExportView.h @@ -0,0 +1,69 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +@class MXSession, MXKViewController, MXKRoomDataSource; + +/** + `MXKEncryptionKeysExportView` is a dialog to export encryption keys from + the user's crypto store. + */ +@interface MXKEncryptionKeysExportView : NSObject + +/** + The UIAlertController instance which handles the dialog. + */ +@property (nonatomic, readonly) UIAlertController *alertController; + +/** + The minimum length of the passphrase. 1 by default. + */ +@property (nonatomic) NSUInteger passphraseMinLength; + +/** + Create the `MXKEncryptionKeysExportView` instance. + + @param mxSession the mxSession to export keys from. + @return the newly created MXKEncryptionKeysExportView instance. + */ +- (instancetype)initWithMatrixSession:(MXSession*)mxSession; + +/** + Show the dialog in a given view controller. + + @param mxkViewController the mxkViewController where to show the dialog. + @param keyFile the path where to export keys to. + @param onComplete a block called when the operation is done. + */ +- (void)showInViewController:(MXKViewController*)mxkViewController toExportKeysToFile:(NSURL*)keyFile onComplete:(void(^)(BOOL success))onComplete; + + +/** + Show the dialog in a given view controller. + + @param viewController the UIViewController where to show the dialog. + @param keyFile the path where to export keys to. + @param onLoading a block called when to show a spinner. + @param onComplete a block called when the operation is done. + */ +- (void)showInUIViewController:(UIViewController*)viewController + toExportKeysToFile:(NSURL*)keyFile + onLoading:(void(^)(BOOL onLoading))onLoading + onComplete:(void(^)(BOOL success))onComplete; + +@end + diff --git a/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysExportView.m b/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysExportView.m new file mode 100644 index 000000000..8351571dd --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysExportView.m @@ -0,0 +1,192 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKEncryptionKeysExportView.h" + +#import "MXKViewController.h" +#import "MXKRoomDataSource.h" +#import "NSBundle+MatrixKit.h" + +#import + +#import "MXKSwiftHeader.h" + +@interface MXKEncryptionKeysExportView () +{ + MXSession *mxSession; +} + +@end + +@implementation MXKEncryptionKeysExportView + +- (instancetype)initWithMatrixSession:(MXSession *)matrixSession +{ + self = [super init]; + if (self) + { + mxSession = matrixSession; + _passphraseMinLength = 1; + + _alertController = [UIAlertController alertControllerWithTitle:[MatrixKitL10n e2eExportRoomKeys] message:[MatrixKitL10n e2eExportPrompt] preferredStyle:UIAlertControllerStyleAlert]; + } + return self; +} + + +- (void)showInViewController:(MXKViewController *)mxkViewController toExportKeysToFile:(NSURL *)keyFile onComplete:(void (^)(BOOL success))onComplete +{ + [self showInUIViewController:mxkViewController toExportKeysToFile:keyFile onLoading:^(BOOL onLoading) { + if (onLoading) + { + [mxkViewController startActivityIndicator]; + } + else + { + [mxkViewController stopActivityIndicator]; + } + } onComplete:onComplete]; +} + +- (void)showInUIViewController:(UIViewController*)viewController + toExportKeysToFile:(NSURL*)keyFile + onLoading:(void(^)(BOOL onLoading))onLoading + onComplete:(void(^)(BOOL success))onComplete +{ + __weak typeof(self) weakSelf = self; + + // Finalise the dialog + [_alertController addTextFieldWithConfigurationHandler:^(UITextField *textField) + { + textField.secureTextEntry = YES; + textField.placeholder = [MatrixKitL10n e2ePassphraseCreate]; + [textField resignFirstResponder]; + }]; + + [_alertController addTextFieldWithConfigurationHandler:^(UITextField *textField) + { + textField.secureTextEntry = YES; + textField.placeholder = [MatrixKitL10n e2ePassphraseConfirm]; + [textField resignFirstResponder]; + }]; + + [_alertController addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + onComplete(NO); + } + + }]]; + + [_alertController addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n e2eExport] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Retrieve the password and confirmation + UITextField *textField = [self.alertController textFields].firstObject; + NSString *password = textField.text; + + textField = [self.alertController textFields][1]; + NSString *confirmation = textField.text; + + // Check they are valid + if (password.length < self.passphraseMinLength || ![password isEqualToString:confirmation]) + { + NSString *error; + if (!password.length) + { + error = [MatrixKitL10n e2ePassphraseEmpty]; + } + else if (password.length < self.passphraseMinLength) + { + error = [MatrixKitL10n e2ePassphraseTooShort:self.passphraseMinLength]; + } + else + { + error = [MatrixKitL10n e2ePassphraseNotMatch]; + } + + UIAlertController *otherAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n error] message:error preferredStyle:UIAlertControllerStyleAlert]; + + [otherAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { + + if (weakSelf) + { + onComplete(NO); + } + + }]]; + + [viewController presentViewController:otherAlert animated:YES completion:nil]; + } + else + { + // Start the export process + onLoading(YES); + + [self->mxSession.crypto exportRoomKeysWithPassword:password success:^(NSData *keyFileData) { + + if (weakSelf) + { + onLoading(NO); + + // Write the result to the passed file + [keyFileData writeToURL:keyFile atomically:YES]; + onComplete(YES); + } + + } failure:^(NSError *error) { + + if (weakSelf) + { + onLoading(NO); + + // TODO: i18n the error + UIAlertController *otherAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n error] message:error.localizedDescription preferredStyle:UIAlertControllerStyleAlert]; + + [otherAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { + + if (weakSelf) + { + onComplete(NO); + } + + }]]; + + [viewController presentViewController:otherAlert animated:YES completion:nil]; + } + }]; + } + } + + }]]; + + + + // And show it + [viewController presentViewController:_alertController animated:YES completion:nil]; +} + + +@end + diff --git a/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysImportView.h b/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysImportView.h new file mode 100644 index 000000000..397edc5ef --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysImportView.h @@ -0,0 +1,49 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +@class MXSession, MXKViewController; + +/** + `MXKEncryptionKeysImportView` is a dialog to import encryption keys into + the user's crypto store. + */ +@interface MXKEncryptionKeysImportView : NSObject + +/** + The UIAlertController instance which handles the dialog. + */ +@property (nonatomic, readonly) UIAlertController *alertController; + +/** + Create the `MXKEncryptionKeysImportView` instance. + + @param mxSession the mxSession to import keys to. + @return the newly created MXKEncryptionKeysImportView instance. + */ +- (instancetype)initWithMatrixSession:(MXSession*)mxSession; + +/** + Show the dialog in a given view controller. + + @param mxkViewController the mxkViewController where to show the dialog. + @param fileURL the url of the keys file. + @param onComplete a block called when the operation is done (whatever it succeeded or failed). + */ +- (void)showInViewController:(MXKViewController*)mxkViewController toImportKeys:(NSURL*)fileURL onComplete:(void(^)(void))onComplete; + +@end diff --git a/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysImportView.m b/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysImportView.m new file mode 100644 index 000000000..154cca7d9 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysImportView.m @@ -0,0 +1,122 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKEncryptionKeysImportView.h" + +#import "MXKViewController.h" +#import "NSBundle+MatrixKit.h" + +#import + +#import "MXKSwiftHeader.h" + +@interface MXKEncryptionKeysImportView () +{ + MXSession *mxSession; +} + +@end + +@implementation MXKEncryptionKeysImportView + +- (instancetype)initWithMatrixSession:(MXSession *)matrixSession +{ + self = [super init]; + if (self) + { + mxSession = matrixSession; + + _alertController = [UIAlertController alertControllerWithTitle:[MatrixKitL10n e2eImportRoomKeys] message:[MatrixKitL10n e2eImportPrompt] preferredStyle:UIAlertControllerStyleAlert]; + } + return self; +} + +- (void)showInViewController:(MXKViewController*)mxkViewController toImportKeys:(NSURL*)fileURL onComplete:(void(^)(void))onComplete +{ + __weak typeof(self) weakSelf = self; + + // Finalise the dialog + [_alertController addTextFieldWithConfigurationHandler:^(UITextField *textField) + { + textField.secureTextEntry = YES; + textField.placeholder = [MatrixKitL10n e2ePassphraseEnter]; + [textField resignFirstResponder]; + }]; + + [_alertController addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + onComplete(); + } + + }]]; + + [_alertController addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n e2eImport] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Retrieve the password + UITextField *textField = [self.alertController textFields].firstObject; + NSString *password = textField.text; + + // Start the import process + [mxkViewController startActivityIndicator]; + [self->mxSession.crypto importRoomKeys:[NSData dataWithContentsOfURL:fileURL] withPassword:password success:^(NSUInteger total, NSUInteger imported) { + + if (weakSelf) + { + [mxkViewController stopActivityIndicator]; + onComplete(); + } + + } failure:^(NSError *error) { + + if (weakSelf) + { + [mxkViewController stopActivityIndicator]; + + // TODO: i18n the error + UIAlertController *otherAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n error] message:error.localizedDescription preferredStyle:UIAlertControllerStyleAlert]; + + [otherAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { + + if (weakSelf) + { + onComplete(); + } + + }]]; + + [mxkViewController presentViewController:otherAlert animated:YES completion:nil]; + } + + }]; + } + + }]]; + + // And show it + [mxkViewController presentViewController:_alertController animated:YES completion:nil]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.h b/Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.h new file mode 100644 index 000000000..874023ebb --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.h @@ -0,0 +1,39 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +#import "MXKCellRendering.h" + +#import "MXKGroupCellDataStoring.h" + +/** + `MXKGroupTableViewCell` instances display a group. + */ +@interface MXKGroupTableViewCell : MXKTableViewCell +{ +@protected + /** + The current cell data displayed by the table view cell + */ + id groupCellData; +} + +@property (weak, nonatomic) IBOutlet UILabel *groupName; +@property (weak, nonatomic) IBOutlet UILabel *groupDescription; +@property (weak, nonatomic) IBOutlet UILabel *memberCount; + +@end diff --git a/Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.m b/Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.m new file mode 100644 index 000000000..188a4c493 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.m @@ -0,0 +1,92 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKGroupTableViewCell.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@implementation MXKGroupTableViewCell +@synthesize delegate; + +#pragma mark - Class methods + +- (void)render:(MXKCellData *)cellData +{ + groupCellData = (id)cellData; + if (groupCellData) + { + // Render the current group values. + _groupName.text = groupCellData.groupDisplayname; + _groupDescription.text = groupCellData.group.profile.shortDescription; + + if (_groupDescription.text.length) + { + _groupDescription.hidden = NO; + } + else + { + // Hide and fill the label with a fake description to harmonize the height of all the cells. + // This is a drawback of the self-sizing cell. + _groupDescription.hidden = YES; + _groupDescription.text = @"No description"; + } + + if (_memberCount) + { + if (groupCellData.group.summary.usersSection.totalUserCountEstimate > 1) + { + _memberCount.text = [MatrixKitL10n numMembersOther:@(groupCellData.group.summary.usersSection.totalUserCountEstimate).stringValue]; + } + else if (groupCellData.group.summary.usersSection.totalUserCountEstimate == 1) + { + _memberCount.text = [MatrixKitL10n numMembersOne:@(1).stringValue]; + } + else + { + _memberCount.text = nil; + } + } + } + else + { + _groupName.text = nil; + _groupDescription.text = nil; + _memberCount.text = nil; + } +} + +- (MXKCellData*)renderedCellData +{ + return groupCellData; +} + ++ (CGFloat)heightForCellData:(MXKCellData *)cellData withMaximumWidth:(CGFloat)maxWidth +{ + // The height is fixed + //@TODO: change this to handle dynamic font + return 70; +} + +- (void)prepareForReuse +{ + [super prepareForReuse]; + + groupCellData = nil; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.xib b/Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.xib new file mode 100644 index 000000000..cf6efef01 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.xib @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKCellRendering.h b/Riot/Modules/MatrixKit/Views/MXKCellRendering.h new file mode 100644 index 000000000..60d712910 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKCellRendering.h @@ -0,0 +1,121 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKCellData.h" + +@protocol MXKCellRenderingDelegate; + +/** + `MXKCellRendering` defines a protocol a view must conform to display a cell. + + A cell is a generic term. It can be a UITableViewCell or a UICollectionViewCell or any object + expected by the end view controller. + */ +@protocol MXKCellRendering + +/** + * Returns the `UINib` object initialized for the cell. + * + * @return The initialized `UINib` object or `nil` if there were errors during + * initialization or the nib file could not be located. + */ ++ (UINib *)nib; + +/** + Configure the cell in order to display the passed data. + + The object implementing the `MXKCellRendering` protocol should be able to cast the past object + into its original class. + + @param cellData the data object to render. + */ +- (void)render:(MXKCellData*)cellData; + +/** + Compute the height of the cell to display the passed data. + + @TODO: To support correctly the dynamic fonts, we have to remove this method and + its use by enabling self sizing cells at the table view level. + When we create a self-sizing table view cell, we need to set the property `estimatedRowHeight` of the table view + and use constraints to define the cell’s size. + + @param cellData the data object to render. + @param maxWidth the maximum available width. + @return the cell height + */ ++ (CGFloat)heightForCellData:(MXKCellData*)cellData withMaximumWidth:(CGFloat)maxWidth; + +@optional + +/** + User's actions delegate. + */ +@property (nonatomic, weak) id delegate; + +/** + This optional getter allow to retrieve the data object currently rendered by the cell. + + @return the current rendered data object. + */ +- (MXKCellData*)renderedCellData; + +/** + Reset the cell. + + The cell is no more displayed. This is time to release resources and removing listeners. + In case of UITableViewCell or UIContentViewCell object, the cell must reset in a state + that it can be reusable. + */ +- (void)didEndDisplay; + +@end + +/** +`MXKCellRenderingDelegate` defines a protocol used when the user has interactions with + the cell view. + */ +@protocol MXKCellRenderingDelegate + +/** + Tells the delegate that a user action (button pressed, tap, long press...) has been observed in the cell. + + The action is described by the `actionIdentifier` param. + This identifier is specific and depends to the cell view class implementing MXKCellRendering. + + @param cell the cell in which gesture has been observed. + @param actionIdentifier an identifier indicating the action type (tap, long press...) and which part of the cell is concerned. + @param userInfo a dict containing additional information. It depends on actionIdentifier. May be nil. + */ +- (void)cell:(id)cell didRecognizeAction:(NSString*)actionIdentifier userInfo:(NSDictionary *)userInfo; + +/** + Asks the delegate if a user action (click on a link) can be done. + + The action is described by the `actionIdentifier` param. + This identifier is specific and depends to the cell view class implementing MXKCellRendering. + + @param cell the cell in which gesture has been observed. + @param actionIdentifier an identifier indicating the action type (link click) and which part of the cell is concerned. + @param userInfo a dict containing additional information. It depends on actionIdentifier. May be nil. + @param defaultValue the value to return by default if the action is not handled. + @return a boolean value which depends on actionIdentifier. + */ +- (BOOL)cell:(id)cell shouldDoAction:(NSString*)actionIdentifier userInfo:(NSDictionary *)userInfo defaultValue:(BOOL)defaultValue; + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKCollectionViewCell.h b/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKCollectionViewCell.h new file mode 100644 index 000000000..3ec4f302f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKCollectionViewCell.h @@ -0,0 +1,47 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +/** + 'MXKCollectionViewCell' class is used to define custom UICollectionViewCell. + Each 'MXKCollectionViewCell-inherited' class has its own 'reuseIdentifier'. + */ +@interface MXKCollectionViewCell : UICollectionViewCell + +/** + Returns the `UINib` object initialized for the cell. + + @return The initialized `UINib` object or `nil` if there were errors during + initialization or the nib file could not be located. + */ ++ (UINib *)nib; + +/** + The default reuseIdentifier of the 'MXKCollectionViewCell-inherited' class. + */ ++ (NSString*)defaultReuseIdentifier; + +/** + Customize the rendering of the collection view cell and its subviews (Do nothing by default). + This method is called when the view is initialized or prepared for reuse. + + Override this method to customize the collection view cell at the application level. + */ +- (void)customizeCollectionViewCellRendering; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKCollectionViewCell.m b/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKCollectionViewCell.m new file mode 100644 index 000000000..e18f68927 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKCollectionViewCell.m @@ -0,0 +1,76 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKCollectionViewCell.h" + +@implementation MXKCollectionViewCell + ++ (UINib *)nib +{ + // Check whether a nib file is available + NSBundle *mainBundle = [NSBundle bundleForClass:self.class]; + NSString *path = [mainBundle pathForResource:NSStringFromClass([self class]) ofType:@"nib"]; + if (path) + { + return [UINib nibWithNibName:NSStringFromClass([self class]) bundle:mainBundle]; + } + return nil; +} + ++ (NSString*)defaultReuseIdentifier +{ + return NSStringFromClass([self class]); +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + [self customizeCollectionViewCellRendering]; +} + +- (void)prepareForReuse +{ + [super prepareForReuse]; + + [self customizeCollectionViewCellRendering]; +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + // Check whether a xib is defined + if ([[self class] nib]) + { + self = [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + self.frame = frame; + } + else + { + self = [super initWithFrame:frame]; + [self customizeCollectionViewCellRendering]; + } + + return self; +} + +- (void)customizeCollectionViewCellRendering +{ + // Do nothing by default. +} + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.h b/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.h new file mode 100644 index 000000000..932a2b4b7 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.h @@ -0,0 +1,45 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKCollectionViewCell.h" + +#import + +#import "MXKImageView.h" + +/** + 'MXKMediaCollectionViewCell' class is used to display picture or video thumbnail. + */ +@interface MXKMediaCollectionViewCell : MXKCollectionViewCell + +@property (weak, nonatomic) IBOutlet UIView *customView; +@property (weak, nonatomic) IBOutlet MXKImageView *mxkImageView; +@property (weak, nonatomic) IBOutlet UIImageView *centerIcon; +@property (weak, nonatomic) IBOutlet UIImageView *bottomLeftIcon; +@property (weak, nonatomic) IBOutlet UIImageView *bottomRightIcon; +@property (weak, nonatomic) IBOutlet UIImageView *topRightIcon; + +/** + A potential player used in the cell. + */ +@property (nonatomic) AVPlayerViewController *moviePlayer; + +/** + A potential observer used to update cell display. + */ +@property (nonatomic) id notificationObserver; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.m b/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.m new file mode 100644 index 000000000..2f19de586 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.m @@ -0,0 +1,73 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKMediaCollectionViewCell.h" + +@implementation MXKMediaCollectionViewCell + +- (void)prepareForReuse +{ + [super prepareForReuse]; + [self.moviePlayer.player pause]; + self.moviePlayer.player = nil; + self.moviePlayer = nil; + + // Restore the cell in reusable state + self.mxkImageView.hidden = NO; + self.mxkImageView.stretchable = NO; + // Cancel potential image download + self.mxkImageView.enableInMemoryCache = NO; + [self.mxkImageView setImageURI:nil + withType:nil + andImageOrientation:UIImageOrientationUp + previewImage:nil + mediaManager:nil]; + + self.customView.hidden = YES; + self.centerIcon.hidden = YES; + + // Remove added view in custon view + NSArray *subViews = self.customView.subviews; + for (UIView *view in subViews) + { + [view removeFromSuperview]; + } + + // Remove potential media download observer + if (self.notificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:self.notificationObserver]; + self.notificationObserver = nil; + } + + // Remove all gesture recognizers + while (self.gestureRecognizers.count) + { + [self removeGestureRecognizer:self.gestureRecognizers[0]]; + } + self.tag = -1; +} + +- (void)dealloc +{ + [self.moviePlayer.player pause]; + self.moviePlayer.player = nil; +} + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.xib b/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.xib new file mode 100644 index 000000000..a9934c689 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.xib @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKEventDetailsView.h b/Riot/Modules/MatrixKit/Views/MXKEventDetailsView.h new file mode 100644 index 000000000..4ab17628c --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKEventDetailsView.h @@ -0,0 +1,32 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKView.h" + +@interface MXKEventDetailsView : MXKView + +@property (weak, nonatomic) IBOutlet UITextView *textView; +@property (weak, nonatomic) IBOutlet UIButton *redactButton; +@property (weak, nonatomic) IBOutlet UIButton *closeButton; +@property (weak, nonatomic) IBOutlet UIActivityIndicatorView *activityIndicator; + +- (instancetype)initWithEvent:(MXEvent*)event andMatrixSession:(MXSession*)session; + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKEventDetailsView.m b/Riot/Modules/MatrixKit/Views/MXKEventDetailsView.m new file mode 100644 index 000000000..59ed20691 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKEventDetailsView.m @@ -0,0 +1,204 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKEventDetailsView.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKConstants.h" + +#import "MXKSwiftHeader.h" + +@interface MXKEventDetailsView () +{ + /** + The displayed event + */ + MXEvent *mxEvent; + + /** + The matrix session. + */ + MXSession *mxSession; +} +@end + +@implementation MXKEventDetailsView + ++ (UINib *)nib +{ + // Check whether a nib file is available + NSBundle *mainBundle = [NSBundle mxk_bundleForClass:self.class]; + + NSString *path = [mainBundle pathForResource:NSStringFromClass([self class]) ofType:@"nib"]; + if (path) + { + return [UINib nibWithNibName:NSStringFromClass([self class]) bundle:mainBundle]; + } + return [UINib nibWithNibName:NSStringFromClass([MXKEventDetailsView class]) bundle:[NSBundle mxk_bundleForClass:[MXKEventDetailsView class]]]; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + // Localize string + [_redactButton setTitle:[MatrixKitL10n redact] forState:UIControlStateNormal]; + [_redactButton setTitle:[MatrixKitL10n redact] forState:UIControlStateHighlighted]; + [_closeButton setTitle:[MatrixKitL10n close] forState:UIControlStateNormal]; + [_closeButton setTitle:[MatrixKitL10n close] forState:UIControlStateHighlighted]; +} + +- (instancetype)initWithEvent:(MXEvent*)event andMatrixSession:(MXSession*)session +{ + self = [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + if (self) + { + mxEvent = event; + mxSession = session; + + [self setTranslatesAutoresizingMaskIntoConstraints: NO]; + + // Disable redact button by default + _redactButton.enabled = NO; + + if (mxEvent) + { + NSMutableDictionary *eventDict = [NSMutableDictionary dictionaryWithDictionary:mxEvent.JSONDictionary]; + + // Remove event type added by SDK + [eventDict removeObjectForKey:@"event_type"]; + // Remove null values and empty dictionaries + for (NSString *key in eventDict.allKeys) + { + if ([[eventDict objectForKey:key] isEqual:[NSNull null]]) + { + [eventDict removeObjectForKey:key]; + } + else if ([[eventDict objectForKey:key] isKindOfClass:[NSDictionary class]]) + { + NSDictionary *dict = [eventDict objectForKey:key]; + if (!dict.count) + { + [eventDict removeObjectForKey:key]; + } + else + { + NSMutableDictionary *updatedDict = [NSMutableDictionary dictionaryWithDictionary:dict]; + for (NSString *subKey in dict.allKeys) + { + if ([[dict objectForKey:subKey] isEqual:[NSNull null]]) + { + [updatedDict removeObjectForKey:subKey]; + } + } + [eventDict setObject:updatedDict forKey:key]; + } + } + } + + // Set text view content + NSError *error; + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:eventDict + options:NSJSONWritingPrettyPrinted + error:&error]; + _textView.text = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + + // Check whether the user can redact this event + // Do not allow to redact the event that enabled encryption (m.room.encryption) + // because it breaks everything + if (!mxEvent.isRedactedEvent && mxEvent.eventType != MXEventTypeRoomEncryption) + { + // Here the event has not been already redacted, check the user's power level + MXRoom *mxRoom = [mxSession roomWithRoomId:mxEvent.roomId]; + if (mxRoom) + { + MXWeakify(self); + [mxRoom state:^(MXRoomState *roomState) { + MXStrongifyAndReturnIfNil(self); + + MXRoomPowerLevels *powerLevels = [roomState powerLevels]; + NSInteger userPowerLevel = [powerLevels powerLevelOfUserWithUserID:self->mxSession.myUser.userId]; + if (powerLevels.redact) + { + if (userPowerLevel >= powerLevels.redact) + { + self.redactButton.enabled = YES; + } + } + else if (userPowerLevel >= [powerLevels minimumPowerLevelForSendingEventAsMessage:kMXEventTypeStringRoomRedaction]) + { + self.redactButton.enabled = YES; + } + }]; + } + } + } + else + { + _textView.text = nil; + } + + // Hide potential activity indicator + [_activityIndicator stopAnimating]; + } + + return self; +} + +- (void)dealloc +{ + mxEvent = nil; + mxSession = nil; +} + +#pragma mark - Actions + +- (IBAction)onButtonPressed:(id)sender +{ + if (sender == _redactButton) + { + MXRoom *mxRoom = [mxSession roomWithRoomId:mxEvent.roomId]; + if (mxRoom) + { + [_activityIndicator startAnimating]; + [mxRoom redactEvent:mxEvent.eventId reason:nil success:^{ + + [self->_activityIndicator stopAnimating]; + [self removeFromSuperview]; + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKEventDetailsView] Redact event (%@) failed", self->mxEvent.eventId); + + // Notify MatrixKit user + NSString *myUserId = mxRoom.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + [self->_activityIndicator stopAnimating]; + + }]; + } + + } + else if (sender == _closeButton) + { + [self removeFromSuperview]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKEventDetailsView.xib b/Riot/Modules/MatrixKit/Views/MXKEventDetailsView.xib new file mode 100644 index 000000000..71685321f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKEventDetailsView.xib @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKImageView.h b/Riot/Modules/MatrixKit/Views/MXKImageView.h new file mode 100644 index 000000000..ddda448c3 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKImageView.h @@ -0,0 +1,126 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import "MXKView.h" + +@class MXKAttachment; + +/** + Customize UIView in order to display image defined with remote url. Zooming inside the image (Stretching) is supported. + */ +@interface MXKImageView : MXKView + +typedef void (^blockMXKImageView_onClick)(MXKImageView *imageView, NSString* title); + +/** + Load an image by its Matrix Content URI. + The image is loaded from the media cache (if any). If the image is not available yet, + it is downloaded from the Matrix media repository only if a media manager instance is provided. + + The image extension is extracted from the provided mime type (if any). By default 'image/jpeg' is considered. + + @param mxContentURI the Matrix Content URI + @param mimeType the media mime type, it is used to define the file extension (may be nil). + @param orientation the actual orientation of the encoded image (used UIImageOrientationUp by default). + @param previewImage image displayed until the actual image is available. + @param mediaManager the media manager instance used to download the image if it is not already in cache. + */ +- (void)setImageURI:(NSString *)mxContentURI + withType:(NSString *)mimeType +andImageOrientation:(UIImageOrientation)orientation + previewImage:(UIImage*)previewImage + mediaManager:(MXMediaManager*)mediaManager; + +/** + Load an image by its Matrix Content URI to fit a specific view size. + + CAUTION: this method is available only for the unencrypted content. + + The image is loaded from the media cache (if any). If the image is not available yet, + it is downloaded from the Matrix media repository only if a media manager instance is provided. + The image extension is extracted from the provided mime type (if any). By default 'image/jpeg' is considered. + + @param mxContentURI the Matrix Content URI + @param mimeType the media mime type, it is used to define the file extension (may be nil). + @param orientation the actual orientation of the encoded image (used UIImageOrientationUp by default). + @param previewImage image displayed until the actual image is available. + @param mediaManager the media manager instance used to download the image if it is not already in cache. + */ +- (void)setImageURI:(NSString *)mxContentURI + withType:(NSString *)mimeType +andImageOrientation:(UIImageOrientation)orientation + toFitViewSize:(CGSize)viewSize + withMethod:(MXThumbnailingMethod)thumbnailingMethod + previewImage:(UIImage*)previewImage + mediaManager:(MXMediaManager*)mediaManager; + +/** + * Load an image attachment into the image viewer and display the full res image. + * This method must be used to display encrypted attachments + * @param attachment The attachment + */ +- (void)setAttachment:(MXKAttachment *)attachment; + +/** + * Load an attachment into the image viewer and display its thumbnail, if it has one. + * This method must be used to display encrypted attachments + * @param attachment The attachment + */ +- (void)setAttachmentThumb:(MXKAttachment *)attachment; + +/** + Toggle display to fullscreen. + + No change is applied on the status bar here, the caller has to handle it. + */ +- (void)showFullScreen; + +/** + The default background color. + Default is [UIColor blackColor]. + */ +@property (nonatomic) UIColor *defaultBackgroundColor; + +// Use this boolean to hide activity indicator during image downloading +@property (nonatomic) BOOL hideActivityIndicator; + +@property (strong, nonatomic) UIImage *image; +@property (nonatomic, readonly) UIImageView *imageView; + +@property (nonatomic) BOOL stretchable; +@property (nonatomic, readonly) BOOL fullScreen; + +// the image is cached in memory. +// The medias manager uses a LRU cache. +// to avoid loading from the file system. +@property (nonatomic) BOOL enableInMemoryCache; + +// mediaManager folder where the image is stored +@property (nonatomic) NSString* mediaFolder; + +// Let the user defines some custom buttons over the tabbar +- (void)setLeftButtonTitle :leftButtonTitle handler:(blockMXKImageView_onClick)handler; +- (void)setRightButtonTitle:rightButtonTitle handler:(blockMXKImageView_onClick)handler; + +- (void)dismissSelection; + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKImageView.m b/Riot/Modules/MatrixKit/Views/MXKImageView.m new file mode 100644 index 000000000..92060afc6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKImageView.m @@ -0,0 +1,925 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKImageView.h" +#import "MXKPieChartView.h" +#import "MXKAttachment.h" + +#import "MXKTools.h" + +@interface MXKImageView () +{ + NSString *mxcURI; + NSString *mimeType; + UIImageOrientation imageOrientation; + + // additional settings used in case of thumbnail. + CGSize thumbnailViewSize; + MXThumbnailingMethod thumbnailMethod; + + UIImage *currentImage; + + // the loading view is composed with the spinner and a pie chart + // the spinner is display until progress > 0 + UIView *loadingView; + UIActivityIndicatorView *waitingDownloadSpinner; + MXKPieChartView *pieChartView; + UILabel *progressInfoLabel; + + // validation buttons + UIButton *leftButton; + UIButton *rightButton; + + NSString *leftButtonTitle; + NSString *rightButtonTitle; + + blockMXKImageView_onClick leftHandler; + blockMXKImageView_onClick rightHandler; + + UIView* bottomBarView; + + // Subviews + UIScrollView *scrollView; + + // Current attachment being displayed in the MXKImageView + MXKAttachment *currentAttachment; +} +@end + +@implementation MXKImageView +@synthesize stretchable, mediaFolder, imageView; + +#define CUSTOM_IMAGE_VIEW_BUTTON_WIDTH 100 + +- (void)dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + [self stopActivityIndicator]; + + if (loadingView) + { + [loadingView removeFromSuperview]; + loadingView = nil; + } + + if (bottomBarView) + { + [bottomBarView removeFromSuperview]; + bottomBarView = nil; + } + + pieChartView = nil; +} + +#pragma mark - Override MXKView + +-(void)customizeViewRendering +{ + [super customizeViewRendering]; + + self.backgroundColor = (_defaultBackgroundColor ? _defaultBackgroundColor : [UIColor blackColor]); + + self.contentMode = UIViewContentModeScaleAspectFit; + self.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleTopMargin; +} + +#pragma mark - + +- (void)startActivityIndicator +{ + // create the views if they don't exist + if (!waitingDownloadSpinner) + { + waitingDownloadSpinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhite]; + + CGRect frame = waitingDownloadSpinner.frame; + frame.size.width += 30; + frame.size.height += 30; + waitingDownloadSpinner.bounds = frame; + [waitingDownloadSpinner.layer setCornerRadius:5]; + } + + if (!loadingView) + { + loadingView = [[UIView alloc] init]; + loadingView.frame = waitingDownloadSpinner.bounds; + waitingDownloadSpinner.frame = waitingDownloadSpinner.bounds; + [loadingView addSubview:waitingDownloadSpinner]; + loadingView.backgroundColor = [UIColor clearColor]; + [self addSubview:loadingView]; + } + + if (!pieChartView) + { + pieChartView = [[MXKPieChartView alloc] init]; + pieChartView.frame = loadingView.bounds; + pieChartView.progress = 0; + pieChartView.progressColor = [UIColor colorWithRed:1 green:1 blue:1 alpha:0.25]; + pieChartView.unprogressColor = [UIColor clearColor]; + + [loadingView addSubview:pieChartView]; + } + + // display the download statistics + if (_fullScreen && !progressInfoLabel) + { + progressInfoLabel = [[UILabel alloc] init]; + progressInfoLabel.backgroundColor = [UIColor whiteColor]; + progressInfoLabel.textColor = [UIColor blackColor]; + progressInfoLabel.font = [UIFont systemFontOfSize:8]; + progressInfoLabel.alpha = 0.25; + progressInfoLabel.text = @""; + progressInfoLabel.numberOfLines = 0; + [progressInfoLabel sizeToFit]; + [self addSubview:progressInfoLabel]; + } + + // initvalue + loadingView.hidden = NO; + pieChartView.progress = 0; + + // Adjust color + if ([self.backgroundColor isEqual:[UIColor blackColor]]) + { + waitingDownloadSpinner.activityIndicatorViewStyle = UIActivityIndicatorViewStyleWhite; + // a preview image could be displayed + // ensure that the white spinner is visible + // it could be drawn on a white area + waitingDownloadSpinner.backgroundColor = [UIColor darkGrayColor]; + + } + else + { + waitingDownloadSpinner.activityIndicatorViewStyle = UIActivityIndicatorViewStyleGray; + } + + // ensure that the spinner is drawn at the top + [loadingView.superview bringSubviewToFront:loadingView]; + + // Adjust position + CGPoint center = CGPointMake(self.frame.size.width / 2, self.frame.size.height / 2); + loadingView.center = center; + + // Start + [waitingDownloadSpinner startAnimating]; +} + +- (void)stopActivityIndicator +{ + if (waitingDownloadSpinner && waitingDownloadSpinner.isAnimating) + { + [waitingDownloadSpinner stopAnimating]; + } + + pieChartView.progress = 0; + loadingView.hidden = YES; + + if (progressInfoLabel) + { + [progressInfoLabel removeFromSuperview]; + progressInfoLabel = nil; + } +} + +#pragma mark - setters/getters + +- (void)setDefaultBackgroundColor:(UIColor *)defaultBackgroundColor +{ + _defaultBackgroundColor = defaultBackgroundColor; + [self customizeViewRendering]; +} + +- (void)setImage:(UIImage *)anImage +{ + // remove the observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + currentImage = anImage; + imageView.image = anImage; + + [self initScrollZoomFactors]; +} + +- (UIImage*)image +{ + return currentImage; +} + +- (void)showFullScreen +{ + // The full screen display mode is supported only if the shared application instance is available. + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication) + { + _fullScreen = YES; + + [self initLayout]; + + if (self.superview) + { + [super removeFromSuperview]; + } + + UIWindow *window = [sharedApplication keyWindow]; + + self.frame = window.bounds; + [window addSubview:self]; + } +} + +#pragma mark - +- (IBAction)onButtonToggle:(id)sender +{ + if (sender == leftButton) + { + dispatch_async(dispatch_get_main_queue(), ^{ + self->leftHandler(self, self->leftButtonTitle); + }); + } + else if (sender == rightButton) + { + dispatch_async(dispatch_get_main_queue(), ^{ + self->rightHandler(self, self->rightButtonTitle); + }); + } +} + +// add a generic button to the bottom view +// return the added UIButton +- (UIButton*) addbuttonWithTitle:(NSString*)title +{ + UIButton* button = [[UIButton alloc] init]; + [button setTitle:title forState:UIControlStateNormal]; + [button setTitle:title forState:UIControlStateHighlighted]; + + if (_fullScreen) + { + // use the same text color as the tabbar + [button setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; + [button setTitleColor:[UIColor whiteColor] forState:UIControlStateHighlighted]; + } + // TODO + // else { + // // use the same text color as the tabbar + // [button setTitleColor:[AppDelegate theDelegate].masterTabBarController.tabBar.tintColor forState:UIControlStateNormal]; + // [button setTitleColor:[AppDelegate theDelegate].masterTabBarController.tabBar.tintColor forState:UIControlStateHighlighted]; + // } + + // keep the bottomView background color + button.backgroundColor = [UIColor clearColor]; + + [button addTarget:self action:@selector(onButtonToggle:) forControlEvents:UIControlEventTouchUpInside]; + [bottomBarView addSubview:button]; + + return button; +} + +- (void)initScrollZoomFactors +{ + // check if the image can be zoomed + if (self.image && self.stretchable && imageView.frame.size.width && imageView.frame.size.height) + { + // ensure that the content size is properly initialized + scrollView.contentSize = scrollView.frame.size; + + // compute the appliable zoom factor + // assume that the user does not expect to zoom more than 100% + CGSize imageSize = self.image.size; + + CGFloat scaleX = imageSize.width / imageView.frame.size.width; + CGFloat scaleY = imageSize.height / imageView.frame.size.height; + + if (scaleX < scaleY) + { + scaleX = scaleY; + } + + if (scaleX < 1.0) + { + scaleX = 1.0; + } + + scrollView.zoomScale = 1.0; + scrollView.minimumZoomScale = 1.0; + scrollView.maximumZoomScale = scaleX; + + // update the image frame to ensure that it fits to the scrollview frame + imageView.frame = scrollView.bounds; + } +} + +- (void)removeFromSuperview +{ + [super removeFromSuperview]; + + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + if (pieChartView) + { + [self stopActivityIndicator]; + } +} + +- (void)initLayout +{ + // create the subviews if they don't exist + if (!scrollView) + { + scrollView = [[UIScrollView alloc] init]; + scrollView.delegate = self; + scrollView.backgroundColor = [UIColor clearColor]; + [self addSubview:scrollView]; + + imageView = [[UIImageView alloc] init]; + imageView.backgroundColor = [UIColor clearColor]; + imageView.userInteractionEnabled = YES; + imageView.contentMode = self.contentMode; + [scrollView addSubview:imageView]; + } +} + +- (void)layoutSubviews +{ + // call upper layer + [super layoutSubviews]; + + [self initLayout]; + + // the image has been updated + if (imageView.image != self.image) + { + imageView.image = self.image; + } + + CGRect tabBarFrame = CGRectZero; + UITabBarController *tabBarController = nil; + UIEdgeInsets safeAreaInsets = UIEdgeInsetsZero; + + if (leftButtonTitle || rightButtonTitle) + { + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication) + { + safeAreaInsets = [sharedApplication keyWindow].safeAreaInsets; + + UIViewController *rootViewController = [sharedApplication keyWindow].rootViewController; + tabBarController = rootViewController.tabBarController; + if (!tabBarController && [rootViewController isKindOfClass:[UITabBarController class]]) + { + tabBarController = (UITabBarController*)rootViewController; + } + } + + if (tabBarController) + { + tabBarFrame = tabBarController.tabBar.frame; + } + else + { + // Define a default tabBar frame + tabBarFrame = CGRectMake(0, 0, self.frame.size.width, 44 + safeAreaInsets.bottom); + } + } + + // update the scrollview frame + CGRect oneSelfFrame = self.frame; + CGRect scrollViewFrame = CGRectIntegral(scrollView.frame); + + if (leftButtonTitle || rightButtonTitle) + { + oneSelfFrame.size.height -= tabBarFrame.size.height; + } + + oneSelfFrame = CGRectIntegral(oneSelfFrame); + oneSelfFrame.origin = scrollViewFrame.origin = CGPointZero; + + // use integral rect to avoid rounded value issue (float precision) + if (!CGRectEqualToRect(oneSelfFrame, scrollViewFrame)) + { + scrollView.frame = oneSelfFrame; + imageView.frame = oneSelfFrame; + + [self initScrollZoomFactors]; + } + + // check if the dedicated buttons are already added + if (leftButtonTitle || rightButtonTitle) + { + + if (!bottomBarView) + { + bottomBarView = [[UIView alloc] init]; + + if (leftButtonTitle) + { + leftButton = [self addbuttonWithTitle:leftButtonTitle]; + } + + rightButton = [[UIButton alloc] init]; + + if (rightButtonTitle) + { + rightButton = [self addbuttonWithTitle:rightButtonTitle]; + } + + // in fullscreen, display both buttons above the view (do the same if there is no tab bar) + if (_fullScreen || tabBarController == nil) + { + bottomBarView.backgroundColor = [UIColor blackColor]; + [self addSubview:bottomBarView]; + } + else + { + // default tabbar background color + CGFloat base = 248.0 / 255.0f; + bottomBarView.backgroundColor = [UIColor colorWithRed:base green:base blue:base alpha:1.0]; + + // Display them over the tabbar + [tabBarController.tabBar addSubview:bottomBarView]; + } + } + + if (_fullScreen) + { + tabBarFrame.origin.y = self.frame.size.height - tabBarFrame.size.height; + } + else + { + tabBarFrame.origin.y = 0; + } + bottomBarView.frame = tabBarFrame; + + if (leftButton) + { + leftButton.frame = CGRectMake(safeAreaInsets.left, 0, CUSTOM_IMAGE_VIEW_BUTTON_WIDTH, bottomBarView.frame.size.height - safeAreaInsets.bottom); + } + + if (rightButton) + { + rightButton.frame = CGRectMake(bottomBarView.frame.size.width - CUSTOM_IMAGE_VIEW_BUTTON_WIDTH - safeAreaInsets.right, 0, CUSTOM_IMAGE_VIEW_BUTTON_WIDTH, bottomBarView.frame.size.height - safeAreaInsets.bottom); + } + } + + if (!loadingView.hidden) + { + // ensure that the spinner is drawn at the top + [loadingView.superview bringSubviewToFront:loadingView]; + + // Adjust positions + CGPoint center = CGPointMake(self.frame.size.width / 2, self.frame.size.height / 2); + loadingView.center = center; + + CGRect progressInfoLabelFrame = progressInfoLabel.frame; + progressInfoLabelFrame.origin.x = center.x - (progressInfoLabelFrame.size.width / 2); + progressInfoLabelFrame.origin.y = 10 + loadingView.frame.origin.y + loadingView.frame.size.height; + progressInfoLabel.frame = progressInfoLabelFrame; + } +} + +- (void)setHideActivityIndicator:(BOOL)hideActivityIndicator +{ + _hideActivityIndicator = hideActivityIndicator; + if (hideActivityIndicator) + { + [self stopActivityIndicator]; + } + else if (mxcURI) + { + NSString *downloadId = [MXMediaManager downloadIdForMatrixContentURI:mxcURI inFolder:mediaFolder]; + if ([MXMediaManager existingDownloaderWithIdentifier:downloadId]) + { + // Loading is in progress, start activity indicator + [self startActivityIndicator]; + } + } +} + +- (void)setImageURI:(NSString *)mxContentURI + withType:(NSString *)mimeType +andImageOrientation:(UIImageOrientation)orientation + previewImage:(UIImage*)previewImage + mediaManager:(MXMediaManager*)mediaManager +{ + [self setImageURI:mxContentURI + withType:mimeType + andImageOrientation:orientation + isThumbnail:NO + previewImage:previewImage + mediaManager:mediaManager]; +} + +- (void)setImageURI:(NSString *)mxContentURI + withType:(NSString *)mimeType +andImageOrientation:(UIImageOrientation)orientation + toFitViewSize:(CGSize)viewSize + withMethod:(MXThumbnailingMethod)thumbnailingMethod + previewImage:(UIImage*)previewImage + mediaManager:(MXMediaManager*)mediaManager +{ + // Store the thumbnail settings + thumbnailViewSize = viewSize; + thumbnailMethod = thumbnailingMethod; + + [self setImageURI:mxContentURI + withType:mimeType + andImageOrientation:orientation + isThumbnail:YES + previewImage:previewImage + mediaManager:mediaManager]; +} + +- (void)setImageURI:(NSString *)mxContentURI + withType:(NSString *)mimeType +andImageOrientation:(UIImageOrientation)orientation + isThumbnail:(BOOL)isThumbnail + previewImage:(UIImage*)previewImage + mediaManager:(MXMediaManager*)mediaManager +{ + // Remove any pending observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + // Reset other data + currentAttachment = nil; + + mxcURI = mxContentURI; + if (!mxcURI) + { + // Set preview by default + self.image = previewImage; + return; + } + + // Store image orientation + imageOrientation = orientation; + + // Store the mime type used to define the cache path of the image. + mimeType = mimeType; + if (!mimeType.length) + { + // Set default mime type if no information is available + mimeType = @"image/jpeg"; + } + + // Retrieve the image from cache if any + NSString *cacheFilePath; + if (isThumbnail) + { + cacheFilePath = [MXMediaManager thumbnailCachePathForMatrixContentURI:mxcURI + andType:mimeType + inFolder:mediaFolder + toFitViewSize:thumbnailViewSize + withMethod:thumbnailMethod]; + } + else + { + cacheFilePath = [MXMediaManager cachePathForMatrixContentURI:mxcURI + andType:mimeType + inFolder:mediaFolder]; + } + + UIImage* image = _enableInMemoryCache ? [MXMediaManager loadThroughCacheWithFilePath:cacheFilePath] : [MXMediaManager loadPictureFromFilePath:cacheFilePath]; + if (image) + { + if (imageOrientation != UIImageOrientationUp) + { + self.image = [UIImage imageWithCGImage:image.CGImage scale:1.0 orientation:imageOrientation]; + } + else + { + self.image = image; + } + + [self stopActivityIndicator]; + } + else + { + // Set preview until the image is loaded + self.image = previewImage; + + // Check whether the image download is in progress + NSString *downloadId; + if (isThumbnail) + { + downloadId = [MXMediaManager thumbnailDownloadIdForMatrixContentURI:mxcURI + inFolder:mediaFolder + toFitViewSize:thumbnailViewSize + withMethod:thumbnailMethod]; + } + else + { + downloadId = [MXMediaManager downloadIdForMatrixContentURI:mxcURI inFolder:mediaFolder]; + } + + MXMediaLoader* loader = [MXMediaManager existingDownloaderWithIdentifier:downloadId]; + if (!loader && mediaManager) + { + // Trigger the download + if (isThumbnail) + { + loader = [mediaManager downloadThumbnailFromMatrixContentURI:mxcURI + withType:mimeType + inFolder:mediaFolder + toFitViewSize:thumbnailViewSize + withMethod:thumbnailMethod + success:nil + failure:nil]; + } + else + { + loader = [mediaManager downloadMediaFromMatrixContentURI:mxcURI + withType:mimeType + inFolder:mediaFolder]; + } + } + + if (loader) + { + // update the progress UI with the current info + if (!_hideActivityIndicator) + { + [self startActivityIndicator]; + } + [self updateProgressUI:loader.statisticsDict]; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:loader]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMediaLoaderStateDidChange:) name:kMXMediaLoaderStateDidChangeNotification object:loader]; + } + } +} + +- (void)setAttachment:(MXKAttachment *)attachment +{ + // Remove any pending observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + // Set default orientation + imageOrientation = UIImageOrientationUp; + + mediaFolder = attachment.eventRoomId; + mxcURI = attachment.contentURL; + mimeType = attachment.contentInfo[@"mimetype"]; + if (!mimeType.length) + { + // Set default mime type if no information is available + mimeType = @"image/jpeg"; + } + + // while we wait for the content to download + self.image = [attachment getCachedThumbnail]; + + if (!_hideActivityIndicator) + { + [self startActivityIndicator]; + } + + currentAttachment = attachment; + + MXWeakify(self); + [attachment getImage:^(MXKAttachment *attachment2, UIImage *img) { + MXStrongifyAndReturnIfNil(self); + + if (self->currentAttachment != attachment2) + { + return; + } + + self.image = img; + [self stopActivityIndicator]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:nil]; + } failure:^(MXKAttachment *attachment2, NSError *error) { + MXLogDebug(@"Unable to fetch image attachment! %@", error); + MXStrongifyAndReturnIfNil(self); + + if (self->currentAttachment != attachment2) + { + return; + } + + [self stopActivityIndicator]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:nil]; + }]; + + // Check whether the image download is in progress + NSString *downloadId = attachment.downloadId; + MXMediaLoader* loader = [MXMediaManager existingDownloaderWithIdentifier:downloadId]; + if (loader) + { + // Observer this loader to display progress + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(checkProgressOnMediaLoaderStateChange:) + name:kMXMediaLoaderStateDidChangeNotification + object:loader]; + } +} + +- (void)setAttachmentThumb:(MXKAttachment *)attachment +{ + // Remove any pending observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + // Store image orientation + imageOrientation = attachment.thumbnailOrientation; + + mediaFolder = attachment.eventRoomId; + + // Remove the existing image (if any) by using the potential preview. + self.image = attachment.previewImage; + + if (!_hideActivityIndicator) + { + [self startActivityIndicator]; + } + + currentAttachment = attachment; + + MXWeakify(self); + [attachment getThumbnail:^(MXKAttachment *attachment2, UIImage *img) { + MXStrongifyAndReturnIfNil(self); + + dispatch_async(dispatch_get_main_queue(), ^{ + + if (self->currentAttachment != attachment2) + { + return; + } + + if (img && self->imageOrientation != UIImageOrientationUp) + { + self.image = [UIImage imageWithCGImage:img.CGImage scale:1.0 orientation:self->imageOrientation]; + } + else + { + self.image = img; + } + [self stopActivityIndicator]; + }); + + } failure:^(MXKAttachment *attachment2, NSError *error) { + MXStrongifyAndReturnIfNil(self); + + dispatch_async(dispatch_get_main_queue(), ^{ + if (self->currentAttachment != attachment2) + { + return; + } + + [self stopActivityIndicator]; + }); + }]; +} + +- (void)updateProgressUI:(NSDictionary*)downloadStatsDict +{ + // Sanity check: updateProgressUI may be called while there is no stats available + // This happens when the download failed at the very beginning. + if (nil == downloadStatsDict) + { + return; + } + + NSNumber* progressNumber = [downloadStatsDict valueForKey:kMXMediaLoaderProgressValueKey]; + + if (progressNumber) + { + pieChartView.progress = progressNumber.floatValue; + waitingDownloadSpinner.hidden = YES; + } + + if (progressInfoLabel) + { + NSNumber* downloadRate = [downloadStatsDict valueForKey:kMXMediaLoaderCurrentDataRateKey]; + + NSNumber* completedBytesCount = [downloadStatsDict valueForKey:kMXMediaLoaderCompletedBytesCountKey]; + NSNumber* totalBytesCount = [downloadStatsDict valueForKey:kMXMediaLoaderTotalBytesCountKey]; + + NSMutableString* text = [[NSMutableString alloc] init]; + + if (completedBytesCount && totalBytesCount) + { + NSString* progressString = [NSString stringWithFormat:@"%@ / %@", [NSByteCountFormatter stringFromByteCount:completedBytesCount.longLongValue countStyle:NSByteCountFormatterCountStyleFile], [NSByteCountFormatter stringFromByteCount:totalBytesCount.longLongValue countStyle:NSByteCountFormatterCountStyleFile]]; + + [text appendString:progressString]; + } + + if (downloadRate) + { + if (completedBytesCount && totalBytesCount) + { + CGFloat remainimgTime = ((totalBytesCount.floatValue - completedBytesCount.floatValue)) / downloadRate.floatValue; + [text appendFormat:@" (%@)", [MXKTools formatSecondsInterval:remainimgTime]]; + } + + [text appendFormat:@"\n %@/s", [NSByteCountFormatter stringFromByteCount:downloadRate.longLongValue countStyle:NSByteCountFormatterCountStyleFile]]; + } + + progressInfoLabel.text = text; + + // on multilines, sizeToFit uses the current width + // so reset it + progressInfoLabel.frame = CGRectZero; + + [progressInfoLabel sizeToFit]; + + // + CGRect progressInfoLabelFrame = progressInfoLabel.frame; + progressInfoLabelFrame.origin.x = self.center.x - (progressInfoLabelFrame.size.width / 2); + progressInfoLabelFrame.origin.y = 10 + loadingView.frame.origin.y + loadingView.frame.size.height; + progressInfoLabel.frame = progressInfoLabelFrame; + } +} + +- (void)onMediaLoaderStateDidChange:(NSNotification *)notif +{ + MXMediaLoader *loader = (MXMediaLoader*)notif.object; + switch (loader.state) { + case MXMediaLoaderStateDownloadInProgress: + [self updateProgressUI:loader.statisticsDict]; + break; + case MXMediaLoaderStateDownloadCompleted: + { + [self stopActivityIndicator]; + // update the image + UIImage* image = [MXMediaManager loadPictureFromFilePath:loader.downloadOutputFilePath]; + if (image) + { + if (imageOrientation != UIImageOrientationUp) + { + self.image = [UIImage imageWithCGImage:image.CGImage scale:1.0 orientation:imageOrientation]; + } + else + { + self.image = image; + } + } + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:loader]; + break; + } + case MXMediaLoaderStateDownloadFailed: + case MXMediaLoaderStateCancelled: + [self stopActivityIndicator]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:loader]; + break; + default: + break; + } +} + +- (void)checkProgressOnMediaLoaderStateChange:(NSNotification *)notif +{ + MXMediaLoader *loader = (MXMediaLoader*)notif.object; + switch (loader.state) { + case MXMediaLoaderStateDownloadInProgress: + [self updateProgressUI:loader.statisticsDict]; + break; + default: + break; + } +} + +#pragma mark - buttons management + +- (void)setLeftButtonTitle: aLeftButtonTitle handler:(blockMXKImageView_onClick)handler +{ + leftButtonTitle = aLeftButtonTitle; + leftHandler = handler; +} + +- (void)setRightButtonTitle:aRightButtonTitle handler:(blockMXKImageView_onClick)handler +{ + rightButtonTitle = aRightButtonTitle; + rightHandler = handler; +} + +- (void)dismissSelection +{ + if (bottomBarView) + { + [bottomBarView removeFromSuperview]; + bottomBarView = nil; + } +} + +#pragma mark - UIScrollViewDelegate +// require to be able to zoom an image +- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView +{ + return self.stretchable ? imageView : nil; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKMessageTextView.h b/Riot/Modules/MatrixKit/Views/MXKMessageTextView.h new file mode 100644 index 000000000..12adffc9f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKMessageTextView.h @@ -0,0 +1,31 @@ +/* + Copyright 2019 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + MXKMessageTextView is a UITextView subclass with link detection without text selection. + */ +@interface MXKMessageTextView : UITextView + +// The last hit test location received by the view. +@property (nonatomic, readonly) CGPoint lastHitTestLocation; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/MatrixKit/Views/MXKMessageTextView.m b/Riot/Modules/MatrixKit/Views/MXKMessageTextView.m new file mode 100644 index 000000000..d68ff2cba --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKMessageTextView.m @@ -0,0 +1,57 @@ +/* + Copyright 2019 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKMessageTextView.h" +#import "UITextView+MatrixKit.h" + +@interface MXKMessageTextView() + +@property (nonatomic, readwrite) CGPoint lastHitTestLocation; + +@end + + +@implementation MXKMessageTextView + +- (BOOL)canBecomeFirstResponder +{ + return NO; +} + +- (BOOL)canPerformAction:(SEL)action withSender:(id)sender +{ + return NO; +} + +- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event +{ + self.lastHitTestLocation = point; + return [super hitTest:point withEvent:event]; +} + +// Indicate to receive a touch event only if a link is hitted. +// Otherwise it means that the touch event will pass through and could be received by a view below. +- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event +{ + if (![super pointInside:point withEvent:event]) + { + return NO; + } + + return [self isThereALinkNearPoint:point]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKPieChartHUD.h b/Riot/Modules/MatrixKit/Views/MXKPieChartHUD.h new file mode 100644 index 000000000..8a6d22d53 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKPieChartHUD.h @@ -0,0 +1,26 @@ +/* + Copyright 2017 Aram Sargsyan + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import "MXKView.h" + +@interface MXKPieChartHUD : MXKView + ++ (MXKPieChartHUD *)showLoadingHudOnView:(UIView *)view WithMessage:(NSString *)message; + +- (void)setProgress:(CGFloat)progress; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKPieChartHUD.m b/Riot/Modules/MatrixKit/Views/MXKPieChartHUD.m new file mode 100644 index 000000000..7a7718837 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKPieChartHUD.m @@ -0,0 +1,115 @@ +/* + Copyright 2017 Aram Sargsyan + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKPieChartHUD.h" +#import "NSBundle+MatrixKit.h" +#import "MXKPieChartView.h" + +@interface MXKPieChartHUD () + +@property (weak, nonatomic) IBOutlet UIView *hudView; +@property (weak, nonatomic) IBOutlet MXKPieChartView *pieChartView; +@property (weak, nonatomic) IBOutlet UILabel *titleLabel; + + +@end + +@implementation MXKPieChartHUD + +#pragma mark - Lifecycle + +- (instancetype)initWithFrame:(CGRect)frame +{ + self = [super initWithFrame:frame]; + if (self) + { + [self configureFromNib]; + } + return self; +} + +- (instancetype)initWithCoder:(NSCoder *)aDecoder +{ + self = [super initWithCoder:aDecoder]; + if (self) + { + [self configureFromNib]; + } + return self; +} + +- (void)configureFromNib +{ + NSBundle *bundle = [NSBundle mxk_bundleForClass:self.class]; + [bundle loadNibNamed:NSStringFromClass(self.class) owner:self options:nil]; + [self customizeViewRendering]; + + self.hudView.frame = self.bounds; + + self.clipsToBounds = YES; + self.layer.cornerRadius = 10.0; + + [self addSubview:self.hudView]; +} + +- (void)customizeViewRendering +{ + [super customizeViewRendering]; + + self.pieChartView.backgroundColor = [UIColor clearColor]; + self.pieChartView.progressColor = [UIColor whiteColor]; + self.pieChartView.unprogressColor = [UIColor clearColor]; + self.pieChartView.tintColor = [UIColor whiteColor]; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + [self.pieChartView.layer setCornerRadius:self.pieChartView.frame.size.width / 2]; +} + +#pragma mark - Public + ++ (MXKPieChartHUD *)showLoadingHudOnView:(UIView *)view WithMessage:(NSString *)message +{ + MXKPieChartHUD *hud = [[MXKPieChartHUD alloc] init]; + [view addSubview:hud]; + + hud.translatesAutoresizingMaskIntoConstraints = NO; + + NSLayoutConstraint *centerXConstraint = [NSLayoutConstraint constraintWithItem:hud attribute:NSLayoutAttributeCenterX relatedBy:NSLayoutRelationEqual toItem:view attribute:NSLayoutAttributeCenterX multiplier:1 constant:0]; + NSLayoutConstraint *centerYConstraint = [NSLayoutConstraint constraintWithItem:hud attribute:NSLayoutAttributeCenterY relatedBy:NSLayoutRelationEqual toItem:view attribute:NSLayoutAttributeCenterY multiplier:1 constant:0]; + NSLayoutConstraint *widthConstraint = [NSLayoutConstraint constraintWithItem:hud attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1 constant:160]; + NSLayoutConstraint *heightConstraint = [NSLayoutConstraint constraintWithItem:hud attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1 constant:105]; + [NSLayoutConstraint activateConstraints:@[centerXConstraint, centerYConstraint, widthConstraint, heightConstraint]]; + + hud.titleLabel.text = message; + + return hud; +} + +- (void)setProgress:(CGFloat)progress +{ + [UIView animateWithDuration:0.2 animations:^{ + [self.pieChartView setProgress:progress]; + }]; + +} + + + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKPieChartHUD.xib b/Riot/Modules/MatrixKit/Views/MXKPieChartHUD.xib new file mode 100644 index 000000000..6ceac397b --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKPieChartHUD.xib @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKPieChartView.h b/Riot/Modules/MatrixKit/Views/MXKPieChartView.h new file mode 100644 index 000000000..7eeb2d2fb --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKPieChartView.h @@ -0,0 +1,35 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKView.h" + +@interface MXKPieChartView : MXKView + +/** + The current progress level in [0, 1] range. + The pie chart is automatically hidden if progress <= 0. + It is shown for other progress values. + */ +@property (nonatomic) CGFloat progress; + +@property (strong, nonatomic) UIColor* progressColor; +@property (strong, nonatomic) UIColor* unprogressColor; + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKPieChartView.m b/Riot/Modules/MatrixKit/Views/MXKPieChartView.m new file mode 100644 index 000000000..fb2d56e2b --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKPieChartView.m @@ -0,0 +1,139 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKPieChartView.h" + +@interface MXKPieChartView () +{ + // graphical items + CAShapeLayer* backgroundContainerLayer; + CAShapeLayer* powerContainerLayer; +} +@end + +@implementation MXKPieChartView + +- (void)setProgress:(CGFloat)progress +{ + // Consider only positive progress value + if (progress <= 0) + { + _progress = 0; + self.hidden = YES; + } + else + { + // Ensure that the progress value does not excceed 1.0 + _progress = MIN(progress, 1.0); + self.hidden = NO; + } + + // defines the view settings + CGFloat radius = self.frame.size.width / 2; + + // draw a rounded view + [self.layer setCornerRadius:radius]; + self.backgroundColor = [UIColor clearColor]; + + // draw the pie + CALayer* layer = [self layer]; + + // remove any previous drawn layer + if (powerContainerLayer) + { + [powerContainerLayer removeFromSuperlayer]; + powerContainerLayer = nil; + } + + // define default colors + if (!_progressColor) + { + _progressColor = [UIColor redColor]; + } + + if (!_unprogressColor) + { + _unprogressColor = [UIColor lightGrayColor]; + } + + // the background cell color is hidden the cell is selected. + // so put in grey the cell background triggers a weird display (the background grey is hidden but not the red part). + // add an other layer fixes the UX. + if (!backgroundContainerLayer) + { + backgroundContainerLayer = [CAShapeLayer layer]; + [backgroundContainerLayer setZPosition:0]; + [backgroundContainerLayer setStrokeColor:NULL]; + backgroundContainerLayer.fillColor = _unprogressColor.CGColor; + + // build the path + CGMutablePathRef path = CGPathCreateMutable(); + CGPathMoveToPoint(path, NULL, radius, radius); + + CGPathAddArc(path, NULL, radius, radius, radius, 0 , 2 * M_PI, 0); + CGPathCloseSubpath(path); + + [backgroundContainerLayer setPath:path]; + CFRelease(path); + + // add the sub layer + [layer addSublayer:backgroundContainerLayer]; + } + + if (_progress) + { + // create the filled layer + powerContainerLayer = [CAShapeLayer layer]; + [powerContainerLayer setZPosition:0]; + [powerContainerLayer setStrokeColor:NULL]; + + // power level is drawn in red + powerContainerLayer.fillColor = _progressColor.CGColor; + + // build the path + CGMutablePathRef path = CGPathCreateMutable(); + CGPathMoveToPoint(path, NULL, radius, radius); + + CGPathAddArc(path, NULL, radius, radius, radius, -M_PI / 2, (_progress * 2 * M_PI) - (M_PI / 2), 0); + CGPathCloseSubpath(path); + + [powerContainerLayer setPath:path]; + CFRelease(path); + + // add the sub layer + [layer addSublayer:powerContainerLayer]; + } +} + +- (void)setProgressColor:(UIColor *)progressColor +{ + _progressColor = progressColor; + self.progress = _progress; +} + +- (void)setUnprogressColor:(UIColor *)unprogressColor +{ + _unprogressColor = unprogressColor; + + if (backgroundContainerLayer) + { + [backgroundContainerLayer removeFromSuperlayer]; + backgroundContainerLayer = nil; + } + self.progress = _progress; +} + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/MXKReceiptSendersContainer.h b/Riot/Modules/MatrixKit/Views/MXKReceiptSendersContainer.h new file mode 100644 index 000000000..26a0c19e7 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKReceiptSendersContainer.h @@ -0,0 +1,99 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKView.h" + +typedef NS_ENUM(NSInteger, ReadReceiptsAlignment) +{ + /** + The latest receipt is displayed on left + */ + ReadReceiptAlignmentLeft = 0, + + /** + The latest receipt is displayed on right + */ + ReadReceiptAlignmentRight = 1, +}; + +/** + `MXKReceiptSendersContainer` is a view dedicated to display receipt senders by using their avatars. + + This container handles automatically the number of visible avatars. A label is added when avatars are not all visible (see 'moreLabel' property). + */ +@interface MXKReceiptSendersContainer : MXKView + +/** + The maximum number of avatars displayed in the container. 3 by default. + */ +@property (nonatomic) NSInteger maxDisplayedAvatars; + +/** + The space between avatars. 2.0 points by default. + */ +@property (nonatomic) CGFloat avatarMargin; + +/** + The label added beside avatars when avatars are not all visible. + */ +@property (nonatomic) UILabel* moreLabel; + +/** + The more label text color (If set to nil `moreLabel.textColor` use `UIColor.blackColor` as default color). + */ +@property (nonatomic) UIColor* moreLabelTextColor; + +/* + The read receipt objects for details required in the details view + */ +@property (nonatomic) NSArray *readReceipts; + +/* + The array of the room members that will be displayed in the container + */ +@property (nonatomic, readonly) NSArray *roomMembers; + +/* + The placeholders of the room members that will be shown if the users don't have avatars + */ +@property (nonatomic, readonly) NSArray *placeholders; + +/** + Initializes an `MXKReceiptSendersContainer` object with a frame and a media manager. + + This is the designated initializer. + + @param frame the container frame. Note that avatar will be displayed in full height in this container. + @param mediaManager the media manager used to download the matrix user's avatar. + @return The newly-initialized MXKReceiptSendersContainer instance + */ +- (instancetype)initWithFrame:(CGRect)frame andMediaManager:(MXMediaManager*)mediaManager; + +/** + Refresh the container content by using the provided room members. + + @param roomMembers list of room members sorted from the latest receipt to the oldest receipt. + @param placeHolders list of placeholders, one by room member. Used when url is nil, or during avatar download. + @param alignment (see ReadReceiptsAlignment). + */ +- (void)refreshReceiptSenders:(NSArray*)roomMembers withPlaceHolders:(NSArray*)placeHolders andAlignment:(ReadReceiptsAlignment)alignment; + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKReceiptSendersContainer.m b/Riot/Modules/MatrixKit/Views/MXKReceiptSendersContainer.m new file mode 100644 index 000000000..05a8acb81 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKReceiptSendersContainer.m @@ -0,0 +1,174 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKReceiptSendersContainer.h" + +#import "MXKImageView.h" + +static UIColor* kMoreLabelDefaultcolor; + +@interface MXKReceiptSendersContainer () + +@property (nonatomic, readwrite) NSArray *roomMembers; +@property (nonatomic, readwrite) NSArray *placeholders; +@property (nonatomic) MXMediaManager *mediaManager; + +@end + + +@implementation MXKReceiptSendersContainer + ++ (void)initialize +{ + if (self == [MXKReceiptSendersContainer class]) + { + kMoreLabelDefaultcolor = [UIColor blackColor]; + } +} + +- (instancetype)initWithFrame:(CGRect)frame andMediaManager:(MXMediaManager*)mediaManager +{ + self = [super initWithFrame:frame]; + if (self) + { + _mediaManager = mediaManager; + _maxDisplayedAvatars = 3; + _avatarMargin = 2.0; + _moreLabel = nil; + _moreLabelTextColor = kMoreLabelDefaultcolor; + } + return self; +} + +- (void)refreshReceiptSenders:(NSArray*)roomMembers withPlaceHolders:(NSArray*)placeHolders andAlignment:(ReadReceiptsAlignment)alignment +{ + // Store the room members and placeholders for showing in the details view controller + self.roomMembers = roomMembers; + self.placeholders = placeHolders; + + // Remove all previous content + for (UIView* view in self.subviews) + { + [view removeFromSuperview]; + } + if (_moreLabel) + { + [_moreLabel removeFromSuperview]; + _moreLabel = nil; + } + + CGRect globalFrame = self.frame; + CGFloat side = globalFrame.size.height; + CGFloat defaultMoreLabelWidth = side < 20 ? 20 : side; + unsigned long count; + unsigned long maxDisplayableItems = (int)((globalFrame.size.width - defaultMoreLabelWidth - _avatarMargin) / (side + _avatarMargin)); + + maxDisplayableItems = MIN(maxDisplayableItems, _maxDisplayedAvatars); + count = MIN(roomMembers.count, maxDisplayableItems); + + int index; + + CGFloat xOff = 0; + + if (alignment == ReadReceiptAlignmentRight) + { + xOff = globalFrame.size.width - (side + _avatarMargin); + } + + for (index = 0; index < count; index++) + { + MXRoomMember *roomMember = [roomMembers objectAtIndex:index]; + UIImage *preview = index < placeHolders.count ? placeHolders[index] : nil; + + MXKImageView *imageView = [[MXKImageView alloc] initWithFrame:CGRectMake(xOff, 0, side, side)]; + imageView.defaultBackgroundColor = [UIColor clearColor]; + imageView.autoresizingMask = UIViewAutoresizingNone; + + if (alignment == ReadReceiptAlignmentRight) + { + xOff -= side + _avatarMargin; + } + else + { + xOff += side + _avatarMargin; + } + + [self addSubview:imageView]; + imageView.enableInMemoryCache = YES; + + [imageView setImageURI:roomMember.avatarUrl + withType:nil + andImageOrientation:UIImageOrientationUp + toFitViewSize:CGSizeMake(side, side) + withMethod:MXThumbnailingMethodCrop + previewImage:preview + mediaManager:_mediaManager]; + + [imageView.layer setCornerRadius:imageView.frame.size.width / 2]; + imageView.clipsToBounds = YES; + } + + // Check whether there are more than expected read receipts + if (roomMembers.count > maxDisplayableItems) + { + // Add a more indicator + + // In case of right alignment, adjust the current position by considering the default label width + if (alignment == ReadReceiptAlignmentRight && side < defaultMoreLabelWidth) + { + xOff -= (defaultMoreLabelWidth - side); + } + + _moreLabel = [[UILabel alloc] initWithFrame:CGRectMake(xOff, 0, defaultMoreLabelWidth, side)]; + _moreLabel.text = [NSString stringWithFormat:(alignment == ReadReceiptAlignmentRight) ? @"%tu+" : @"+%tu", roomMembers.count - maxDisplayableItems]; + _moreLabel.font = [UIFont systemFontOfSize:11]; + _moreLabel.adjustsFontSizeToFitWidth = YES; + _moreLabel.minimumScaleFactor = 0.6; + + // In case of right alignment, adjust the horizontal position according to the actual label width + if (alignment == ReadReceiptAlignmentRight) + { + [_moreLabel sizeToFit]; + CGRect frame = _moreLabel.frame; + if (frame.size.width < defaultMoreLabelWidth) + { + frame.origin.x += (defaultMoreLabelWidth - frame.size.width); + _moreLabel.frame = frame; + } + } + + _moreLabel.textColor = self.moreLabelTextColor ?: kMoreLabelDefaultcolor; + [self addSubview:_moreLabel]; + } +} + +- (void)dealloc +{ + NSArray* subviews = self.subviews; + for (UIView* view in subviews) + { + [view removeFromSuperview]; + } + + if (_moreLabel) + { + [_moreLabel removeFromSuperview]; + _moreLabel = nil; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKRoomActivitiesView.h b/Riot/Modules/MatrixKit/Views/MXKRoomActivitiesView.h new file mode 100644 index 000000000..e6a73e00e --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKRoomActivitiesView.h @@ -0,0 +1,71 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKView.h" + +@protocol MXKRoomActivitiesViewDelegate; + +/** + Customize UIView to display some extra info above the RoomInputToolBar + */ +@interface MXKRoomActivitiesView : MXKView + +@property (nonatomic) CGFloat height; + +@property (weak, nonatomic) id delegate; + +/** + Returns the `UINib` object initialized for a `MXKRoomActivitiesView`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `roomActivitiesView` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKRoomActivitiesView-inherited` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKRoomActivitiesView-inherited` object if successful, `nil` otherwise. + */ ++ (instancetype)roomActivitiesView; + +/** + Dispose any resources and listener. + */ +- (void)destroy; + +@end + +@protocol MXKRoomActivitiesViewDelegate + +/** + Called when the activities view height changes. + + @param roomActivitiesView the MXKRoomActivitiesView instance. + @param oldHeight its previous height. + @param newHeight its new height. + */ +- (void)didChangeHeight:(MXKRoomActivitiesView*)roomActivitiesView oldHeight:(CGFloat)oldHeight newHeight:(CGFloat)newHeight; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKRoomActivitiesView.m b/Riot/Modules/MatrixKit/Views/MXKRoomActivitiesView.m new file mode 100644 index 000000000..98991cdb4 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKRoomActivitiesView.m @@ -0,0 +1,58 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomActivitiesView.h" + +@implementation MXKRoomActivitiesView + ++ (UINib *)nib +{ + // No 'MXKRoomActivitiesView.xib' has been defined yet + return nil; +} + ++ (instancetype)roomActivitiesView +{ + id instance = nil; + + if ([[self class] nib]) + { + @try { + instance = [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + } + @catch (NSException *exception) { + } + } + + if (!instance) + { + instance = [[self alloc] initWithFrame:CGRectZero]; + } + + return instance; +} + +- (void)destroy +{ + _delegate = nil; +} + +- (CGFloat)height +{ + return self.frame.size.height; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKRoomCreationView.h b/Riot/Modules/MatrixKit/Views/MXKRoomCreationView.h new file mode 100644 index 000000000..f269dbe87 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKRoomCreationView.h @@ -0,0 +1,140 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKView.h" + +@class MXKRoomCreationView; +@protocol MXKRoomCreationViewDelegate + +/** + Tells the delegate that an alert must be presented. + + @param creationView the view. + @param alertController the alert to present. + */ +- (void)roomCreationView:(MXKRoomCreationView*)creationView presentAlertController:(UIAlertController*)alertController; + +/** + Tells the delegate to open the room with the provided identifier in a specific matrix session. + + @param creationView the view. + @param roomId the room identifier. + @param mxSession the matrix session in which the room should be available. + */ +- (void)roomCreationView:(MXKRoomCreationView*)creationView showRoom:(NSString*)roomId withMatrixSession:(MXSession*)mxSession; +@end + +/** + MXKRoomCreationView instance is a cell dedicated to room creation. + Add this view in your app to offer room creation option. + */ +@interface MXKRoomCreationView : MXKView { +@protected + UIView *inputAccessoryView; +} + +/** + * Returns the `UINib` object initialized for the tool bar view. + * + * @return The initialized `UINib` object or `nil` if there were errors during + * initialization or the nib file could not be located. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKRoomCreationView-inherited` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKRoomCreationView-inherited` object if successful, `nil` otherwise. + */ ++ (instancetype)roomCreationView; + +/** + The delegate. + */ +@property (nonatomic, weak) id delegate; + +/** + Hide room name field (NO by default). + Set YES this property to disable room name edition and hide the related items. + */ +@property (nonatomic, getter=isRoomNameFieldHidden) BOOL roomNameFieldHidden; + +/** + Hide room alias field (NO by default). + Set YES this property to disable room alias edition and hide the related items. + */ +@property (nonatomic, getter=isRoomAliasFieldHidden) BOOL roomAliasFieldHidden; + +/** + Hide room participants field (NO by default). + Set YES this property to disable room participants edition and hide the related items. + */ +@property (nonatomic, getter=isParticipantsFieldHidden) BOOL participantsFieldHidden; + +/** + The view height which takes into account potential hidden fields + */ +@property (nonatomic) CGFloat actualFrameHeight; + +/** + */ +@property (nonatomic) NSArray* mxSessions; + +/** + The custom accessory view associated to all text field of this 'MXKRoomCreationView' instance. + This view is actually used to retrieve the keyboard view. Indeed the keyboard view is the superview of + this accessory view when a text field become the first responder. + */ +@property (readonly) UIView *inputAccessoryView; + +/** + UI items + */ +@property (weak, nonatomic) IBOutlet UILabel *roomNameLabel; +@property (weak, nonatomic) IBOutlet UILabel *roomAliasLabel; +@property (weak, nonatomic) IBOutlet UILabel *participantsLabel; +@property (weak, nonatomic) IBOutlet UITextField *roomNameTextField; +@property (weak, nonatomic) IBOutlet UITextField *roomAliasTextField; +@property (weak, nonatomic) IBOutlet UITextField *participantsTextField; +@property (weak, nonatomic) IBOutlet UISegmentedControl *roomVisibilityControl; +@property (weak, nonatomic) IBOutlet UIButton *createRoomBtn; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *roomNameFieldTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *roomAliasFieldTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *participantsFieldTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *textFieldLeftConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *createRoomBtnTopConstraint; + +/** + Action registered to handle text field editing change (UIControlEventEditingChanged). + */ +- (IBAction)textFieldEditingChanged:(id)sender; + +/** + Force dismiss keyboard. + */ +- (void)dismissKeyboard; + +/** + Dispose any resources and listener. + */ +- (void)destroy; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKRoomCreationView.m b/Riot/Modules/MatrixKit/Views/MXKRoomCreationView.m new file mode 100644 index 000000000..d132e8e00 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKRoomCreationView.m @@ -0,0 +1,578 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomCreationView.h" + +#import "MXKConstants.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@interface MXKRoomCreationView () +{ + UIAlertController *mxSessionPicker; + + // Array of homeserver suffix (NSString instance) + NSMutableArray *homeServerSuffixArray; +} + +@end + +@implementation MXKRoomCreationView +@synthesize inputAccessoryView; + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRoomCreationView class]) + bundle:[NSBundle bundleForClass:[MXKRoomCreationView class]]]; +} + ++ (instancetype)roomCreationView +{ + if ([[self class] nib]) + { + return [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + } + else + { + return [[self alloc] init]; + } +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + // Add observer to keep align text fields + [_roomNameLabel addObserver:self forKeyPath:@"text" options:0 context:nil]; + [_roomAliasLabel addObserver:self forKeyPath:@"text" options:0 context:nil]; + [_participantsLabel addObserver:self forKeyPath:@"text" options:0 context:nil]; + [self alignTextFields]; + + // Finalize setup + [self setTranslatesAutoresizingMaskIntoConstraints: NO]; + + // Add an accessory view to the text views in order to retrieve keyboard view. + inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; + _roomNameTextField.inputAccessoryView = inputAccessoryView; + _roomAliasTextField.inputAccessoryView = inputAccessoryView; + _participantsTextField.inputAccessoryView = inputAccessoryView; + + // Localize strings + _roomNameLabel.text = [MatrixKitL10n roomCreationNameTitle]; + _roomNameTextField.placeholder = [MatrixKitL10n roomCreationNamePlaceholder]; + _roomAliasLabel.text = [MatrixKitL10n roomCreationAliasTitle]; + _roomAliasTextField.placeholder = [MatrixKitL10n roomCreationAliasPlaceholder]; + _participantsLabel.text = [MatrixKitL10n roomCreationParticipantsTitle]; + _participantsTextField.placeholder = [MatrixKitL10n roomCreationParticipantsPlaceholder]; + + [_roomVisibilityControl setTitle:[MatrixKitL10n public] forSegmentAtIndex:0]; + [_roomVisibilityControl setTitle:[MatrixKitL10n private] forSegmentAtIndex:1]; + + [_createRoomBtn setTitle:[MatrixKitL10n createRoom] forState:UIControlStateNormal]; + [_createRoomBtn setTitle:[MatrixKitL10n createRoom] forState:UIControlStateHighlighted]; +} + +- (void)dealloc +{ + [self destroy]; + + inputAccessoryView = nil; +} + +- (void)setRoomNameFieldHidden:(BOOL)roomNameFieldHidden +{ + _roomNameFieldHidden = _roomNameTextField.hidden = _roomNameLabel.hidden = roomNameFieldHidden; + + if (roomNameFieldHidden) + { + _roomAliasFieldTopConstraint.constant -= _roomNameTextField.frame.size.height + 8; + _participantsFieldTopConstraint.constant -= _roomNameTextField.frame.size.height + 8; + _createRoomBtnTopConstraint.constant -= _roomNameTextField.frame.size.height + 8; + } + else + { + _roomAliasFieldTopConstraint.constant += _roomNameTextField.frame.size.height + 8; + _participantsFieldTopConstraint.constant += _roomNameTextField.frame.size.height + 8; + _createRoomBtnTopConstraint.constant += _roomNameTextField.frame.size.height + 8; + } + + [self alignTextFields]; +} + +- (void)setRoomAliasFieldHidden:(BOOL)roomAliasFieldHidden +{ + _roomAliasFieldHidden = _roomAliasTextField.hidden = _roomAliasLabel.hidden = roomAliasFieldHidden; + + if (roomAliasFieldHidden) + { + _participantsFieldTopConstraint.constant -= _roomAliasTextField.frame.size.height + 8; + _createRoomBtnTopConstraint.constant -= _roomAliasTextField.frame.size.height + 8; + } + else + { + _participantsFieldTopConstraint.constant += _roomAliasTextField.frame.size.height + 8; + _createRoomBtnTopConstraint.constant += _roomAliasTextField.frame.size.height + 8; + } + + [self alignTextFields]; +} + +- (void)setParticipantsFieldHidden:(BOOL)participantsFieldHidden +{ + _participantsFieldHidden = _participantsTextField.hidden = _participantsLabel.hidden = participantsFieldHidden; + + if (participantsFieldHidden) + { + _createRoomBtnTopConstraint.constant -= _participantsTextField.frame.size.height + 8; + } + else + { + _createRoomBtnTopConstraint.constant += _participantsTextField.frame.size.height + 8; + } + + [self alignTextFields]; +} + +- (CGFloat)actualFrameHeight +{ + return (_createRoomBtnTopConstraint.constant + _createRoomBtn.frame.size.height + 8); +} + +- (void)setMxSessions:(NSArray *)mxSessions +{ + _mxSessions = mxSessions; + + if (mxSessions.count) + { + homeServerSuffixArray = [NSMutableArray array]; + + for (MXSession *mxSession in mxSessions) + { + NSString *homeserverSuffix = mxSession.matrixRestClient.homeserverSuffix; + if (homeserverSuffix && [homeServerSuffixArray indexOfObject:homeserverSuffix] == NSNotFound) + { + [homeServerSuffixArray addObject:homeserverSuffix]; + } + } + } + else + { + homeServerSuffixArray = nil; + } + + // Update alias placeholder in room creation section + if (homeServerSuffixArray.count == 1) + { + _roomAliasTextField.placeholder = [MatrixKitL10n roomCreationAliasPlaceholderWithHomeserver:homeServerSuffixArray.firstObject]; + } + else + { + _roomAliasTextField.placeholder = [MatrixKitL10n roomCreationAliasPlaceholder]; + } +} + +- (void)dismissKeyboard +{ + // Hide the keyboard + [_roomNameTextField resignFirstResponder]; + [_roomAliasTextField resignFirstResponder]; + [_participantsTextField resignFirstResponder]; +} + +- (void)destroy +{ + self.mxSessions = nil; + + // Remove observers + [_roomNameLabel removeObserver:self forKeyPath:@"text"]; + [_roomAliasLabel removeObserver:self forKeyPath:@"text"]; + [_participantsLabel removeObserver:self forKeyPath:@"text"]; +} + +- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event +{ + // UIView will be "transparent" for touch events if we return NO + return YES; +} + +#pragma mark - Internal methods + +- (void)alignTextFields +{ + CGFloat maxLabelLenght = 0; + + if (!_roomNameLabel.hidden) + { + maxLabelLenght = _roomNameLabel.frame.size.width; + } + if (!_roomAliasLabel.hidden && maxLabelLenght < _roomAliasLabel.frame.size.width) + { + maxLabelLenght = _roomAliasLabel.frame.size.width; + } + if (!_participantsLabel.hidden && maxLabelLenght < _participantsLabel.frame.size.width) + { + maxLabelLenght = _participantsLabel.frame.size.width; + } + + // Update textField left constraint by adding marging + _textFieldLeftConstraint.constant = maxLabelLenght + (2 * 8); + + [self layoutIfNeeded]; +} + +- (NSString*)alias +{ + // Extract alias name from alias text field + NSString *alias = _roomAliasTextField.text; + if (alias.length) + { + // Remove '#' character + alias = [alias substringFromIndex:1]; + + NSString *actualAlias = nil; + for (NSString *homeServerSuffix in homeServerSuffixArray) + { + // Remove homeserver suffix + NSRange range = [alias rangeOfString:homeServerSuffix]; + if (range.location != NSNotFound) + { + actualAlias = [alias stringByReplacingCharactersInRange:range withString:@""]; + break; + } + } + + if (actualAlias) + { + alias = actualAlias; + } + else + { + MXLogDebug(@"[MXKRoomCreationView] Wrong room alias has been set (%@)", _roomAliasTextField.text); + alias = nil; + } + } + + if (! alias.length) + { + alias = nil; + } + + return alias; +} + +- (NSArray*)participantsList +{ + NSMutableArray *participants = [NSMutableArray array]; + + if (_participantsTextField.text.length) + { + NSArray *components = [_participantsTextField.text componentsSeparatedByString:@";"]; + + for (NSString *component in components) + { + // Remove white space from both ends + NSString *user = [component stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if (user.length > 1 && [user hasPrefix:@"@"]) + { + [participants addObject:user]; + } + } + } + + if (participants.count == 0) + { + participants = nil; + } + + return participants; +} + +- (void)selectMatrixSession:(void (^)(MXSession *selectedSession))onSelection +{ + if (_mxSessions.count == 1) + { + if (onSelection) + { + onSelection(_mxSessions.firstObject); + } + } + else if (_mxSessions.count > 1) + { + if (mxSessionPicker) + { + [mxSessionPicker dismissViewControllerAnimated:NO completion:nil]; + } + + mxSessionPicker = [UIAlertController alertControllerWithTitle:[MatrixKitL10n selectAccount] message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + + __weak typeof(self) weakSelf = self; + + for(MXSession *mxSession in _mxSessions) + { + [mxSessionPicker addAction:[UIAlertAction actionWithTitle:mxSession.myUser.userId + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->mxSessionPicker = nil; + + if (onSelection) + { + onSelection(mxSession); + } + } + + }]]; + } + + [mxSessionPicker addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->mxSessionPicker = nil; + } + + }]]; + + [mxSessionPicker popoverPresentationController].sourceView = self; + [mxSessionPicker popoverPresentationController].sourceRect = self.bounds; + + if (self.delegate) + { + [self.delegate roomCreationView:self presentAlertController:mxSessionPicker]; + } + } +} + +#pragma mark - UITextField delegate + +- (IBAction)textFieldEditingChanged:(id)sender +{ + // Update Create Room button + NSString *roomName = _roomNameTextField.text; + NSString *roomAlias = _roomAliasTextField.text; + NSString *participants = _participantsTextField.text; + + // Room alias is required to create public room + _createRoomBtn.enabled = ((_roomVisibilityControl.selectedSegmentIndex == 0) ? roomAlias.length : (roomName.length || roomAlias.length || participants.length)); +} + +- (void)textFieldDidBeginEditing:(UITextField *)textField +{ + if (textField == _participantsTextField) + { + if (textField.text.length == 0) + { + textField.text = @"@"; + } + } + else if (textField == _roomAliasTextField) + { + if (textField.text.length == 0) + { + textField.text = @"#"; + } + } +} + +- (void)textFieldDidEndEditing:(UITextField *)textField +{ + if (textField == _roomAliasTextField) + { + if (homeServerSuffixArray.count == 1) + { + // Check whether homeserver suffix should be added + NSRange range = [textField.text rangeOfString:@":"]; + if (range.location == NSNotFound) + { + textField.text = [textField.text stringByAppendingString:homeServerSuffixArray.firstObject]; + } + } + + // Check whether the alias is valid + if (!self.alias) + { + // reset text field + textField.text = nil; + + // Update Create button status + _createRoomBtn.enabled = ((_roomVisibilityControl.selectedSegmentIndex == 1) && (_roomNameTextField.text.length || _participantsTextField.text.length)); + } + } + else if (textField == _participantsTextField) + { + NSArray *participants = self.participantsList; + textField.text = [participants componentsJoinedByString:@"; "]; + + // Update Create button status + _createRoomBtn.enabled = ((_roomVisibilityControl.selectedSegmentIndex == 0) ? _roomAliasTextField.text.length : (_roomNameTextField.text.length || _roomAliasTextField.text.length || _participantsTextField.text.length)); + } +} + +- (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string +{ + // Auto complete participant IDs + if (textField == _participantsTextField) + { + // Add @ if none + if (!textField.text.length || textField.text.length == range.length) + { + if ([string hasPrefix:@"@"] == NO) + { + textField.text = [NSString stringWithFormat:@"@%@",string]; + return NO; + } + } + else if (range.location == textField.text.length) + { + if ([string isEqualToString:@";"]) + { + // Add '@' character + textField.text = [textField.text stringByAppendingString:@"; @"]; + return NO; + } + } + } + else if (textField == _roomAliasTextField) + { + // Add # if none + if (!textField.text.length || textField.text.length == range.length) + { + if ([string hasPrefix:@"#"] == NO) + { + if ([string isEqualToString:@":"] && homeServerSuffixArray.count == 1) + { + textField.text = [NSString stringWithFormat:@"#%@",homeServerSuffixArray.firstObject]; + } + else + { + textField.text = [NSString stringWithFormat:@"#%@",string]; + } + return NO; + } + } + else if (homeServerSuffixArray.count == 1) + { + // Add homeserver automatically when user adds ':' at the end + if (range.location == textField.text.length && [string isEqualToString:@":"]) + { + textField.text = [textField.text stringByAppendingString:homeServerSuffixArray.firstObject]; + return NO; + } + } + } + return YES; +} + +- (BOOL)textFieldShouldReturn:(UITextField*) textField +{ + // "Done" key has been pressed + [textField resignFirstResponder]; + return YES; +} + +#pragma mark - Actions + +- (IBAction)onButtonPressed:(id)sender +{ + [self dismissKeyboard]; + + // Handle multi-sessions here + [self selectMatrixSession:^(MXSession *selectedSession) + { + if (sender == self->_createRoomBtn) + { + // Disable button to prevent multiple request + self->_createRoomBtn.enabled = NO; + + MXRoomCreationParameters *roomCreationParameters = [MXRoomCreationParameters new]; + + roomCreationParameters.name = self->_roomNameTextField.text; + if (!roomCreationParameters.name.length) + { + roomCreationParameters.name = nil; + } + + // Check whether some users must be invited + roomCreationParameters.inviteArray = self.participantsList; + + // Prepare room settings + + if (self->_roomVisibilityControl.selectedSegmentIndex == 0) + { + roomCreationParameters.visibility = kMXRoomDirectoryVisibilityPublic; + } + else + { + roomCreationParameters.visibility = kMXRoomDirectoryVisibilityPrivate; + roomCreationParameters.isDirect = (roomCreationParameters.inviteArray.count == 1); + } + + // Ensure direct chat are created with equal ops on both sides (the trusted_private_chat preset) + roomCreationParameters.preset = (roomCreationParameters.isDirect ? kMXRoomPresetTrustedPrivateChat : nil); + + // Create new room + [selectedSession createRoomWithParameters:roomCreationParameters success:^(MXRoom *room) { + + // Reset text fields + self->_roomNameTextField.text = nil; + self->_roomAliasTextField.text = nil; + self->_participantsTextField.text = nil; + + if (self.delegate) + { + // Open created room + [self.delegate roomCreationView:self showRoom:room.roomId withMatrixSession:selectedSession]; + } + + } failure:^(NSError *error) { + + self->_createRoomBtn.enabled = YES; + + MXLogDebug(@"[MXKRoomCreationView] Create room (%@ %@ (%@)) failed", self->_roomNameTextField.text, self.alias, (self->_roomVisibilityControl.selectedSegmentIndex == 0) ? @"Public":@"Private"); + + // Notify MatrixKit user + NSString *myUserId = selectedSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + }]; +} + +#pragma mark - KVO + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context +{ + // Check whether one label has been updated + if ([@"text" isEqualToString:keyPath] && (object == _roomNameLabel || object == _roomAliasLabel || object == _participantsLabel)) + { + // Update left constraint of the text fields + [object sizeToFit]; + [self alignTextFields]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKRoomCreationView.xib b/Riot/Modules/MatrixKit/Views/MXKRoomCreationView.xib new file mode 100644 index 000000000..c70ef4790 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKRoomCreationView.xib @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCell.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCell.h new file mode 100644 index 000000000..a05455e31 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCell.h @@ -0,0 +1,82 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKCellData.h" + +/** + List the display box types for the cell subviews. + */ +typedef enum : NSUInteger { + /** + By default the view display box is unchanged. + */ + MXKTableViewCellDisplayBoxTypeDefault, + /** + Define a circle box based on the smaller size of the view frame, some portion of content may be clipped. + */ + MXKTableViewCellDisplayBoxTypeCircle, + /** + Round the corner of the display box of the view. + */ + MXKTableViewCellDisplayBoxTypeRoundedCorner + +} MXKTableViewCellDisplayBoxType; + +/** + 'MXKTableViewCell' class is used to define custom UITableViewCell. + Each 'MXKTableViewCell-inherited' class has its own 'reuseIdentifier'. + */ +@interface MXKTableViewCell : UITableViewCell +{ +@protected + NSString *mxkReuseIdentifier; +} + +/** + Returns the `UINib` object initialized for the cell. + + @return The initialized `UINib` object or `nil` if there were errors during + initialization or the nib file could not be located. + */ ++ (UINib *)nib; + +/** + The default reuseIdentifier of the 'MXKTableViewCell-inherited' class. + */ ++ (NSString*)defaultReuseIdentifier; + +/** + Override [UITableViewCell initWithStyle:reuseIdentifier:] to load cell content from nib file (if any), + and handle reuse identifier. + */ +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier; + +/** + Customize the rendering of the table view cell and its subviews (Do nothing by default). + This method is called when the view is initialized or prepared for reuse. + + Override this method to customize the table view cell at the application level. + */ +- (void)customizeTableViewCellRendering; + +/** + The current cell data displayed by the table view cell + */ +@property (weak, nonatomic, readonly) MXKCellData *mxkCellData; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCell.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCell.m new file mode 100644 index 000000000..081bef6bc --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCell.m @@ -0,0 +1,100 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" +#import "NSBundle+MatrixKit.h" + +@implementation MXKTableViewCell + ++ (UINib *)nib +{ + // Check whether a nib file is available + NSBundle *mainBundle = [NSBundle mxk_bundleForClass:self.class]; + + NSString *path = [mainBundle pathForResource:NSStringFromClass([self class]) ofType:@"nib"]; + if (path) + { + return [UINib nibWithNibName:NSStringFromClass([self class]) bundle:mainBundle]; + } + return nil; +} + ++ (NSString*)defaultReuseIdentifier +{ + return NSStringFromClass([self class]); +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + [self customizeTableViewCellRendering]; +} + +- (void)prepareForReuse +{ + [super prepareForReuse]; + + [self customizeTableViewCellRendering]; +} + +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier +{ + // Check whether a xib is defined + if ([[self class] nib]) + { + self = [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + } + else + { + self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; + [self customizeTableViewCellRendering]; + } + + if (reuseIdentifier.length) + { + // The provided identifier is not always conserved in the new created cell. + // This depends how the method [initWithStyle:reuseIdentifier:] is trigerred. + // Trick: we store a copy of this identifier. + mxkReuseIdentifier = reuseIdentifier; + } + else + { + mxkReuseIdentifier = [[self class] defaultReuseIdentifier]; + } + + return self; +} + +- (NSString*)reuseIdentifier +{ + NSString *identifier = super.reuseIdentifier; + + if (!identifier.length) + { + identifier = mxkReuseIdentifier; + } + + return identifier; +} + +- (void)customizeTableViewCellRendering +{ + // Do nothing by default. +} + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.h new file mode 100644 index 000000000..daa880d3d --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.h @@ -0,0 +1,27 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithButton' inherits 'MXKTableViewCell' class. + It constains a 'UIButton' centered in cell content view. + */ +@interface MXKTableViewCellWithButton : MXKTableViewCell + +@property (strong, nonatomic) IBOutlet UIButton *mxkButton; + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.m new file mode 100644 index 000000000..25cfc0245 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.m @@ -0,0 +1,34 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithButton.h" + +@implementation MXKTableViewCellWithButton + +- (void)prepareForReuse +{ + [super prepareForReuse]; + + // TODO: Code commented for a quick fix for https://github.com/vector-im/riot-ios/issues/1323 + // This line was a fix for https://github.com/vector-im/riot-ios/issues/1354 + // but it creates a regression that is worse than the bug it fixes. + // self.mxkButton.titleLabel.text = nil; + + [self.mxkButton removeTarget:nil action:nil forControlEvents:UIControlEventAllEvents]; + self.mxkButton.accessibilityIdentifier = nil; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.xib new file mode 100644 index 000000000..7df7dbb6d --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.xib @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButtons.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButtons.h new file mode 100644 index 000000000..17f5df957 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButtons.h @@ -0,0 +1,36 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithButtons' inherits 'MXKTableViewCell' class. + It displays several buttons with the system style in a UITableViewCell. All buttons have the same width and they are horizontally aligned. + They are vertically centered. + */ +@interface MXKTableViewCellWithButtons : MXKTableViewCell + +/** + The number of buttons + */ +@property (nonatomic) NSUInteger mxkButtonNumber; + +/** + The current array of buttons + */ +@property (nonatomic) NSArray *mxkButtons; + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButtons.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButtons.m new file mode 100644 index 000000000..ac5db6c4f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButtons.m @@ -0,0 +1,156 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithButtons.h" + +@interface MXKTableViewCellWithButtons () +{ + NSMutableArray *buttonArray; +} +@end + +@implementation MXKTableViewCellWithButtons + +- (void)setMxkButtonNumber:(NSUInteger)buttonNumber +{ + if (_mxkButtonNumber == buttonNumber) + { + return; + } + + _mxkButtonNumber = buttonNumber; + buttonArray = [NSMutableArray arrayWithCapacity:buttonNumber]; + + CGFloat containerWidth = self.contentView.frame.size.width / buttonNumber; + UIView *previousContainer = nil; + NSLayoutConstraint *leftConstraint; + NSLayoutConstraint *rightConstraint; + NSLayoutConstraint *widthConstraint; + NSLayoutConstraint *topConstraint; + NSLayoutConstraint *bottomConstraint; + + for (NSInteger index = 0; index < buttonNumber; index++) + { + UIView *buttonContainer = [[UIView alloc] initWithFrame:CGRectMake(index * containerWidth, 0, containerWidth, self.contentView.frame.size.height)]; + buttonContainer.backgroundColor = [UIColor clearColor]; + [self.contentView addSubview:buttonContainer]; + + // Add container constraints + buttonContainer.translatesAutoresizingMaskIntoConstraints = NO; + if (!previousContainer) + { + leftConstraint = [NSLayoutConstraint constraintWithItem:buttonContainer + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:self.contentView + attribute:NSLayoutAttributeLeading + multiplier:1 + constant:0]; + widthConstraint = [NSLayoutConstraint constraintWithItem:buttonContainer + attribute:NSLayoutAttributeWidth + relatedBy:NSLayoutRelationEqual + toItem:self.contentView + attribute:NSLayoutAttributeWidth + multiplier:(1.0 / buttonNumber) + constant:0]; + } + else + { + leftConstraint = [NSLayoutConstraint constraintWithItem:buttonContainer + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:previousContainer + attribute:NSLayoutAttributeTrailing + multiplier:1 + constant:0]; + widthConstraint = [NSLayoutConstraint constraintWithItem:buttonContainer + attribute:NSLayoutAttributeWidth + relatedBy:NSLayoutRelationEqual + toItem:previousContainer + attribute:NSLayoutAttributeWidth + multiplier:1 + constant:0]; + } + + topConstraint = [NSLayoutConstraint constraintWithItem:buttonContainer + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self.contentView + attribute:NSLayoutAttributeTop + multiplier:1 + constant:0]; + + bottomConstraint = [NSLayoutConstraint constraintWithItem:buttonContainer + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:self.contentView + attribute:NSLayoutAttributeBottom + multiplier:1 + constant:0]; + + [NSLayoutConstraint activateConstraints:@[leftConstraint, widthConstraint, topConstraint, bottomConstraint]]; + previousContainer = buttonContainer; + + // Add Button + UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem]; + button.frame = CGRectMake(10, 8, containerWidth - 20, buttonContainer.frame.size.height - 16); + [buttonContainer addSubview:button]; + [buttonArray addObject:button]; + + // Add button constraints + button.translatesAutoresizingMaskIntoConstraints = NO; + leftConstraint = [NSLayoutConstraint constraintWithItem:button + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:buttonContainer + attribute:NSLayoutAttributeLeading + multiplier:1 + constant:10]; + rightConstraint = [NSLayoutConstraint constraintWithItem:button + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:buttonContainer + attribute:NSLayoutAttributeTrailing + multiplier:1 + constant:-10]; + + topConstraint = [NSLayoutConstraint constraintWithItem:button + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:buttonContainer + attribute:NSLayoutAttributeTop + multiplier:1 + constant:8]; + + bottomConstraint = [NSLayoutConstraint constraintWithItem:button + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:buttonContainer + attribute:NSLayoutAttributeBottom + multiplier:1 + constant:-8]; + + [NSLayoutConstraint activateConstraints:@[leftConstraint, rightConstraint, topConstraint, bottomConstraint]]; + } +} + +- (NSArray*)mxkButtons +{ + return [NSArray arrayWithArray:buttonArray]; +} + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.h new file mode 100644 index 000000000..89076126a --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.h @@ -0,0 +1,38 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithLabelAndButton' inherits 'MXKTableViewCell' class. + It constains a 'UILabel' and a 'UIButton' vertically centered. + */ +@interface MXKTableViewCellWithLabelAndButton : MXKTableViewCell + +@property (strong, nonatomic) IBOutlet UILabel *mxkLabel; +@property (strong, nonatomic) IBOutlet UIButton *mxkButton; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelLeadingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkButtonLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkButtonTrailingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkButtonProportionalWidthToMxkButtonConstraint; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.m new file mode 100644 index 000000000..a34c8ba80 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.m @@ -0,0 +1,22 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithLabelAndButton.h" + +@implementation MXKTableViewCellWithLabelAndButton + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.xib new file mode 100644 index 000000000..83f6075d5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.xib @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.h new file mode 100644 index 000000000..c985c0f7d --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.h @@ -0,0 +1,44 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithLabelAndImageView' inherits 'MXKTableViewCell' class. + It constains a 'UILabel' and a 'UIImageView' vertically centered. + */ +@interface MXKTableViewCellWithLabelAndImageView : MXKTableViewCell + +@property (strong, nonatomic) IBOutlet UILabel *mxkLabel; +@property (strong, nonatomic) IBOutlet UIImageView *mxkImageView; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelLeadingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkImageViewLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkImageViewTrailingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkImageViewWidthConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkImageViewHeightConstraint; + +/** + The image view display box type ('MXKTableViewCellDisplayBoxTypeDefault' by default) + */ +@property (nonatomic) MXKTableViewCellDisplayBoxType mxkImageViewDisplayBoxType; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.m new file mode 100644 index 000000000..5c89806e6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.m @@ -0,0 +1,44 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithLabelAndImageView.h" + +@implementation MXKTableViewCellWithLabelAndImageView + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + if (self.mxkImageViewDisplayBoxType == MXKTableViewCellDisplayBoxTypeCircle) + { + // Round image view for thumbnail + _mxkImageView.layer.cornerRadius = _mxkImageView.frame.size.width / 2; + _mxkImageView.clipsToBounds = YES; + } + else if (self.mxkImageViewDisplayBoxType == MXKTableViewCellDisplayBoxTypeRoundedCorner) + { + _mxkImageView.layer.cornerRadius = 5; + _mxkImageView.clipsToBounds = YES; + } + else + { + _mxkImageView.layer.cornerRadius = 0; + _mxkImageView.clipsToBounds = NO; + } +} + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.xib new file mode 100644 index 000000000..249476e98 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.xib @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.h new file mode 100644 index 000000000..6119dfbb0 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.h @@ -0,0 +1,46 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +#import "MXKImageView.h" + +/** + 'MXKTableViewCellWithLabelAndMXKImageView' inherits 'MXKTableViewCell' class. + It constains a 'UILabel' and a 'MXKImageView' vertically centered. + */ +@interface MXKTableViewCellWithLabelAndMXKImageView : MXKTableViewCell + +@property (strong, nonatomic) IBOutlet UILabel *mxkLabel; +@property (strong, nonatomic) IBOutlet MXKImageView *mxkImageView; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelLeadingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkImageViewLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkImageViewTrailingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkImageViewWidthConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkImageViewHeightConstraint; + +/** + The MXKImageView display box type ('MXKTableViewCellDisplayBoxTypeDefault' by default) + */ +@property (nonatomic) MXKTableViewCellDisplayBoxType mxkImageViewDisplayBoxType; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.m new file mode 100644 index 000000000..bfffb2d38 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.m @@ -0,0 +1,44 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithLabelAndMXKImageView.h" + +@implementation MXKTableViewCellWithLabelAndMXKImageView + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + if (self.mxkImageViewDisplayBoxType == MXKTableViewCellDisplayBoxTypeCircle) + { + // Round image view for thumbnail + _mxkImageView.layer.cornerRadius = _mxkImageView.frame.size.width / 2; + _mxkImageView.clipsToBounds = YES; + } + else if (self.mxkImageViewDisplayBoxType == MXKTableViewCellDisplayBoxTypeRoundedCorner) + { + _mxkImageView.layer.cornerRadius = 5; + _mxkImageView.clipsToBounds = YES; + } + else + { + _mxkImageView.layer.cornerRadius = 0; + _mxkImageView.clipsToBounds = NO; + } +} + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.xib new file mode 100644 index 000000000..92d745d55 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.xib @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.h new file mode 100644 index 000000000..cc10e1bb5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.h @@ -0,0 +1,42 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithLabelAndSlider' inherits 'MXKTableViewCell' class. + It constains a 'UILabel' and a 'UISlider'. + */ +@interface MXKTableViewCellWithLabelAndSlider : MXKTableViewCell + +@property (nonatomic) IBOutlet UILabel *mxkLabel; +@property (nonatomic) IBOutlet UISlider *mxkSlider; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelTrailingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSliderTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSliderLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSliderTrailingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSliderBottomConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSliderHeightConstraint; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.m new file mode 100644 index 000000000..171f8d86c --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.m @@ -0,0 +1,22 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithLabelAndSlider.h" + +@implementation MXKTableViewCellWithLabelAndSlider + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.xib new file mode 100644 index 000000000..4c5f752e1 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.xib @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.h new file mode 100644 index 000000000..5dd17657c --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.h @@ -0,0 +1,39 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithLabelAndSubLabel' inherits 'MXKTableViewCell' class. + It constains two 'UILabel' instances. + */ +@interface MXKTableViewCellWithLabelAndSubLabel : MXKTableViewCell + +@property (strong, nonatomic) IBOutlet UILabel* mxkLabel; +@property (strong, nonatomic) IBOutlet UILabel* mxkSublabel; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelTrailingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSublabelLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSublabelTrailingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSublabelBottomConstraint; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.m new file mode 100644 index 000000000..6b52aabf0 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.m @@ -0,0 +1,21 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithLabelAndSubLabel.h" + +@implementation MXKTableViewCellWithLabelAndSubLabel + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.xib new file mode 100644 index 000000000..725ef0db4 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.xib @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.h new file mode 100644 index 000000000..7c8a45b00 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.h @@ -0,0 +1,35 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithLabelAndSwitch' inherits 'MXKTableViewCell' class. + It constains a 'UILabel' and a 'UISwitch' vertically centered. + */ +@interface MXKTableViewCellWithLabelAndSwitch : MXKTableViewCell + +@property (strong, nonatomic) IBOutlet UILabel *mxkLabel; +@property (strong, nonatomic) IBOutlet UISwitch *mxkSwitch; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSwitchLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSwitchTrailingConstraint; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.m new file mode 100644 index 000000000..a321d8385 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.m @@ -0,0 +1,22 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithLabelAndSwitch.h" + +@implementation MXKTableViewCellWithLabelAndSwitch + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.xib new file mode 100644 index 000000000..1bf5c96de --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.xib @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.h new file mode 100644 index 000000000..516f0dc61 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.h @@ -0,0 +1,47 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithLabelAndTextField' inherits 'MXKTableViewCell' class. + It constains a 'UILabel' and a 'UITextField' vertically centered. + */ +@interface MXKTableViewCellWithLabelAndTextField : MXKTableViewCell +{ +@protected + UIView *inputAccessoryView; +} + +@property (strong, nonatomic) IBOutlet UILabel *mxkLabel; +@property (strong, nonatomic) IBOutlet UITextField *mxkTextField; + +/** + The custom accessory view associated with the text field. This view is + actually used to retrieve the keyboard view. Indeed the keyboard view is the superview of + the accessory view when the text field become the first responder. + */ +@property (readonly) UIView *inputAccessoryView; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelMinWidthConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkTextFieldLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkTextFieldTrailingConstraint; + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.m new file mode 100644 index 000000000..23911441f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.m @@ -0,0 +1,58 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithLabelAndTextField.h" + +@implementation MXKTableViewCellWithLabelAndTextField +@synthesize inputAccessoryView; + +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier +{ + self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; + if (self) + { + // Add an accessory view to the text view in order to retrieve keyboard view. + inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; + _mxkTextField.inputAccessoryView = inputAccessoryView; + } + + return self; +} + +- (void)dealloc +{ + inputAccessoryView = nil; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + // Fix the minimum width of the label in order to keep it visible when the textfield width is increasing. + [_mxkLabel sizeToFit]; + _mxkLabelMinWidthConstraint.constant = _mxkLabel.frame.size.width; +} + +#pragma mark - UITextField delegate + +- (BOOL)textFieldShouldReturn:(UITextField*)textField +{ + // "Done" key has been pressed + [self.mxkTextField resignFirstResponder]; + return YES; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.xib new file mode 100644 index 000000000..285424726 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.xib @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.h new file mode 100644 index 000000000..332c37013 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.h @@ -0,0 +1,58 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithLabelTextFieldAndButton' inherits 'MXKTableViewCell' class. + It constains a 'UILabel' on the first line. The second line is composed with a 'UITextField' and a 'UIButton' + vertically aligned. + */ +@interface MXKTableViewCellWithLabelTextFieldAndButton : MXKTableViewCell +{ +@protected + UIView *inputAccessoryView; +} + +@property (strong, nonatomic) IBOutlet UILabel *mxkLabel; +@property (strong, nonatomic) IBOutlet UITextField *mxkTextField; +@property (strong, nonatomic) IBOutlet UIButton *mxkButton; + +/** + The custom accessory view associated with the text field. This view is + actually used to retrieve the keyboard view. Indeed the keyboard view is the superview of + the accessory view when the text field become the first responder. + */ +@property (readonly) UIView *inputAccessoryView; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelTrailingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkTextFieldLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkTextFieldTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkTextFieldBottomConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkButtonLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkButtonTrailingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkButtonMinWidthConstraint; + +- (IBAction)textFieldEditingChanged:(id)sender; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.m new file mode 100644 index 000000000..6a0d9f7fd --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.m @@ -0,0 +1,57 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithLabelTextFieldAndButton.h" + +@implementation MXKTableViewCellWithLabelTextFieldAndButton +@synthesize inputAccessoryView; + +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier +{ + self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; + if (self) + { + // Add an accessory view to the text view in order to retrieve keyboard view. + inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; + _mxkTextField.inputAccessoryView = inputAccessoryView; + } + + return self; +} + +- (void)dealloc +{ + inputAccessoryView = nil; +} + +#pragma mark - UITextField delegate + +- (BOOL)textFieldShouldReturn:(UITextField*)textField +{ + // "Done" key has been pressed + [self.mxkTextField resignFirstResponder]; + return YES; +} + +#pragma mark - Action + +- (IBAction)textFieldEditingChanged:(id)sender +{ + self.mxkButton.enabled = (self.mxkTextField.text.length != 0); +} + + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.xib new file mode 100644 index 000000000..0042c591f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.xib @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.h new file mode 100644 index 000000000..d9224a404 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.h @@ -0,0 +1,35 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithPicker' inherits 'MXKTableViewCell' class. + It constains a 'UIPickerView' vertically centered. + */ +@interface MXKTableViewCellWithPicker : MXKTableViewCell + +@property (strong, nonatomic) IBOutlet UIPickerView* mxkPickerView; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkPickerViewLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkPickerViewTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkPickerViewBottomConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkPickerViewTrailingConstraint; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.m new file mode 100644 index 000000000..79800813d --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.m @@ -0,0 +1,22 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithPicker.h" + +@implementation MXKTableViewCellWithPicker + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.xib new file mode 100644 index 000000000..081fe0e3a --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.xib @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.h new file mode 100644 index 000000000..f7e399637 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.h @@ -0,0 +1,35 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithSearchBar' inherits 'MXKTableViewCell' class. + It constains a 'UISearchBar' vertically centered. + */ +@interface MXKTableViewCellWithSearchBar : MXKTableViewCell + +@property (strong, nonatomic) IBOutlet UISearchBar *mxkSearchBar; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSearchBarLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSearchBarTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSearchBarBottomConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSearchBarTrailingConstraint; + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.m new file mode 100644 index 000000000..24fc295eb --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.m @@ -0,0 +1,22 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithSearchBar.h" + +@implementation MXKTableViewCellWithSearchBar + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.xib new file mode 100644 index 000000000..d068674c5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.xib @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.h new file mode 100644 index 000000000..3d9d195fe --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.h @@ -0,0 +1,50 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithTextFieldAndButton' inherits 'MXKTableViewCell' class. + It constains a 'UITextField' and a 'UIButton' vertically centered. + */ +@interface MXKTableViewCellWithTextFieldAndButton : MXKTableViewCell +{ +@protected + UIView *inputAccessoryView; +} + +@property (strong, nonatomic) IBOutlet UITextField *mxkTextField; +@property (strong, nonatomic) IBOutlet UIButton *mxkButton; + +/** + The custom accessory view associated with the text field. This view is + actually used to retrieve the keyboard view. Indeed the keyboard view is the superview of + the accessory view when the text field become the first responder. + */ +@property (readonly) UIView *inputAccessoryView; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkTextFieldLeadingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkButtonLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkButtonTrailingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkButtonMinWidthConstraint; + +- (IBAction)textFieldEditingChanged:(id)sender; +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.m new file mode 100644 index 000000000..29ef52a56 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.m @@ -0,0 +1,57 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithTextFieldAndButton.h" + +@implementation MXKTableViewCellWithTextFieldAndButton +@synthesize inputAccessoryView; + +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier +{ + self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; + if (self) + { + // Add an accessory view to the text view in order to retrieve keyboard view. + inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; + _mxkTextField.inputAccessoryView = inputAccessoryView; + } + + return self; +} + +- (void)dealloc +{ + inputAccessoryView = nil; +} + +#pragma mark - UITextField delegate + +- (BOOL)textFieldShouldReturn:(UITextField*)textField +{ + // "Done" key has been pressed + [self.mxkTextField resignFirstResponder]; + return YES; +} + +#pragma mark - Action + +- (IBAction)textFieldEditingChanged:(id)sender +{ + self.mxkButton.enabled = (self.mxkTextField.text.length != 0); +} + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.xib new file mode 100644 index 000000000..ca70d76e6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.xib @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.h new file mode 100644 index 000000000..e3ef4f366 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.h @@ -0,0 +1,35 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithTextView' inherits 'MXKTableViewCell' class. + It constains a 'UITextView' vertically centered. + */ +@interface MXKTableViewCellWithTextView : MXKTableViewCell + +@property (strong, nonatomic) IBOutlet UITextView *mxkTextView; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkTextViewLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkTextViewTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkTextViewBottomConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkTextViewTrailingConstraint; + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.m new file mode 100644 index 000000000..5f306b073 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.m @@ -0,0 +1,22 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithTextView.h" + +@implementation MXKTableViewCellWithTextView + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.xib new file mode 100644 index 000000000..6095702d8 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.xib @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterView.h b/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterView.h new file mode 100644 index 000000000..d19ce5ce6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterView.h @@ -0,0 +1,50 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +/** + 'MXKTableViewHeaderFooterView' class is used to define custom UITableViewHeaderFooterView (Either the header or footer for a section). + Each 'MXKTableViewHeaderFooterView-inherited' class has its own 'reuseIdentifier'. + */ +@interface MXKTableViewHeaderFooterView : UITableViewHeaderFooterView +{ +@protected + NSString *mxkReuseIdentifier; +} + +/** + Returns the `UINib` object initialized for the header/footer view. + + @return The initialized `UINib` object or `nil` if there were errors during + initialization or the nib file could not be located. + */ ++ (UINib *)nib; + +/** + The default reuseIdentifier of the 'MXKTableViewHeaderFooterView-inherited' class. + */ ++ (NSString*)defaultReuseIdentifier; + +/** + Customize the rendering of the header/footer view and its subviews (Do nothing by default). + This method is called when the view is initialized or prepared for reuse. + + Override this method to customize the view at the application level. + */ +- (void)customizeTableViewHeaderFooterViewRendering; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterView.m b/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterView.m new file mode 100644 index 000000000..c1b128be0 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterView.m @@ -0,0 +1,100 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewHeaderFooterView.h" +#import "NSBundle+MatrixKit.h" + +@implementation MXKTableViewHeaderFooterView + ++ (UINib *)nib +{ + // Check whether a nib file is available + NSBundle *mainBundle = [NSBundle mxk_bundleForClass:self.class]; + + NSString *path = [mainBundle pathForResource:NSStringFromClass([self class]) ofType:@"nib"]; + if (path) + { + return [UINib nibWithNibName:NSStringFromClass([self class]) bundle:mainBundle]; + } + return nil; +} + ++ (NSString*)defaultReuseIdentifier +{ + return NSStringFromClass([self class]); +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + [self customizeTableViewHeaderFooterViewRendering]; +} + +- (void)prepareForReuse +{ + [super prepareForReuse]; + + [self customizeTableViewHeaderFooterViewRendering]; +} + +- (instancetype)initWithReuseIdentifier:(NSString *)reuseIdentifier +{ + // Check whether a xib is defined + if ([[self class] nib]) + { + self = [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + } + else + { + self = [super initWithReuseIdentifier:reuseIdentifier]; + [self customizeTableViewHeaderFooterViewRendering]; + } + + if (reuseIdentifier.length) + { + // The provided identifier is not always conserved in the new created view. + // This depends how the method [initWithStyle:reuseIdentifier:] is trigerred. + // Trick: we store a copy of this identifier. + mxkReuseIdentifier = reuseIdentifier; + } + else + { + mxkReuseIdentifier = [[self class] defaultReuseIdentifier]; + } + + return self; +} + +- (NSString*)reuseIdentifier +{ + NSString *identifier = super.reuseIdentifier; + + if (!identifier.length) + { + identifier = mxkReuseIdentifier; + } + + return identifier; +} + +- (void)customizeTableViewHeaderFooterViewRendering +{ + // Do nothing by default. +} + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.h b/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.h new file mode 100644 index 000000000..c5b5f71c1 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.h @@ -0,0 +1,37 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewHeaderFooterView.h" + +/** + 'MXKTableViewHeaderFooterWithLabel' inherits 'MXKTableViewHeaderFooterView' class. + It constains a 'UILabel' vertically centered in which the dymanic fonts is enabled. + The height of this header is dynamically adapted to its content. + */ +@interface MXKTableViewHeaderFooterWithLabel : MXKTableViewHeaderFooterView + +@property (strong, nonatomic) IBOutlet UIView *mxkContentView; +@property (strong, nonatomic) IBOutlet UILabel *mxkLabel; + +/** + The following constraints are defined between the label and the content view (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelTrailingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelBottomConstraint; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.m b/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.m new file mode 100644 index 000000000..302617ad3 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.m @@ -0,0 +1,22 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewHeaderFooterWithLabel.h" + +@implementation MXKTableViewHeaderFooterWithLabel + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.xib new file mode 100644 index 000000000..1a3c4428c --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.xib @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKView.h b/Riot/Modules/MatrixKit/Views/MXKView.h new file mode 100644 index 000000000..1531a7923 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKView.h @@ -0,0 +1,34 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +/** + `MXKView` is a base class used to add some functionalities to the UIView class. + */ +@interface MXKView : UIView + +/** + Customize the rendering of the view and its subviews (Do nothing by default). + This method is called automatically when the view is initialized or loaded from an Interface Builder archive (or nib file). + + Override this method to customize the view instance at the application level. + It may be used to handle different rendering themes. In this case this method should be called whenever the theme has changed. + */ +- (void)customizeViewRendering; + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKView.m b/Riot/Modules/MatrixKit/Views/MXKView.m new file mode 100644 index 000000000..52e43cff8 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKView.m @@ -0,0 +1,53 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKView.h" + +@implementation MXKView + +- (instancetype)initWithFrame:(CGRect)frame +{ + self = [super initWithFrame:frame]; + if (self) + { + [self customizeViewRendering]; + } + return self; +} + +- (instancetype)initWithCoder:(NSCoder *)aDecoder +{ + self = [super initWithCoder:aDecoder]; + if (self) + { + [self customizeViewRendering]; + } + return self; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + [self customizeViewRendering]; +} + +- (void)customizeViewRendering +{ + // Do nothing by default. +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.h b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.h new file mode 100644 index 000000000..20e9f453b --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.h @@ -0,0 +1,67 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKTableViewCell.h" + +/** + MXPushRuleCreationTableViewCell instance is a table view cell used to create a new push rule. + */ +@interface MXKPushRuleCreationTableViewCell : MXKTableViewCell + +/** + The category the created push rule will belongs to (MXPushRuleKindContent by default). + */ +@property (nonatomic) MXPushRuleKind mxPushRuleKind; + +/** + The related matrix session + */ +@property (nonatomic) MXSession* mxSession; + +/** + The graphics items + */ +@property (strong, nonatomic) IBOutlet UITextField* inputTextField; + +@property (unsafe_unretained, nonatomic) IBOutlet UISegmentedControl *actionSegmentedControl; +@property (unsafe_unretained, nonatomic) IBOutlet UISwitch *soundSwitch; +@property (unsafe_unretained, nonatomic) IBOutlet UISwitch *highlightSwitch; + +@property (strong, nonatomic) IBOutlet UIButton* addButton; + +@property (strong, nonatomic) IBOutlet UIPickerView* roomPicker; +@property (unsafe_unretained, nonatomic) IBOutlet UIButton *roomPickerDoneButton; + +/** + Force dismiss keyboard. + */ +- (void)dismissKeyboard; + +/** + Action registered to handle text field editing change (UIControlEventEditingChanged). + */ +- (IBAction)textFieldEditingChanged:(id)sender; + +/** + Action registered on the following events: + - 'UIControlEventTouchUpInside' for UIButton instances. + - 'UIControlEventValueChanged' for UISwitch and UISegmentedControl instances. + */ +- (IBAction)onButtonPressed:(id)sender; + +@end diff --git a/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.m b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.m new file mode 100644 index 000000000..b0929edc9 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.m @@ -0,0 +1,208 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKPushRuleCreationTableViewCell.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@interface MXKPushRuleCreationTableViewCell () +{ + /** + Snapshot of matrix session rooms used in room picker (in case of MXPushRuleKindRoom) + */ + NSArray* rooms; +} +@end + +@implementation MXKPushRuleCreationTableViewCell + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + self.mxPushRuleKind = MXPushRuleKindContent; +} + +- (void)setMxPushRuleKind:(MXPushRuleKind)mxPushRuleKind +{ + switch (mxPushRuleKind) + { + case MXPushRuleKindContent: + _inputTextField.placeholder = [MatrixKitL10n notificationSettingsWordToMatch]; + _inputTextField.autocorrectionType = UITextAutocorrectionTypeDefault; + break; + case MXPushRuleKindRoom: + _inputTextField.placeholder = [MatrixKitL10n notificationSettingsSelectRoom]; + break; + case MXPushRuleKindSender: + _inputTextField.placeholder = [MatrixKitL10n notificationSettingsSenderHint]; + _inputTextField.autocorrectionType = UITextAutocorrectionTypeNo; + break; + default: + break; + } + + _inputTextField.hidden = NO; + _roomPicker.hidden = YES; + _roomPickerDoneButton.hidden = YES; + + _mxPushRuleKind = mxPushRuleKind; +} + +- (void)dismissKeyboard +{ + [_inputTextField resignFirstResponder]; +} + +#pragma mark - UITextField delegate + +- (IBAction)textFieldEditingChanged:(id)sender +{ + // Update Add Room button + if (_inputTextField.text.length) + { + _addButton.enabled = YES; + } + else + { + _addButton.enabled = NO; + } +} + +- (BOOL)textFieldShouldBeginEditing:(UITextField *)textField +{ + if (textField == _inputTextField && _mxPushRuleKind == MXPushRuleKindRoom) + { + _inputTextField.hidden = YES; + _roomPicker.hidden = NO; + _roomPickerDoneButton.hidden = NO; + return NO; + } + + return YES; +} + +- (void)textFieldDidBeginEditing:(UITextField *)textField +{ + if (textField == _inputTextField && _mxPushRuleKind == MXPushRuleKindSender) + { + if (textField.text.length == 0) + { + textField.text = @"@"; + } + } +} + +- (BOOL)textFieldShouldReturn:(UITextField*) textField +{ + // "Done" key has been pressed + [textField resignFirstResponder]; + return YES; +} + +#pragma mark - Actions + +- (IBAction)onButtonPressed:(id)sender +{ + [self dismissKeyboard]; + + if (sender == _addButton) + { + // Disable button to prevent multiple request + _addButton.enabled = NO; + + if (_mxPushRuleKind == MXPushRuleKindContent) + { + [_mxSession.notificationCenter addContentRule:_inputTextField.text + notify:(_actionSegmentedControl.selectedSegmentIndex == 0) + sound:_soundSwitch.on + highlight:_highlightSwitch.on]; + } + else if (_mxPushRuleKind == MXPushRuleKindRoom) + { + MXRoom* room; + NSInteger row = [_roomPicker selectedRowInComponent:0]; + if ((row >= 0) && (row < rooms.count)) + { + room = [rooms objectAtIndex:row]; + } + + if (room) + { + [_mxSession.notificationCenter addRoomRule:room.roomId + notify:(_actionSegmentedControl.selectedSegmentIndex == 0) + sound:_soundSwitch.on + highlight:_highlightSwitch.on]; + } + + } + else if (_mxPushRuleKind == MXPushRuleKindSender) + { + [_mxSession.notificationCenter addSenderRule:_inputTextField.text + notify:(_actionSegmentedControl.selectedSegmentIndex == 0) + sound:_soundSwitch.on + highlight:_highlightSwitch.on]; + } + + + _inputTextField.text = nil; + } + else if (sender == _roomPickerDoneButton) + { + NSInteger row = [_roomPicker selectedRowInComponent:0]; + // sanity check + if ((row >= 0) && (row < rooms.count)) + { + MXRoom* room = [rooms objectAtIndex:row]; + _inputTextField.text = room.summary.displayname; + _addButton.enabled = YES; + } + + _inputTextField.hidden = NO; + _roomPicker.hidden = YES; + _roomPickerDoneButton.hidden = YES; + } +} + +#pragma mark - UIPickerViewDataSource + +- (NSInteger)numberOfComponentsInPickerView:(UIPickerView *)pickerView +{ + return 1; +} + +- (NSInteger)pickerView:(UIPickerView *)pickerView numberOfRowsInComponent:(NSInteger)component +{ + rooms = [_mxSession.rooms sortedArrayUsingComparator:^NSComparisonResult(MXRoom* firstRoom, MXRoom* secondRoom) { + + // Alphabetic order + return [firstRoom.summary.displayname compare:secondRoom.summary.displayname options:NSCaseInsensitiveSearch]; + }]; + + return rooms.count; +} + +#pragma mark - UIPickerViewDelegate + +- (NSString *)pickerView:(UIPickerView *)pickerView titleForRow:(NSInteger)row forComponent:(NSInteger)component +{ + MXRoom* room = [rooms objectAtIndex:row]; + return room.summary.displayname; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.xib b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.xib new file mode 100644 index 000000000..a3b574312 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.xib @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.h b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.h new file mode 100644 index 000000000..b569cb7c3 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.h @@ -0,0 +1,57 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKTableViewCell.h" + +/** + MKPushRuleTableViewCell instance is a table view cell used to display a notification rule. + */ +@interface MXKPushRuleTableViewCell : MXKTableViewCell + +/** + The displayed rule + */ +@property (nonatomic) MXPushRule* mxPushRule; + +/** + The related matrix session + */ +@property (nonatomic) MXSession* mxSession; + +/** + The graphics items + */ +@property (strong, nonatomic) IBOutlet UIButton* controlButton; + +@property (strong, nonatomic) IBOutlet UIButton* deleteButton; +@property (unsafe_unretained, nonatomic) IBOutlet NSLayoutConstraint *deleteButtonWidthConstraint; + +@property (strong, nonatomic) IBOutlet UILabel* ruleDescription; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *ruleDescriptionBottomConstraint; +@property (unsafe_unretained, nonatomic) IBOutlet NSLayoutConstraint *ruleDescriptionLeftConstraint; + + +@property (strong, nonatomic) IBOutlet UILabel* ruleActions; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *ruleActionsHeightConstraint; + +/** + Action registered on `UIControlEventTouchUpInside` event for both buttons. + */ +- (IBAction)onButtonPressed:(id)sender; + +@end diff --git a/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.m b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.m new file mode 100644 index 000000000..221c16c2c --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.m @@ -0,0 +1,174 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKPushRuleTableViewCell.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@implementation MXKPushRuleTableViewCell + +- (void)awakeFromNib +{ + [super awakeFromNib]; + [_controlButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_pause"] forState:UIControlStateNormal]; + [_controlButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_pause"] forState:UIControlStateHighlighted]; + + [_deleteButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_minus"] forState:UIControlStateNormal]; + [_deleteButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_minus"] forState:UIControlStateHighlighted]; +} + +- (void)customizeTableViewCellRendering +{ + [super customizeTableViewCellRendering]; + + _controlButton.backgroundColor = [UIColor clearColor]; + + _deleteButton.backgroundColor = [UIColor clearColor]; + + _ruleDescription.numberOfLines = 0; +} + +- (void)setMxPushRule:(MXPushRule *)mxPushRule +{ + // Set the right control icon + if (mxPushRule.enabled) + { + [_controlButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_pause"] forState:UIControlStateNormal]; + [_controlButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_pause"] forState:UIControlStateHighlighted]; + } + else + { + [_controlButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_play"] forState:UIControlStateNormal]; + [_controlButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_play"] forState:UIControlStateHighlighted]; + } + + // Prepare rule description (use rule id by default) + NSString *description = mxPushRule.ruleId; + + switch (mxPushRule.kind) + { + case MXPushRuleKindContent: + description = mxPushRule.pattern; + break; + case MXPushRuleKindRoom: + { + MXRoom *room = [_mxSession roomWithRoomId:mxPushRule.ruleId]; + if (room) + { + description = [MatrixKitL10n notificationSettingsRoomRuleTitle:room.summary.displayname]; + } + break; + } + default: + break; + } + + _ruleDescription.text = description; + + // Delete button and rule actions are hidden for predefined rules + if (mxPushRule.isDefault) + { + if (!_deleteButton.hidden) + { + _deleteButton.hidden = YES; + // Adjust layout by updating constraint + _ruleDescriptionLeftConstraint.constant -= _deleteButtonWidthConstraint.constant; + } + + if (!_ruleActions.isHidden) + { + _ruleActions.hidden = YES; + // Adjust layout by updating constraint + _ruleDescriptionBottomConstraint.constant -= _ruleActionsHeightConstraint.constant; + } + } + else + { + if (_deleteButton.hidden) + { + _deleteButton.hidden = NO; + // Adjust layout by updating constraint + _ruleDescriptionLeftConstraint.constant += _deleteButtonWidthConstraint.constant; + } + + // Prepare rule actions description + NSString *notify; + NSString *sound = @""; + NSString *highlight = @""; + for (MXPushRuleAction *ruleAction in mxPushRule.actions) + { + if (ruleAction.actionType == MXPushRuleActionTypeDontNotify) + { + notify = [MatrixKitL10n notificationSettingsNeverNotify]; + sound = @""; + highlight = @""; + break; + } + else if (ruleAction.actionType == MXPushRuleActionTypeNotify || ruleAction.actionType == MXPushRuleActionTypeCoalesce) + { + notify = [MatrixKitL10n notificationSettingsAlwaysNotify]; + } + else if (ruleAction.actionType == MXPushRuleActionTypeSetTweak) + { + if ([ruleAction.parameters[@"set_tweak"] isEqualToString:@"sound"]) + { + sound = [NSString stringWithFormat:@", %@", [MatrixKitL10n notificationSettingsCustomSound]]; + } + else if ([ruleAction.parameters[@"set_tweak"] isEqualToString:@"highlight"]) + { + // Check the highlight tweak "value" + // If not present, highlight. Else check its value before highlighting + if (nil == ruleAction.parameters[@"value"] || YES == [ruleAction.parameters[@"value"] boolValue]) + { + highlight = [NSString stringWithFormat:@", %@", [MatrixKitL10n notificationSettingsHighlight]]; + } + } + } + } + + if (notify.length) + { + _ruleActions.text = [NSString stringWithFormat:@"%@%@%@", notify, sound, highlight]; + } + + if (_ruleActions.isHidden) + { + _ruleActions.hidden = NO; + // Adjust layout by updating constraint + _ruleDescriptionBottomConstraint.constant += _ruleActionsHeightConstraint.constant; + } + } + + _mxPushRule = mxPushRule; +} + +- (IBAction)onButtonPressed:(id)sender +{ + if (sender == _controlButton) + { + // Swap enable state + [_mxSession.notificationCenter enableRule:_mxPushRule isEnabled:!_mxPushRule.enabled]; + } + else if (sender == _deleteButton) + { + [_mxSession.notificationCenter removeRule:_mxPushRule]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.xib b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.xib new file mode 100644 index 000000000..b78c75b9a --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.xib @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.h b/Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.h new file mode 100644 index 000000000..cc7c3708d --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.h @@ -0,0 +1,29 @@ +/* + Copyright 2017 Aram Sargsyan + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import "MXKTableViewCell.h" + +@class MXKImageView; + + +@interface MXKReadReceiptTableViewCell : MXKTableViewCell + +@property (weak, nonatomic) IBOutlet MXKImageView *avatarImageView; +@property (weak, nonatomic) IBOutlet UILabel *displayNameLabel; +@property (weak, nonatomic) IBOutlet UILabel *receiptDescriptionLabel; + +@end diff --git a/Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.m b/Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.m new file mode 100644 index 000000000..56216a1ce --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.m @@ -0,0 +1,44 @@ +/* + Copyright 2017 Aram Sargsyan + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKReadReceiptTableViewCell.h" +#import "MXKImageView.h" + +@implementation MXKReadReceiptTableViewCell + +- (void)awakeFromNib +{ + [super awakeFromNib]; + self.avatarImageView.enableInMemoryCache = YES; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + if (self.avatarImageView) { + //Make imageView round + self.avatarImageView.layer.cornerRadius = CGRectGetWidth(self.avatarImageView.frame)/2; + self.avatarImageView.clipsToBounds = YES; + } +} + +- (void)setSelected:(BOOL)selected animated:(BOOL)animated +{ + [super setSelected:selected animated:animated]; + // Configure the view for the selected state +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.xib b/Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.xib new file mode 100644 index 000000000..f006664aa --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.xib @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomBubbleTableViewCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomBubbleTableViewCell.h new file mode 100644 index 000000000..60cad3248 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomBubbleTableViewCell.h @@ -0,0 +1,328 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" +#import "MXKCellRendering.h" +#import "MXKReceiptSendersContainer.h" + +#import + +@class MXKImageView; +@class MXKPieChartView; +@class MXKRoomBubbleCellData; + +#pragma mark - MXKCellRenderingDelegate cell tap locations + +/** + Action identifier used when the user tapped on message text view. + + The `userInfo` dictionary contains an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the tapped event. + */ +extern NSString *const kMXKRoomBubbleCellTapOnMessageTextView; + +/** + Action identifier used when the user tapped on user name label. + + The `userInfo` dictionary contains an `NSString` object under the `kMXKRoomBubbleCellUserIdKey` key, representing the user id of the tapped name label. + */ +extern NSString *const kMXKRoomBubbleCellTapOnSenderNameLabel; + +/** + Action identifier used when the user tapped on avatar view. + + The `userInfo` dictionary contains an `NSString` object under the `kMXKRoomBubbleCellUserIdKey` key, representing the user id of the tapped avatar. + */ +extern NSString *const kMXKRoomBubbleCellTapOnAvatarView; + +/** + Action identifier used when the user tapped on date/time container. + + The `userInfo` is nil. + */ +extern NSString *const kMXKRoomBubbleCellTapOnDateTimeContainer; + +/** + Action identifier used when the user tapped on attachment view. + + The `userInfo` is nil. The attachment can be retrieved via MXKRoomBubbleTableViewCell.attachmentView. + */ +extern NSString *const kMXKRoomBubbleCellTapOnAttachmentView; + +/** + Action identifier used when the user tapped on overlay container. + + The `userInfo` is nil + */ +extern NSString *const kMXKRoomBubbleCellTapOnOverlayContainer; + +/** + Action identifier used when the user tapped on content view. + + The `userInfo` dictionary may contain an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the event displayed at the level of the tapped line. This dictionary is empty if no event correspond to the tapped position. + */ +extern NSString *const kMXKRoomBubbleCellTapOnContentView; + +/** + Action identifier used when the user pressed unsent button displayed in front of an unsent event. + + The `userInfo` dictionary contains an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the unsent event. + */ +extern NSString *const kMXKRoomBubbleCellUnsentButtonPressed; + +/** + Action identifier used when the user long pressed on a displayed event. + + The `userInfo` dictionary contains an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the selected event. + */ +extern NSString *const kMXKRoomBubbleCellLongPressOnEvent; + +/** + Action identifier used when the user long pressed on progress view. + + The `userInfo` is nil. The progress view can be retrieved via MXKRoomBubbleTableViewCell.progressView. + */ +extern NSString *const kMXKRoomBubbleCellLongPressOnProgressView; + +/** + Action identifier used when the user long pressed on avatar view. + + The `userInfo` dictionary contains an `NSString` object under the `kMXKRoomBubbleCellUserIdKey` key, representing the user id of the concerned avatar. + */ +extern NSString *const kMXKRoomBubbleCellLongPressOnAvatarView; + +/** + Action identifier used when the user clicked on a link. + + This action is sent via the MXKCellRenderingDelegate `shouldDoAction` operation. + + The `userInfo` dictionary contains a `NSURL` object under the `kMXKRoomBubbleCellUrl` key, representing the url the user wants to open. And a NSNumber wrapping `UITextItemInteraction` raw value, representing the type of interaction expected with the URL, under the `kMXKRoomBubbleCellUrlItemInteraction` key. + + The shouldDoAction implementation must return NO to prevent the system (safari) from opening the link. + + @discussion: If the link refers to a room alias/id, a user id or an event id, the non-ASCII characters (like '#' in room alias) has been + escaped to be able to convert it into a legal URL string. + */ +extern NSString *const kMXKRoomBubbleCellShouldInteractWithURL; + +/** + Notifications `userInfo` keys + */ +extern NSString *const kMXKRoomBubbleCellUserIdKey; +extern NSString *const kMXKRoomBubbleCellEventKey; +extern NSString *const kMXKRoomBubbleCellEventIdKey; +extern NSString *const kMXKRoomBubbleCellReceiptsContainerKey; +extern NSString *const kMXKRoomBubbleCellUrl; +extern NSString *const kMXKRoomBubbleCellUrlItemInteraction; + +#pragma mark - MXKRoomBubbleTableViewCell + +/** + `MXKRoomBubbleTableViewCell` is a base class for displaying a room bubble. + + This class is used to handle a maximum of items which may be present in bubbles display (like the user's picture view, the message text view...). + To optimize bubbles rendering, we advise to define a .xib for each kind of bubble layout (with or without sender's information, with or without attachment...). + Each inherited class should define only the actual displayed items. + */ +@interface MXKRoomBubbleTableViewCell : MXKTableViewCell +{ +@protected + /** + The current bubble data displayed by the table view cell + */ + MXKRoomBubbleCellData *bubbleData; +} + +/** + The current bubble data displayed by the table view cell + */ +@property (strong, nonatomic, readonly) MXKRoomBubbleCellData *bubbleData; + +/** + Option to highlight or not the content of message text view (May be used in case of text selection). + NO by default. + */ +@property (nonatomic) BOOL allTextHighlighted; + +/** + Tell whether the animation should start automatically in case of animated gif (NO by default). + */ +@property (nonatomic) BOOL isAutoAnimatedGif; + +/** + The default picture displayed when no picture is available. + */ +@property (nonatomic) UIImage *picturePlaceholder; + +/** + The list of the temporary subviews that should be removed before reusing the cell (nil by default). + */ +@property (nonatomic) NSMutableArray *tmpSubviews; + +/** + The read receipts alignment. + By default, they are left aligned. + */ +@property (nonatomic) ReadReceiptsAlignment readReceiptsAlignment; + +@property (weak, nonatomic) IBOutlet UILabel *userNameLabel; +@property (weak, nonatomic) IBOutlet UIView *userNameTapGestureMaskView; +@property (strong, nonatomic) IBOutlet MXKImageView *pictureView; +@property (weak, nonatomic) IBOutlet UITextView *messageTextView; +@property (strong, nonatomic) IBOutlet MXKImageView *attachmentView; +@property (strong, nonatomic) IBOutlet UIImageView *playIconView; +@property (strong, nonatomic) IBOutlet UIImageView *fileTypeIconView; +@property (weak, nonatomic) IBOutlet UIView *bubbleInfoContainer; +@property (weak, nonatomic) IBOutlet UIView *bubbleOverlayContainer; + +/** + The container view in which the encryption information may be displayed + */ +@property (weak, nonatomic) IBOutlet UIView *encryptionStatusContainerView; + +@property (weak, nonatomic) IBOutlet UIView *progressView; +@property (weak, nonatomic) IBOutlet UILabel *statsLabel; +@property (weak, nonatomic) IBOutlet MXKPieChartView *progressChartView; + +/** + The constraints which defines the relationship between messageTextView and its superview. + The defined constant are supposed >= 0. + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *msgTextViewTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *msgTextViewBottomConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *msgTextViewLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *msgTextViewTrailingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *msgTextViewWidthConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *msgTextViewMinHeightConstraint; + +/** + The constraints which defines the relationship between attachmentView and its superview + The defined constant are supposed >= 0. + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *attachViewWidthConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *attachViewMinHeightConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *attachViewTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *attachViewBottomConstraint; + +/** + The constraints which defines the relationship between bubbleInfoContainer and its superview + The defined constant are supposed >= 0. + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *bubbleInfoContainerTopConstraint; + +/** + The read marker view and its layout constraints (nil by default). + */ +@property (nonatomic) UIView *readMarkerView; +@property (nonatomic) NSLayoutConstraint *readMarkerViewTopConstraint; +@property (nonatomic) NSLayoutConstraint *readMarkerViewLeadingConstraint; +@property (nonatomic) NSLayoutConstraint *readMarkerViewTrailingConstraint; +@property (nonatomic) NSLayoutConstraint *readMarkerViewHeightConstraint; + +/** + The potential webview used to render an attachment (for example an animated gif). + */ +@property (nonatomic) WKWebView *attachmentWebView; + +/** + Called during the designated initializer of the UITableViewCell class to set the default + properties values. + + You should not call this method directly. + + Subclasses can override this method as needed to customize the initialization. + */ +- (void)finalizeInit; + +/** + Handle progressView display. + */ +- (void)startProgressUI; +- (void)updateProgressUI:(NSDictionary*)statisticsDict; + +#pragma mark - Original Xib values + +/** + Get an original instance of the `MXKRoomBubbleTableViewCell` child class. + + @return an instance of the child class caller which has the original Xib values. + */ ++ (MXKRoomBubbleTableViewCell*)cellWithOriginalXib; + +/** + Disable the handling of the long press on event (see kMXKRoomBubbleCellLongPressOnEvent). NO by default. + + CAUTION: Changing this flag only impact the new created cells (existing 'MXKRoomBubbleTableViewCell' instances are unchanged). + */ ++ (void)disableLongPressGestureOnEvent:(BOOL)disable; + +/** + Method used during [MXKCellRendering render:] to check the provided `cellData` + and prepare the protected `bubbleData`. + Do not override it. + + @param cellData the data object to render. + */ +- (void)prepareRender:(MXKCellData*)cellData; + +/** + Refresh the flair information added to the sender display name. + */ +- (void)renderSenderFlair; + +/** + Highlight text message related to a specific event in the displayed message. + + @param eventId the id of the event to highlight (use nil to cancel highlighting). + */ +- (void)highlightTextMessageForEvent:(NSString*)eventId; + +/** + The top position of an event in the cell. + + A cell can display several events. The method returns the vertical position of a given + event in the cell. + + @return the y position (in pixel) of the event in the cell. + */ +- (CGFloat)topPositionOfEvent:(NSString*)eventId; + +/** + The bottom position of an event in the cell. + + A cell can display several events. The method returns the vertical position of the bottom part + of a given event in the cell. + + @return the y position (in pixel) of the bottom part of the event in the cell. + */ +- (CGFloat)bottomPositionOfEvent:(NSString*)eventId; + +/** + Restore `attachViewBottomConstraint` constant to default value. + */ +- (void)resetAttachmentViewBottomConstraintConstant; + +/** + Redeclare heightForCellData:withMaximumWidth: method from MXKCellRendering to use it as a class method in Swift and not a static method. + */ ++ (CGFloat)heightForCellData:(MXKCellData*)cellData withMaximumWidth:(CGFloat)maxWidth; + +/** + Setup outlets views. Useful to call when cell subclass does not use a xib otherwise this method is called automatically in `awakeFromNib`. + */ +- (void)setupViews; + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomBubbleTableViewCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomBubbleTableViewCell.m new file mode 100644 index 000000000..b550a1ef1 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomBubbleTableViewCell.m @@ -0,0 +1,1563 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomBubbleTableViewCell.h" + +#import "MXKImageView.h" +#import "MXKPieChartView.h" +#import "MXKRoomBubbleCellData.h" +#import "MXKTools.h" + +#import "MXKConstants.h" + +#import "NSBundle+MatrixKit.h" +#import "MXRoom+Sync.h" +#import "MXKMessageTextView.h" +#import "UITextView+MatrixKit.h" + +#pragma mark - Constant definitions +NSString *const kMXKRoomBubbleCellTapOnMessageTextView = @"kMXKRoomBubbleCellTapOnMessageTextView"; +NSString *const kMXKRoomBubbleCellTapOnSenderNameLabel = @"kMXKRoomBubbleCellTapOnSenderNameLabel"; +NSString *const kMXKRoomBubbleCellTapOnAvatarView = @"kMXKRoomBubbleCellTapOnAvatarView"; +NSString *const kMXKRoomBubbleCellTapOnDateTimeContainer = @"kMXKRoomBubbleCellTapOnDateTimeContainer"; +NSString *const kMXKRoomBubbleCellTapOnAttachmentView = @"kMXKRoomBubbleCellTapOnAttachmentView"; +NSString *const kMXKRoomBubbleCellTapOnOverlayContainer = @"kMXKRoomBubbleCellTapOnOverlayContainer"; +NSString *const kMXKRoomBubbleCellTapOnContentView = @"kMXKRoomBubbleCellTapOnContentView"; + +NSString *const kMXKRoomBubbleCellUnsentButtonPressed = @"kMXKRoomBubbleCellUnsentButtonPressed"; + +NSString *const kMXKRoomBubbleCellLongPressOnEvent = @"kMXKRoomBubbleCellLongPressOnEvent"; +NSString *const kMXKRoomBubbleCellLongPressOnProgressView = @"kMXKRoomBubbleCellLongPressOnProgressView"; +NSString *const kMXKRoomBubbleCellLongPressOnAvatarView = @"kMXKRoomBubbleCellLongPressOnAvatarView"; +NSString *const kMXKRoomBubbleCellShouldInteractWithURL = @"kMXKRoomBubbleCellShouldInteractWithURL"; + +NSString *const kMXKRoomBubbleCellUserIdKey = @"kMXKRoomBubbleCellUserIdKey"; +NSString *const kMXKRoomBubbleCellEventKey = @"kMXKRoomBubbleCellEventKey"; +NSString *const kMXKRoomBubbleCellEventIdKey = @"kMXKRoomBubbleCellEventIdKey"; +NSString *const kMXKRoomBubbleCellReceiptsContainerKey = @"kMXKRoomBubbleCellReceiptsContainerKey"; +NSString *const kMXKRoomBubbleCellUrl = @"kMXKRoomBubbleCellUrl"; +NSString *const kMXKRoomBubbleCellUrlItemInteraction = @"kMXKRoomBubbleCellUrlItemInteraction"; + +static BOOL _disableLongPressGestureOnEvent; + +@interface MXKRoomBubbleTableViewCell () +{ + // The list of UIViews used to fix the display of side borders for HTML blockquotes + NSMutableArray *htmlBlockquoteSideBorderViews; +} + +@property (nonatomic, weak) UIView *messageTextBackgroundView; +@property (nonatomic) double attachmentViewBottomConstraintDefaultConstant; + +@end + +@implementation MXKRoomBubbleTableViewCell +@synthesize delegate, bubbleData, readReceiptsAlignment; +@synthesize mxkCellData; + ++ (instancetype)roomBubbleTableViewCell +{ + MXKRoomBubbleTableViewCell *instance = nil; + + // Check whether a xib is defined + if ([[self class] nib]) + { + @try { + instance = [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + } + @catch (NSException *exception) { + } + } + + if (!instance) + { + instance = [[self alloc] init]; + } + + return instance; +} + ++ (void)disableLongPressGestureOnEvent:(BOOL)disable +{ + _disableLongPressGestureOnEvent = disable; +} + +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(nullable NSString *)reuseIdentifier +{ + self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; + if (self) + { + [self finalizeInit]; + } + return self; +} +- (nullable instancetype)initWithCoder:(NSCoder *)aDecoder +{ + self = [super initWithCoder:aDecoder]; + if (self) + { + [self finalizeInit]; + } + return self; +} + +- (void)finalizeInit +{ + self.readReceiptsAlignment = ReadReceiptAlignmentLeft; + _allTextHighlighted = NO; + _isAutoAnimatedGif = NO; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + [self setupViews]; +} + +- (void)setupViews +{ + if (self.userNameLabel) + { + // Listen to name tap + UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onSenderNameTap:)]; + [tapGesture setNumberOfTouchesRequired:1]; + [tapGesture setNumberOfTapsRequired:1]; + [tapGesture setDelegate:self]; + + if (self.userNameTapGestureMaskView) + { + [self.userNameTapGestureMaskView addGestureRecognizer:tapGesture]; + } + else + { + [self.userNameLabel addGestureRecognizer:tapGesture]; + self.userNameLabel.userInteractionEnabled = YES; + } + } + + if (self.pictureView) + { + self.pictureView.mediaFolder = kMXMediaManagerAvatarThumbnailFolder; + + // Listen to avatar tap + UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onAvatarTap:)]; + [tapGesture setNumberOfTouchesRequired:1]; + [tapGesture setNumberOfTapsRequired:1]; + [tapGesture setDelegate:self]; + [self.pictureView addGestureRecognizer:tapGesture]; + self.pictureView.userInteractionEnabled = YES; + + // Add a long gesture recognizer on avatar (in order to display for example the member details) + UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(onLongPressGesture:)]; + [self.pictureView addGestureRecognizer:longPress]; + } + + if (self.messageTextView) + { + // Listen to textView tap + UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onMessageTap:)]; + [tapGesture setNumberOfTouchesRequired:1]; + [tapGesture setNumberOfTapsRequired:1]; + [tapGesture setDelegate:self]; + [self.messageTextView addGestureRecognizer:tapGesture]; + self.messageTextView.userInteractionEnabled = YES; + + // Recognise and make tappable phone numbers, address, etc. + self.messageTextView.dataDetectorTypes = UIDataDetectorTypeAll; + + // Listen to link click + self.messageTextView.delegate = self; + + if (_disableLongPressGestureOnEvent == NO) + { + // Add a long gesture recognizer on text view (in order to display for example the event details) + UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(onLongPressGesture:)]; + longPress.delegate = self; + + // MXKMessageTextView does not catch touches outside of links. Add a background view to handle long touch. + if ([self.messageTextView isKindOfClass:[MXKMessageTextView class]]) + { + UIView *messageTextBackgroundView = [[UIView alloc] initWithFrame:self.messageTextView.frame]; + messageTextBackgroundView.backgroundColor = [UIColor clearColor]; + [self.contentView insertSubview:messageTextBackgroundView belowSubview:self.messageTextView]; + messageTextBackgroundView.translatesAutoresizingMaskIntoConstraints = NO; + [messageTextBackgroundView.leftAnchor constraintEqualToAnchor:self.messageTextView.leftAnchor].active = YES; + [messageTextBackgroundView.rightAnchor constraintEqualToAnchor:self.messageTextView.rightAnchor].active = YES; + [messageTextBackgroundView.topAnchor constraintEqualToAnchor:self.messageTextView.topAnchor].active = YES; + [messageTextBackgroundView.bottomAnchor constraintEqualToAnchor:self.messageTextView.bottomAnchor].active = YES; + + [messageTextBackgroundView addGestureRecognizer:longPress]; + + self.messageTextBackgroundView = messageTextBackgroundView; + } + else + { + [self.messageTextView addGestureRecognizer:longPress]; + } + } + } + + if (self.playIconView) + { + self.playIconView.image = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"play"]; + } + + if (self.bubbleOverlayContainer) + { + // Add tap recognizer on overlay container + UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onOverlayTap:)]; + [tapGesture setNumberOfTouchesRequired:1]; + [tapGesture setNumberOfTapsRequired:1]; + [tapGesture setDelegate:self]; + [self.bubbleOverlayContainer addGestureRecognizer:tapGesture]; + } + + // Listen to content view tap by default + UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onContentViewTap:)]; + [tapGesture setNumberOfTouchesRequired:1]; + [tapGesture setNumberOfTapsRequired:1]; + [tapGesture setDelegate:self]; + [self.contentView addGestureRecognizer:tapGesture]; + + if (_disableLongPressGestureOnEvent == NO) + { + // Add a long gesture recognizer on text view (in order to display for example the event details) + UILongPressGestureRecognizer *longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(onLongPressGesture:)]; + longPressGestureRecognizer.delegate = self; + [self.contentView addGestureRecognizer:longPressGestureRecognizer]; + } + + [self setupConstraintsConstantDefaultValues]; +} + +- (void)customizeTableViewCellRendering +{ + [super customizeTableViewCellRendering]; + + // Clear the default background color of a MXKImageView instance + self.pictureView.defaultBackgroundColor = [UIColor clearColor]; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + if (self.pictureView) + { + // Round image view + [self.pictureView.layer setCornerRadius:self.pictureView.frame.size.width / 2]; + self.pictureView.clipsToBounds = YES; + } +} + +/** + Manually add a side border for HTML blockquotes. + + @discussion + `NSAttributedString` and `UITextView` classes do not support it natively. This + method add an `UIView` to the `UITextView` that implements this border. + + @param canRetry YES if the method can retry later if the UI is not yet ready. + */ +- (void)fixHTMLBlockQuoteRendering:(BOOL)canRetry +{ + if (self.messageTextView && htmlBlockquoteSideBorderViews.count == 0) + { + __weak typeof(self) weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + + if (weakSelf) + { + typeof(self) self = weakSelf; + [MXKTools enumerateMarkedBlockquotesInAttributedString:self.messageTextView.attributedText + usingBlock:^(NSRange range, BOOL *stop) + { + // Compute the UITextRange of the blockquote + UITextPosition *beginning = self.messageTextView.beginningOfDocument; + UITextPosition *start = [self.messageTextView positionFromPosition:beginning offset:range.location]; + UITextPosition *end = [self.messageTextView positionFromPosition:start offset:range.length]; + UITextRange *textRange = [self.messageTextView textRangeFromPosition:start toPosition:end]; + + // Get the rect area of this blockquote within the cell + // There can be several rects in case of multilines. Hence, the merge + NSArray *array = [self.messageTextView selectionRectsForRange:textRange]; + CGRect textRect = CGRectNull; + for (UITextSelectionRect *rect in array) + { + if (rect.rect.size.width) + { + textRect = CGRectUnion(textRect, rect.rect); + } + } + + if (!CGRectIsNull(textRect)) + { + // Add a left border with a height that covers all the blockquote block height + // TODO: Manage RTL language + UIView *sideBorderView = [[UIView alloc] initWithFrame:CGRectMake(5, textRect.origin.y, 4, textRect.size.height)]; + sideBorderView.backgroundColor = self.bubbleData.eventFormatter.htmlBlockquoteBorderColor; + [sideBorderView setTranslatesAutoresizingMaskIntoConstraints:NO]; + + [self.messageTextView addSubview:sideBorderView]; + + if (!self->htmlBlockquoteSideBorderViews) + { + self->htmlBlockquoteSideBorderViews = [NSMutableArray array]; + } + + [self->htmlBlockquoteSideBorderViews addObject:sideBorderView]; + } + else if (canRetry) + { + // Have not found rect area that corresponds to the blockquote + // Try again later when the UI is more ready. Try it only once + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self fixHTMLBlockQuoteRendering:NO]; + }); + } + }]; + } + }); + } +} + +- (void)dealloc +{ + // remove any pending observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + delegate = nil; +} + +- (UIImage*)picturePlaceholder +{ + return [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"default-profile"]; +} + +- (void)setIsAutoAnimatedGif:(BOOL)isAutoAnimatedGif +{ + _isAutoAnimatedGif = isAutoAnimatedGif; + + [self renderGif]; +} + +- (void)setAllTextHighlighted:(BOOL)allTextHighlighted +{ + _allTextHighlighted = allTextHighlighted; + + if (self.messageTextView && bubbleData.textMessage.length != 0) + { + if (_allTextHighlighted) + { + NSMutableAttributedString *highlightedString = [[NSMutableAttributedString alloc] initWithAttributedString:bubbleData.attributedTextMessage]; + UIColor *color = self.tintColor ? self.tintColor : [UIColor lightGrayColor]; + [highlightedString addAttribute:NSBackgroundColorAttributeName value:color range:NSMakeRange(0, highlightedString.length)]; + self.messageTextView.attributedText = highlightedString; + } + else + { + self.messageTextView.attributedText = bubbleData.attributedTextMessage; + } + } +} + +- (void)highlightTextMessageForEvent:(NSString*)eventId +{ + if (self.messageTextView) + { + if (eventId.length) + { + self.messageTextView.attributedText = [bubbleData attributedTextMessageWithHighlightedEvent:eventId tintColor:self.tintColor]; + } + else + { + // Restore original string + self.messageTextView.attributedText = bubbleData.attributedTextMessage; + } + } +} + +- (CGFloat)topPositionOfEvent:(NSString*)eventId +{ + CGFloat topPositionOfEvent = 0; + + // Retrieve the component that hosts the event + MXKRoomBubbleComponent *theComponent; + for (MXKRoomBubbleComponent *component in bubbleData.bubbleComponents) + { + if ([component.event.eventId isEqualToString:eventId]) + { + theComponent = component; + break; + } + } + + if (theComponent) + { + topPositionOfEvent = theComponent.position.y + self.msgTextViewTopConstraint.constant; + } + return topPositionOfEvent; +} + +- (CGFloat)bottomPositionOfEvent:(NSString*)eventId +{ + CGFloat bottomPositionOfEvent = self.frame.size.height - self.msgTextViewBottomConstraint.constant; + + // Parse each component by the end of the array in order to compute the bottom position. + NSArray *bubbleComponents = bubbleData.bubbleComponents; + NSInteger index = bubbleComponents.count; + + while (index --) + { + MXKRoomBubbleComponent *component = bubbleComponents[index]; + if ([component.event.eventId isEqualToString:eventId]) + { + break; + } + else + { + // Update the bottom position + bottomPositionOfEvent = component.position.y + self.msgTextViewTopConstraint.constant; + } + } + return bottomPositionOfEvent; +} + +- (void)setSelected:(BOOL)selected animated:(BOOL)animated +{ + [super setSelected:selected animated:animated]; + + // Configure the view for the selected state +} + +- (void)render:(MXKCellData *)cellData +{ + [self prepareRender:cellData]; + + if (bubbleData) + { + // Check conditions to display the message sender name + if (self.userNameLabel) + { + // Display sender's name except if the name appears in the displayed text (see emote and membership events) + if (bubbleData.shouldHideSenderName == NO) + { + if (bubbleData.senderFlair) + { + [self renderSenderFlair]; + } + else + { + self.userNameLabel.text = bubbleData.senderDisplayName; + } + + + self.userNameLabel.hidden = NO; + self.userNameTapGestureMaskView.userInteractionEnabled = YES; + } + else + { + self.userNameLabel.hidden = YES; + self.userNameTapGestureMaskView.userInteractionEnabled = NO; + } + } + + // Check whether the sender's picture is actually displayed before loading it. + if (self.pictureView) + { + self.pictureView.enableInMemoryCache = YES; + // Consider here the sender avatar is stored unencrypted on Matrix media repo + [self.pictureView setImageURI:bubbleData.senderAvatarUrl + withType:nil + andImageOrientation:UIImageOrientationUp + toFitViewSize:self.pictureView.frame.size + withMethod:MXThumbnailingMethodCrop + previewImage:bubbleData.senderAvatarPlaceholder ? bubbleData.senderAvatarPlaceholder : self.picturePlaceholder + mediaManager:bubbleData.mxSession.mediaManager]; + } + + if (self.attachmentView && bubbleData.isAttachmentWithThumbnail) + { + // Set attached media folders + self.attachmentView.mediaFolder = bubbleData.roomId; + + self.attachmentView.backgroundColor = [UIColor clearColor]; + + // Retrieve the suitable content size for the attachment thumbnail + CGSize contentSize = bubbleData.contentSize; + + // Update image view frame in order to center loading wheel (if any) + CGRect frame = self.attachmentView.frame; + frame.size.width = contentSize.width; + frame.size.height = contentSize.height; + self.attachmentView.frame = frame; + + // Set play icon visibility + self.playIconView.hidden = (bubbleData.attachment.type != MXKAttachmentTypeVideo); + + // Hide by default file type icon + self.fileTypeIconView.hidden = YES; + + // Display the attachment thumbnail + [self.attachmentView setAttachmentThumb:bubbleData.attachment]; + + if (bubbleData.attachment.contentURL) + { + // Add tap recognizer to open attachment + UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onAttachmentTap:)]; + [tap setNumberOfTouchesRequired:1]; + [tap setNumberOfTapsRequired:1]; + [tap setDelegate:self]; + [self.attachmentView addGestureRecognizer:tap]; + } + + [self startProgressUI]; + + // Adjust Attachment width constant + self.attachViewWidthConstraint.constant = contentSize.width; + + // Add a long gesture recognizer on progressView to cancel the current operation (Note: only the download can be cancelled). + UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(onLongPressGesture:)]; + [self.progressView addGestureRecognizer:longPress]; + + if (_disableLongPressGestureOnEvent == NO) + { + // Add a long gesture recognizer on attachment view in order to display for example the event details + longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(onLongPressGesture:)]; + [self.attachmentView addGestureRecognizer:longPress]; + } + + // Handle here the case of the attached gif + [self renderGif]; + } + else if (self.messageTextView) + { + // Compute message content size + bubbleData.maxTextViewWidth = self.frame.size.width - (self.msgTextViewLeadingConstraint.constant + self.msgTextViewTrailingConstraint.constant); + CGSize contentSize = bubbleData.contentSize; + + // Prepare displayed text message + NSAttributedString* newText = nil; + + // Underline attached file name + if (self.isBubbleDataContainsFileAttachment) + { + NSMutableAttributedString *updatedText = [[NSMutableAttributedString alloc] initWithAttributedString:bubbleData.attributedTextMessage]; + [updatedText addAttribute:NSUnderlineStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:NSMakeRange(0, updatedText.length)]; + + newText = updatedText; + } + else + { + newText = bubbleData.attributedTextMessage; + } + + // update the text only if it is required + // updating a text is quite long (even with the same text). + if (![self.messageTextView.attributedText isEqualToAttributedString:newText]) + { + self.messageTextView.attributedText = newText; + + if (bubbleData.displayFix & MXKRoomBubbleComponentDisplayFixHtmlBlockquote) + { + [self fixHTMLBlockQuoteRendering:YES]; + } + } + + // Update msgTextView width constraint to align correctly the text + if (self.msgTextViewWidthConstraint.constant != contentSize.width) + { + self.msgTextViewWidthConstraint.constant = contentSize.width; + } + } + + // Check and update each component position (used to align timestamps label in front of events, and to handle tap gesture on events) + [bubbleData prepareBubbleComponentsPosition]; + + // Handle here timestamp display (only if a container has been defined) + if (self.bubbleInfoContainer) + { + if ((bubbleData.showBubbleDateTime && !bubbleData.useCustomDateTimeLabel) + || (bubbleData.showBubbleReceipts && !bubbleData.useCustomReceipts)) + { + // Add datetime label for each component + self.bubbleInfoContainer.hidden = NO; + + // ensure that older subviews are removed + // They should be (they are removed when the is not anymore used). + // But, it seems that is not always true. + NSArray* views = [self.bubbleInfoContainer subviews]; + for(UIView* view in views) + { + [view removeFromSuperview]; + } + + for (MXKRoomBubbleComponent *component in bubbleData.bubbleComponents) + { + if (component.event.sentState != MXEventSentStateFailed) + { + CGFloat timeLabelOffset = 0; + + if (component.date && bubbleData.showBubbleDateTime && !bubbleData.useCustomDateTimeLabel) + { + UILabel *dateTimeLabel = [[UILabel alloc] initWithFrame:CGRectMake(0, component.position.y, self.bubbleInfoContainer.frame.size.width , 15)]; + + dateTimeLabel.text = [bubbleData.eventFormatter dateStringFromDate:component.date withTime:YES]; + if (bubbleData.isIncoming) + { + dateTimeLabel.textAlignment = NSTextAlignmentRight; + } + else + { + dateTimeLabel.textAlignment = NSTextAlignmentLeft; + } + dateTimeLabel.textColor = [UIColor lightGrayColor]; + dateTimeLabel.font = [UIFont systemFontOfSize:11]; + dateTimeLabel.adjustsFontSizeToFitWidth = YES; + dateTimeLabel.minimumScaleFactor = 0.6; + + [dateTimeLabel setTranslatesAutoresizingMaskIntoConstraints:NO]; + [self.bubbleInfoContainer addSubview:dateTimeLabel]; + // Force dateTimeLabel in full width (to handle auto-layout in case of screen rotation) + NSLayoutConstraint *leftConstraint = [NSLayoutConstraint constraintWithItem:dateTimeLabel + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:self.bubbleInfoContainer + attribute:NSLayoutAttributeLeading + multiplier:1.0 + constant:0]; + NSLayoutConstraint *rightConstraint = [NSLayoutConstraint constraintWithItem:dateTimeLabel + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:self.bubbleInfoContainer + attribute:NSLayoutAttributeTrailing + multiplier:1.0 + constant:0]; + // Vertical constraints are required for iOS > 8 + NSLayoutConstraint *topConstraint = [NSLayoutConstraint constraintWithItem:dateTimeLabel + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self.bubbleInfoContainer + attribute:NSLayoutAttributeTop + multiplier:1.0 + constant:component.position.y]; + NSLayoutConstraint *heightConstraint = [NSLayoutConstraint constraintWithItem:dateTimeLabel + attribute:NSLayoutAttributeHeight + relatedBy:NSLayoutRelationEqual + toItem:nil + attribute:NSLayoutAttributeNotAnAttribute + multiplier:1.0 + constant:15]; + [NSLayoutConstraint activateConstraints:@[leftConstraint, rightConstraint, topConstraint, heightConstraint]]; + + timeLabelOffset += 15; + } + + if (bubbleData.showBubbleReceipts && !bubbleData.useCustomReceipts) + { + NSMutableArray* roomMembers = nil; + NSMutableArray* placeholders = nil; + NSArray *receipts = bubbleData.readReceipts[component.event.eventId]; + + // Check whether some receipts are found + if (receipts.count) + { + MXRoom* room = [bubbleData.mxSession roomWithRoomId:bubbleData.roomId]; + if (room) + { + // Retrieve the corresponding room members + roomMembers = [[NSMutableArray alloc] initWithCapacity:receipts.count]; + placeholders = [[NSMutableArray alloc] initWithCapacity:receipts.count]; + + MXRoomMembers *stateRoomMembers = room.dangerousSyncState.members; + for (MXReceiptData* data in receipts) + { + MXRoomMember * roomMember = [stateRoomMembers memberWithUserId:data.userId]; + if (roomMember) + { + [roomMembers addObject:roomMember]; + [placeholders addObject:self.picturePlaceholder]; + } + } + } + } + + if (roomMembers.count) + { + MXKReceiptSendersContainer* avatarsContainer = [[MXKReceiptSendersContainer alloc] initWithFrame:CGRectMake(0, component.position.y + timeLabelOffset, self.bubbleInfoContainer.frame.size.width , 15) andMediaManager:bubbleData.mxSession.mediaManager]; + + [avatarsContainer refreshReceiptSenders:roomMembers withPlaceHolders:placeholders andAlignment:self.readReceiptsAlignment]; + + [self.bubbleInfoContainer addSubview:avatarsContainer]; + + // Force dateTimeLabel in full width (to handle auto-layout in case of screen rotation) + NSLayoutConstraint *leftConstraint = [NSLayoutConstraint constraintWithItem:avatarsContainer + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:self.bubbleInfoContainer + attribute:NSLayoutAttributeLeading + multiplier:1.0 + constant:0]; + NSLayoutConstraint *rightConstraint = [NSLayoutConstraint constraintWithItem:avatarsContainer + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:self.bubbleInfoContainer + attribute:NSLayoutAttributeTrailing + multiplier:1.0 + constant:0]; + // Vertical constraints are required for iOS > 8 + NSLayoutConstraint *topConstraint = [NSLayoutConstraint constraintWithItem:avatarsContainer + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self.bubbleInfoContainer + attribute:NSLayoutAttributeTop + multiplier:1.0 + constant:(component.position.y + timeLabelOffset)]; + + NSLayoutConstraint *heightConstraint = [NSLayoutConstraint constraintWithItem:avatarsContainer + attribute:NSLayoutAttributeHeight + relatedBy:NSLayoutRelationEqual + toItem:nil + attribute:NSLayoutAttributeNotAnAttribute + multiplier:1.0 + constant:15]; + + [NSLayoutConstraint activateConstraints:@[leftConstraint, rightConstraint, topConstraint, heightConstraint]]; + } + } + } + } + } + else + { + self.bubbleInfoContainer.hidden = YES; + } + } + } +} + +- (void)prepareRender:(MXKCellData *)cellData +{ + // Sanity check: accept only object of MXKRoomBubbleCellData classes or sub-classes + NSParameterAssert([cellData isKindOfClass:[MXKRoomBubbleCellData class]]); + + bubbleData = (MXKRoomBubbleCellData*)cellData; + mxkCellData = cellData; +} + +- (void)renderSenderFlair +{ + NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:[NSString stringWithFormat:@"%@ ", bubbleData.senderDisplayName]]; + + NSUInteger index = 0; + + for (MXGroup *group in bubbleData.senderFlair) + { + NSString *mxcAvatarURI = group.profile.avatarUrl; + NSString *cacheFilePath = [MXMediaManager thumbnailCachePathForMatrixContentURI:mxcAvatarURI andType:@"image/jpeg" inFolder:kMXMediaManagerDefaultCacheFolder toFitViewSize:CGSizeMake(12, 12) withMethod:MXThumbnailingMethodCrop]; + + // Check whether the avatar url is valid + if (cacheFilePath) + { + UIImage *image = [MXMediaManager loadThroughCacheWithFilePath:cacheFilePath]; + if (image) + { + NSTextAttachment *textAttachment = [[NSTextAttachment alloc] init]; + textAttachment.image = [MXKTools resizeImageWithRoundedCorners:image toSize:CGSizeMake(12, 12)]; + NSAttributedString *attrStringWithImage = [NSAttributedString attributedStringWithAttachment:textAttachment]; + [attributedString appendAttributedString:attrStringWithImage]; + [attributedString appendAttributedString:[[NSAttributedString alloc] initWithString:@" "]]; + } + else + { + NSString *downloadId = [MXMediaManager thumbnailDownloadIdForMatrixContentURI:mxcAvatarURI + inFolder:kMXMediaManagerDefaultCacheFolder + toFitViewSize:CGSizeMake(12, 12) + withMethod:MXThumbnailingMethodCrop]; + // Check whether the download is in progress. + MXMediaLoader* loader = [MXMediaManager existingDownloaderWithIdentifier:downloadId]; + if (loader) + { + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:loader]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onFlairDownloadStateChange:) name:kMXMediaLoaderStateDidChangeNotification object:loader]; + } + else + { + MXWeakify(self); + [bubbleData.mxSession.mediaManager downloadThumbnailFromMatrixContentURI:mxcAvatarURI + withType:@"image/jpeg" + inFolder:kMXMediaManagerDefaultCacheFolder + toFitViewSize:CGSizeMake(12, 12) + withMethod:MXThumbnailingMethodCrop + success:^(NSString *outputFilePath) { + // Refresh sender flair + MXStrongifyAndReturnIfNil(self); + [self renderSenderFlair]; + } + failure:nil]; + } + } + + index++; + if (index == 3) + { + if (bubbleData.senderFlair.count > 3) + { + NSAttributedString *more = [[NSAttributedString alloc] initWithString:[NSString stringWithFormat:@"+%tu", (bubbleData.senderFlair.count - 3)] attributes:@{NSFontAttributeName: [UIFont systemFontOfSize:11.0], NSBaselineOffsetAttributeName:@(+2)}]; + [attributedString appendAttributedString:more]; + } + break; + } + } + } + + self.userNameLabel.attributedText = attributedString; +} + +- (void)renderGif +{ + if (self.attachmentView && bubbleData.attachment) + { + NSString *mimetype = nil; + if (bubbleData.attachment.thumbnailInfo) + { + mimetype = bubbleData.attachment.thumbnailInfo[@"mimetype"]; + } + else if (bubbleData.attachment.contentInfo) + { + mimetype = bubbleData.attachment.contentInfo[@"mimetype"]; + } + + if ([mimetype isEqualToString:@"image/gif"]) + { + if (_isAutoAnimatedGif) + { + // Hide the file type icon, and the progress UI + self.fileTypeIconView.hidden = YES; + [self stopProgressUI]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:nil]; + + // Animated gif is displayed in a webview added on the attachment view + self.attachmentWebView = [[WKWebView alloc] initWithFrame:self.attachmentView.bounds]; + self.attachmentWebView.opaque = NO; + self.attachmentWebView.backgroundColor = [UIColor clearColor]; + self.attachmentWebView.contentMode = UIViewContentModeScaleAspectFit; + self.attachmentWebView.autoresizingMask = (UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin); + self.attachmentWebView.userInteractionEnabled = NO; + self.attachmentWebView.hidden = YES; + [self.attachmentView addSubview:self.attachmentWebView]; + + __weak WKWebView *weakAnimatedGifViewer = self.attachmentWebView; + __weak typeof(self) weakSelf = self; + + void (^onDownloaded)(NSData *) = ^(NSData *data){ + + if (weakAnimatedGifViewer && weakAnimatedGifViewer.superview) + { + WKWebView *strongAnimatedGifViewer = weakAnimatedGifViewer; + strongAnimatedGifViewer.navigationDelegate = weakSelf; + [strongAnimatedGifViewer loadData:data MIMEType:@"image/gif" characterEncodingName:@"UTF-8" baseURL:[NSURL URLWithString:@"http://"]]; + } + }; + + void (^onFailure)(NSError *) = ^(NSError *error){ + + MXLogDebug(@"[MXKRoomBubbleTableViewCell] gif download failed"); + // Notify the end user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + }; + + [bubbleData.attachment getAttachmentData:^(NSData *data) { + onDownloaded(data); + } failure:^(NSError *error) { + onFailure(error); + }]; + } + else + { + self.fileTypeIconView.image = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"filetype-gif"]; + self.fileTypeIconView.hidden = NO; + + // Check whether a download is in progress + [self startProgressUI]; + } + } + } +} + ++ (CGFloat)heightForCellData:(MXKCellData*)cellData withMaximumWidth:(CGFloat)maxWidth +{ + // Sanity check: accept only object of MXKRoomBubbleCellData classes or sub-classes + NSParameterAssert([cellData isKindOfClass:[MXKRoomBubbleCellData class]]); + + MXKRoomBubbleCellData *bubbleData = (MXKRoomBubbleCellData*)cellData; + MXKRoomBubbleTableViewCell* cell = [self cellWithOriginalXib]; + CGFloat rowHeight = cell.frame.size.height; + + if (cell.attachmentView && bubbleData.isAttachmentWithThumbnail) + { + // retrieve the suggested image view height + rowHeight = bubbleData.contentSize.height; + + // Check here the minimum height defined in cell view for text message + if (cell.attachViewMinHeightConstraint && rowHeight < cell.attachViewMinHeightConstraint.constant) + { + rowHeight = cell.attachViewMinHeightConstraint.constant; + } + + // Finalize the row height by adding the vertical constraints. + rowHeight += cell.attachViewTopConstraint.constant + cell.attachViewBottomConstraint.constant; + } + else if (cell.messageTextView) + { + // Update maximum width available for the textview + bubbleData.maxTextViewWidth = maxWidth - (cell.msgTextViewLeadingConstraint.constant + cell.msgTextViewTrailingConstraint.constant); + + // Retrieve the suggested height of the message content + rowHeight = bubbleData.contentSize.height; + + // Consider here the minimum height defined in cell view for text message + if (cell.msgTextViewMinHeightConstraint && rowHeight < cell.msgTextViewMinHeightConstraint.constant) + { + rowHeight = cell.msgTextViewMinHeightConstraint.constant; + } + + // Finalize the row height by adding the top and bottom constraints of the message text view in cell + rowHeight += cell.msgTextViewTopConstraint.constant + cell.msgTextViewBottomConstraint.constant; + } + + return rowHeight; +} + +- (void)prepareForReuse +{ + [super prepareForReuse]; + + [self didEndDisplay]; +} + +- (void)didEndDisplay +{ + bubbleData = nil; + + for (UIView *sideBorder in htmlBlockquoteSideBorderViews) + { + [sideBorder removeFromSuperview]; + } + [htmlBlockquoteSideBorderViews removeAllObjects]; + htmlBlockquoteSideBorderViews = nil; + + if (_attachmentWebView) + { + [_attachmentWebView removeFromSuperview]; + _attachmentWebView.navigationDelegate = nil; + _attachmentWebView = nil; + } + + if (_readMarkerView) + { + [_readMarkerView removeFromSuperview]; + _readMarkerView = nil; + _readMarkerViewTopConstraint = nil; + _readMarkerViewLeadingConstraint = nil; + _readMarkerViewTrailingConstraint = nil; + _readMarkerViewHeightConstraint = nil; + } + + if (self.attachmentView) + { + // Remove all gesture recognizer + while (self.attachmentView.gestureRecognizers.count) + { + [self.attachmentView removeGestureRecognizer:self.attachmentView.gestureRecognizers[0]]; + } + + // Prevent the cell from displaying again the image in case of reuse. + self.attachmentView.image = nil; + } + + // Remove potential dateTime (or unsent) label(s) + if (self.bubbleInfoContainer && self.bubbleInfoContainer.subviews.count > 0) + { + NSArray* subviews = self.bubbleInfoContainer.subviews; + + for (UIView *view in subviews) + { + [view removeFromSuperview]; + } + } + self.bubbleInfoContainer.hidden = YES; + + // Remove temporary subviews + if (self.tmpSubviews) + { + for (UIView *view in self.tmpSubviews) + { + [view removeFromSuperview]; + } + self.tmpSubviews = nil; + } + + // Remove potential overlay subviews + if (self.bubbleOverlayContainer) + { + NSArray* subviews = self.bubbleOverlayContainer.subviews; + + for (UIView *view in subviews) + { + [view removeFromSuperview]; + } + + self.bubbleOverlayContainer.hidden = YES; + } + + if (self.progressView) + { + [self stopProgressUI]; + + // Remove long tap gesture on the progressView + while (self.progressView.gestureRecognizers.count) + { + [self.progressView removeGestureRecognizer:self.progressView.gestureRecognizers[0]]; + } + } + + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + delegate = nil; + + self.readReceiptsAlignment = ReadReceiptAlignmentLeft; + _allTextHighlighted = NO; + _isAutoAnimatedGif = NO; + + [self resetConstraintsConstantToDefault]; +} + +- (BOOL)shouldInteractWithURL:(NSURL *)URL urlItemInteraction:(UITextItemInteraction)urlItemInteraction associatedEvent:(MXEvent*)associatedEvent +{ + return [self shouldInteractWithURL:URL urlItemInteractionValue:@(urlItemInteraction) associatedEvent:associatedEvent]; +} + +- (BOOL)shouldInteractWithURL:(NSURL *)URL urlItemInteractionValue:(NSNumber*)urlItemInteractionValue associatedEvent:(MXEvent*)associatedEvent +{ + NSMutableDictionary *userInfo = [@{ + kMXKRoomBubbleCellUrl:URL, + kMXKRoomBubbleCellUrlItemInteraction:urlItemInteractionValue + } mutableCopy]; + + if (associatedEvent) + { + userInfo[kMXKRoomBubbleCellEventKey] = associatedEvent; + } + + return [delegate cell:self shouldDoAction:kMXKRoomBubbleCellShouldInteractWithURL userInfo:userInfo defaultValue:YES]; +} + +- (BOOL)isBubbleDataContainsFileAttachment +{ + return bubbleData.attachment + && (bubbleData.attachment.type == MXKAttachmentTypeFile || bubbleData.attachment.type == MXKAttachmentTypeAudio || bubbleData.attachment.type == MXKAttachmentTypeVoiceMessage) + && bubbleData.attachment.contentURL + && bubbleData.attachment.contentInfo; +} + +- (MXKRoomBubbleComponent*)closestBubbleComponentForGestureRecognizer:(UIGestureRecognizer*)gestureRecognizer locationInView:(UIView*)view +{ + CGPoint tapPoint = [gestureRecognizer locationInView:view]; + MXKRoomBubbleComponent *tappedComponent; + + if (tapPoint.y >= 0 && tapPoint.y <= view.frame.size.height) + { + tappedComponent = [self closestBubbleComponentAtPosition:tapPoint]; + } + + return tappedComponent; +} + +- (MXKRoomBubbleComponent*)closestBubbleComponentAtPosition:(CGPoint)position +{ + MXKRoomBubbleComponent *tappedComponent; + + NSArray *bubbleComponents = bubbleData.bubbleComponents; + + for (MXKRoomBubbleComponent *component in bubbleComponents) + { + // Ignore components without display (For example redacted event or state events) + if (!component.attributedTextMessage) + { + continue; + } + + if (component.position.y > position.y) + { + break; + } + + tappedComponent = component; + } + + return tappedComponent; +} + +- (void)setupConstraintsConstantDefaultValues +{ + self.attachmentViewBottomConstraintDefaultConstant = self.attachViewBottomConstraint.constant; +} + +- (void)resetAttachmentViewBottomConstraintConstant +{ + self.attachViewBottomConstraint.constant = self.attachmentViewBottomConstraintDefaultConstant; +} + +- (void)resetConstraintsConstantToDefault +{ + [self resetAttachmentViewBottomConstraintConstant]; +} + +#pragma mark - Attachment progress handling + +- (void)updateProgressUI:(NSDictionary*)statisticsDict +{ + self.progressView.hidden = !statisticsDict; + + NSNumber* downloadRate = [statisticsDict valueForKey:kMXMediaLoaderCurrentDataRateKey]; + + NSNumber* completedBytesCount = [statisticsDict valueForKey:kMXMediaLoaderCompletedBytesCountKey]; + NSNumber* totalBytesCount = [statisticsDict valueForKey:kMXMediaLoaderTotalBytesCountKey]; + + NSMutableString* text = [[NSMutableString alloc] init]; + + if (completedBytesCount && totalBytesCount) + { + NSString* progressString = [NSString stringWithFormat:@"%@ / %@", [NSByteCountFormatter stringFromByteCount:completedBytesCount.longLongValue countStyle:NSByteCountFormatterCountStyleFile], [NSByteCountFormatter stringFromByteCount:totalBytesCount.longLongValue countStyle:NSByteCountFormatterCountStyleFile]]; + + [text appendString:progressString]; + } + + if (downloadRate && downloadRate.longLongValue) + { + [text appendFormat:@"\n%@/s", [NSByteCountFormatter stringFromByteCount:downloadRate.longLongValue countStyle:NSByteCountFormatterCountStyleFile]]; + + if (completedBytesCount && totalBytesCount) + { + CGFloat remainimgTime = ((totalBytesCount.floatValue - completedBytesCount.floatValue)) / downloadRate.floatValue; + [text appendFormat:@"\n%@", [MXKTools formatSecondsInterval:remainimgTime]]; + } + } + + self.statsLabel.text = text; + + NSNumber* progressNumber = [statisticsDict valueForKey:kMXMediaLoaderProgressValueKey]; + + if (progressNumber) + { + self.progressChartView.progress = progressNumber.floatValue; + } +} + +- (void)onAttachmentLoaderStateChange:(NSNotification *)notif +{ + MXMediaLoader *loader = (MXMediaLoader*)notif.object; + switch (loader.state) { + case MXMediaLoaderStateDownloadInProgress: + [self updateProgressUI:loader.statisticsDict]; + break; + case MXMediaLoaderStateDownloadCompleted: + case MXMediaLoaderStateDownloadFailed: + case MXMediaLoaderStateCancelled: + [self stopProgressUI]; + // remove the observer + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:loader]; + break; + default: + break; + } +} + +- (void)onFlairDownloadStateChange:(NSNotification *)notif +{ + MXMediaLoader *loader = (MXMediaLoader*)notif.object; + switch (loader.state) { + case MXMediaLoaderStateDownloadCompleted: + [self renderSenderFlair]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:loader]; + break; + case MXMediaLoaderStateDownloadFailed: + case MXMediaLoaderStateCancelled: + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:loader]; + break; + default: + break; + } +} + +- (void)startProgressUI +{ + self.progressView.hidden = YES; + + // there is an attachment URL + if (bubbleData.attachment.contentURL) + { + // remove any pending observers + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:nil]; + + // check if there is a download in progress + MXMediaLoader *loader = [MXMediaManager existingDownloaderWithIdentifier:bubbleData.attachment.downloadId]; + if (loader) + { + // defines the text to display + [self updateProgressUI:loader.statisticsDict]; + + // anyway listen to the progress event + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(onAttachmentLoaderStateChange:) + name:kMXMediaLoaderStateDidChangeNotification + object:loader]; + } + } +} + +- (void)stopProgressUI +{ + self.progressView.hidden = YES; + + // do not remove the observer here + // the download could restart without recomposing the cell +} + +#pragma mark - Original Xib values + +/** + `childClasses` hosts one instance of each child classes of `MXKRoomBubbleTableViewCell`. + The key is the child class name. The value, the instance. + */ +static NSMutableDictionary *childClasses; + ++ (MXKRoomBubbleTableViewCell*)cellWithOriginalXib +{ + MXKRoomBubbleTableViewCell *cellWithOriginalXib; + + @synchronized(self) + { + if (childClasses == nil) + { + childClasses = [NSMutableDictionary dictionary]; + } + + // To save memory, use only one original instance per child class + cellWithOriginalXib = childClasses[NSStringFromClass(self.class)]; + if (nil == cellWithOriginalXib) + { + cellWithOriginalXib = [self roomBubbleTableViewCell]; + + childClasses[NSStringFromClass(self.class)] = cellWithOriginalXib; + } + } + return cellWithOriginalXib; +} + +#pragma mark - User actions + +- (IBAction)onMessageTap:(UITapGestureRecognizer*)sender +{ + if (delegate) + { + // Check whether the current displayed text corresponds to an attached file + // NOTE: This assumes that a cell with attachment has only one `MXKRoomBubbleComponent` + if (self.isBubbleDataContainsFileAttachment) + { + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnAttachmentView userInfo:nil]; + } + else + { + NSURL *tappedUrl; + + // Hyperlinks in UITextView does not respond instantly to touch. + // To overcome this, check manually if a link has been touched in UITextView when performing a quick tap. + // Otherwise UITextViewDelegate method `- (BOOL)textView:shouldInteractWithURL:inRange:interaction:` is still called for long press and force touch. + if ([sender.view isEqual:self.messageTextView]) + { + UITextView *textView = self.messageTextView; + CGPoint tapLocation = [sender locationInView:textView]; + UITextPosition *textPosition = [textView closestPositionToPoint:tapLocation]; + NSDictionary *attributes = [textView textStylingAtPosition:textPosition inDirection:UITextStorageDirectionForward]; + + // The value of `NSLinkAttributeName` attribute could be an NSURL or an NSString object. + id tappedURLObject = attributes[NSLinkAttributeName]; + + if (tappedURLObject) + { + if ([tappedURLObject isKindOfClass:[NSURL class]]) + { + tappedUrl = (NSURL*)tappedURLObject; + } + else if ([tappedURLObject isKindOfClass:[NSString class]]) + { + tappedUrl = [NSURL URLWithString:(NSString*)tappedURLObject]; + } + } + } + + MXKRoomBubbleComponent *tappedComponent = [self closestBubbleComponentForGestureRecognizer:sender locationInView:sender.view]; + MXEvent *tappedEvent = tappedComponent.event; + + // If a link has been touched warn delegate immediately. + if (tappedUrl) + { + [self shouldInteractWithURL:tappedUrl urlItemInteraction:UITextItemInteractionInvokeDefaultAction associatedEvent:tappedEvent]; + } + else + { + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnMessageTextView userInfo:(tappedEvent ? @{kMXKRoomBubbleCellEventKey:tappedEvent} : nil)]; + } + } + } +} + +- (IBAction)onSenderNameTap:(UITapGestureRecognizer*)sender +{ + if (delegate) + { + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnSenderNameLabel userInfo:@{kMXKRoomBubbleCellUserIdKey: bubbleData.senderId}]; + } +} + +- (IBAction)onAvatarTap:(UITapGestureRecognizer*)sender +{ + if (delegate) + { + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnAvatarView userInfo:@{kMXKRoomBubbleCellUserIdKey: bubbleData.senderId}]; + } +} + +- (IBAction)onAttachmentTap:(UITapGestureRecognizer*)sender +{ + if (delegate) + { + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnAttachmentView userInfo:nil]; + } +} + +- (IBAction)showHideDateTime:(id)sender +{ + if (delegate) + { + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnDateTimeContainer userInfo:nil]; + } +} + +- (IBAction)onOverlayTap:(UITapGestureRecognizer*)sender +{ + if (delegate) + { + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnOverlayContainer userInfo:nil]; + } +} + +- (IBAction)onContentViewTap:(UITapGestureRecognizer*)sender +{ + if (delegate) + { + // Check whether a bubble component is displayed at the level of the tapped line. + MXKRoomBubbleComponent *tappedComponent = nil; + + if (self.attachmentView) + { + // Check whether the user tapped on the side of the attachment. + tappedComponent = [self closestBubbleComponentForGestureRecognizer:sender locationInView:self.attachmentView]; + } + else if (self.messageTextView) + { + // NOTE: A tap on messageTextView using `MXKMessageTextView` class fallback here if the user does not tap on a link. + + // Use the same hack as `onMessageTap:`, check whether the current displayed text corresponds to an attached file + // NOTE: This assumes that a cell with attachment has only one `MXKRoomBubbleComponent` + if (self.isBubbleDataContainsFileAttachment) + { + // This assume that an attachment use one cell in the application using MatrixKit + // This condition is a fix to handle + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnAttachmentView userInfo:nil]; + } + else + { + // Check whether the user tapped in front of a text component. + tappedComponent = [self closestBubbleComponentForGestureRecognizer:sender locationInView:self.messageTextView]; + } + } + else + { + tappedComponent = [self.bubbleData getFirstBubbleComponentWithDisplay]; + } + + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnContentView userInfo:(tappedComponent ? @{kMXKRoomBubbleCellEventKey:tappedComponent.event} : nil)]; + } +} + +- (IBAction)onLongPressGesture:(UILongPressGestureRecognizer*)longPressGestureRecognizer +{ + if (longPressGestureRecognizer.state == UIGestureRecognizerStateBegan && delegate) + { + UIView* view = longPressGestureRecognizer.view; + + // Check the view on which long press has been detected + if (view == self.progressView) + { + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellLongPressOnProgressView userInfo:nil]; + } + else if (view == self.messageTextView || view == self.messageTextBackgroundView || view == self.attachmentView) + { + MXKRoomBubbleComponent *tappedComponent = [self closestBubbleComponentForGestureRecognizer:longPressGestureRecognizer locationInView:view]; + MXEvent *selectedEvent = tappedComponent.event; + + if (selectedEvent) + { + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellLongPressOnEvent userInfo:@{kMXKRoomBubbleCellEventKey:selectedEvent}]; + } + } + else if (view == self.pictureView) + { + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellLongPressOnAvatarView userInfo:@{kMXKRoomBubbleCellUserIdKey: bubbleData.senderId}]; + } + else if (view == self.contentView) + { + // Check whether a bubble component is displayed at the level of the tapped line. + MXKRoomBubbleComponent *tappedComponent = nil; + + if (self.attachmentView) + { + // Check whether the user tapped on the side of the attachment. + tappedComponent = [self closestBubbleComponentForGestureRecognizer:longPressGestureRecognizer locationInView:self.attachmentView]; + } + else if (self.messageTextView) + { + // Check whether the user tapped in front of a text component. + tappedComponent = [self closestBubbleComponentForGestureRecognizer:longPressGestureRecognizer locationInView:self.messageTextView]; + } + else + { + tappedComponent = [self.bubbleData getFirstBubbleComponentWithDisplay]; + } + + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellLongPressOnEvent userInfo:(tappedComponent ? @{kMXKRoomBubbleCellEventKey:tappedComponent.event} : nil)]; + } + } +} + +#pragma mark - UITextView delegate + +- (BOOL)textView:(UITextView *)textView shouldInteractWithURL:(NSURL *)URL inRange:(NSRange)characterRange interaction:(UITextItemInteraction)interaction +{ + BOOL shouldInteractWithURL = YES; + + if (delegate && URL) + { + MXEvent *associatedEvent; + + if ([textView isMemberOfClass:[MXKMessageTextView class]]) + { + MXKMessageTextView *mxkMessageTextView = (MXKMessageTextView *)textView; + MXKRoomBubbleComponent *bubbleComponent = [self closestBubbleComponentAtPosition:mxkMessageTextView.lastHitTestLocation]; + associatedEvent = bubbleComponent.event; + } + + // Ask the delegate if iOS can open the link + shouldInteractWithURL = [self shouldInteractWithURL:URL urlItemInteraction:interaction associatedEvent:associatedEvent]; + } + + return shouldInteractWithURL; +} + +// Delegate method only called on iOS 9. iOS 10+ use method above. +- (BOOL)textView:(UITextView *)textView shouldInteractWithURL:(NSURL *)URL inRange:(NSRange)characterRange +{ + BOOL shouldInteractWithURL = YES; + + if (delegate && URL) + { + MXEvent *associatedEvent; + + if ([textView isMemberOfClass:[MXKMessageTextView class]]) + { + MXKMessageTextView *mxkMessageTextView = (MXKMessageTextView *)textView; + MXKRoomBubbleComponent *bubbleComponent = [self closestBubbleComponentAtPosition:mxkMessageTextView.lastHitTestLocation]; + associatedEvent = bubbleComponent.event; + } + + // Ask the delegate if iOS can open the link + shouldInteractWithURL = [self shouldInteractWithURL:URL urlItemInteractionValue:@(0) associatedEvent:associatedEvent]; + } + + return shouldInteractWithURL; +} + +#pragma mark - WKNavigationDelegate + +- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation +{ + if (webView == _attachmentWebView && self.attachmentView) + { + // The attachment webview is ready to replace the attachment view. + _attachmentWebView.hidden = NO; + self.attachmentView.image = nil; + } +} + +#pragma mark - UIGestureRecognizerDelegate + +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch +{ + UIView *recognizerView = gestureRecognizer.view; + + if ([recognizerView isDescendantOfView:self.contentView]) + { + UIView *touchedView = touch.view; + + if ([touchedView isKindOfClass:[UIButton class]]) + { + return NO; + } + + // Prevent gesture recognizer to be recognized by a custom view added to the cell contentView and with user interaction enabled + for (UIView *tmpSubview in self.tmpSubviews) + { + if (tmpSubview.isUserInteractionEnabled && [tmpSubview isDescendantOfView:self.contentView]) + { + CGPoint touchedPoint = [touch locationInView:tmpSubview]; + + if (CGRectContainsPoint(tmpSubview.bounds, touchedPoint)) + { + return NO; + } + } + } + + // Prevent gesture recognizer to be recognized when user hits a link in a UITextView, let UITextViewDelegate handle links. + if ([touchedView isKindOfClass:[UITextView class]]) + { + UITextView *textView = (UITextView*)touchedView; + CGPoint touchLocation = [touch locationInView:textView]; + + return [textView isThereALinkNearPoint:touchLocation] == NO; + } + } + + return YES; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.h new file mode 100644 index 000000000..72a541cba --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.h @@ -0,0 +1,25 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomBubbleTableViewCell.h" + +/** + `MXKRoomEmptyBubbleTableViewCell` displays an empty bubbles without user's information. + This kind of bubble may be used to localize an event without display in the room history. + */ +@interface MXKRoomEmptyBubbleTableViewCell : MXKRoomBubbleTableViewCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.m new file mode 100644 index 000000000..54ce13bc6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.m @@ -0,0 +1,21 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomEmptyBubbleTableViewCell.h" + +@implementation MXKRoomEmptyBubbleTableViewCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.xib b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.xib new file mode 100644 index 000000000..bfcfca2e5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.xib @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSBubbleTableViewCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSBubbleTableViewCell.h new file mode 100644 index 000000000..6f558f091 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSBubbleTableViewCell.h @@ -0,0 +1,26 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +#import "MXKCellRendering.h" + +/** + `MXKRoomIOSBubbleTableViewCell` instances mimic bubbles in the stock iOS messages application. + */ +@interface MXKRoomIOSBubbleTableViewCell : MXKTableViewCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSBubbleTableViewCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSBubbleTableViewCell.m new file mode 100644 index 000000000..7766a3294 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSBubbleTableViewCell.m @@ -0,0 +1,45 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIOSBubbleTableViewCell.h" + +#import "MXKRoomBubbleCellDataStoring.h" + +@implementation MXKRoomIOSBubbleTableViewCell + +- (void)render:(MXKCellData *)cellData +{ + id bubbleData = (id)cellData; + if (bubbleData) + { + self.textLabel.attributedText = bubbleData.attributedTextMessage; + } + else + { + self.textLabel.text = @""; + } + + // Light custo for now... @TODO + self.layer.cornerRadius = 20; + self.backgroundColor = [UIColor blueColor]; +} + ++ (CGFloat)heightForCellData:(MXKCellData *)cellData withMaximumWidth:(CGFloat)maxWidth +{ + return 44; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.h new file mode 100644 index 000000000..49d42ba45 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.h @@ -0,0 +1,36 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomOutgoingBubbleTableViewCell.h" + +/** + `MXKRoomIOSBubbleTableViewCell` instances mimic bubbles in the stock iOS messages application. + It is dedicated to outgoing messages. + It subclasses `MXKRoomOutgoingBubbleTableViewCell` to take benefit of the available mechanic. + */ +@interface MXKRoomIOSOutgoingBubbleTableViewCell : MXKRoomOutgoingBubbleTableViewCell + +/** + The green bubble displayed in background. + */ +@property (weak, nonatomic) IBOutlet UIImageView *bubbleImageView; + +/** + The width constraint on this backgroung green bubble. + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *bubbleImageViewWidthConstraint; + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.m new file mode 100644 index 000000000..cb45c7426 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.m @@ -0,0 +1,130 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIOSOutgoingBubbleTableViewCell.h" + +#import "MXKRoomBubbleCellData.h" + +#import "MXEvent+MatrixKit.h" +#import "MXKTools.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKImageView.h" + +#define OUTGOING_BUBBLE_COLOR 0x00e34d + +@implementation MXKRoomIOSOutgoingBubbleTableViewCell + +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier +{ + self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; + if (self) + { + // Create the strechable background bubble + self.bubbleImageView.image = self.class.bubbleImage; + } + + return self; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; +} + +- (void)render:(MXKCellData *)cellData +{ + [super render:cellData]; + + // Reset values + self.bubbleImageView.hidden = NO; + + // Customise the data precomputed by the legacy classes + // Replace black color in texts by the white color expected for outgoing messages. + NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithAttributedString:self.messageTextView.attributedText]; + + // Change all attributes one by one + [attributedString enumerateAttributesInRange:NSMakeRange(0, attributedString.length) options:0 usingBlock:^(NSDictionary *attrs, NSRange range, BOOL *stop) + { + + // Replace only black colored texts + if (attrs[NSForegroundColorAttributeName] == self->bubbleData.eventFormatter.defaultTextColor) + { + + // By white + NSMutableDictionary *newAttrs = [NSMutableDictionary dictionaryWithDictionary:attrs]; + newAttrs[NSForegroundColorAttributeName] = [UIColor whiteColor]; + + [attributedString setAttributes:newAttrs range:range]; + } + }]; + + self.messageTextView.attributedText = attributedString; + + // Update the bubble width to include the text view + self.bubbleImageViewWidthConstraint.constant = bubbleData.contentSize.width + 17; + + // Limit bubble width + if (self.bubbleImageViewWidthConstraint.constant < 46) + { + self.bubbleImageViewWidthConstraint.constant = 46; + } + + // Mask the image with the bubble + if (bubbleData.attachment && bubbleData.attachment.type != MXKAttachmentTypeFile && bubbleData.attachment.type != MXKAttachmentTypeAudio) + { + self.bubbleImageView.hidden = YES; + + UIImageView *rightBubbleImageView = [[UIImageView alloc] initWithImage:self.class.bubbleImage]; + rightBubbleImageView.frame = CGRectMake(0, 0, self.bubbleImageViewWidthConstraint.constant, bubbleData.contentSize.height + self.attachViewTopConstraint.constant - 4); + + self.attachmentView.layer.mask = rightBubbleImageView.layer; + } +} + ++ (CGFloat)heightForCellData:(MXKCellData *)cellData withMaximumWidth:(CGFloat)maxWidth +{ + CGFloat rowHeight = [super heightForCellData:cellData withMaximumWidth:maxWidth]; + + CGFloat height = self.cellWithOriginalXib.frame.size.height; + + // Use the xib height as the minimal height + if (rowHeight < height) + { + rowHeight = height; + } + + return rowHeight; +} + +/** + Create the strechable background bubble. + + @return the bubble image. + */ ++ (UIImage *)bubbleImage +{ + UIImage *rightBubbleImage = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"bubble_ios_messages_right"]; + + rightBubbleImage = [MXKTools paintImage:rightBubbleImage + withColor:[MXKTools colorWithRGBValue:OUTGOING_BUBBLE_COLOR]]; + + UIEdgeInsets edgeInsets = UIEdgeInsetsMake(17, 22, 17, 27); + return [rightBubbleImage resizableImageWithCapInsets:edgeInsets]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.xib b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.xib new file mode 100644 index 000000000..042b9ec58 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.xib @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.h new file mode 100644 index 000000000..6819e8d6d --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.h @@ -0,0 +1,24 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIncomingBubbleTableViewCell.h" + +/** + `MXKRoomIncomingAttachmentBubbleCell` displays incoming attachment bubbles with sender's information. + */ +@interface MXKRoomIncomingAttachmentBubbleCell : MXKRoomIncomingBubbleTableViewCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.m new file mode 100644 index 000000000..ecb9eaa3a --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.m @@ -0,0 +1,21 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIncomingAttachmentBubbleCell.h" + +@implementation MXKRoomIncomingAttachmentBubbleCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.xib b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.xib new file mode 100644 index 000000000..ee13c949d --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.xib @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.h new file mode 100644 index 000000000..3d4bbb06b --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.h @@ -0,0 +1,24 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIncomingAttachmentBubbleCell.h" + +/** + `MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell` displays incoming message bubbles without sender's information. + */ +@interface MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell : MXKRoomIncomingAttachmentBubbleCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.m new file mode 100644 index 000000000..6171e49da --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.m @@ -0,0 +1,21 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.h" + +@implementation MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.xib b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.xib new file mode 100644 index 000000000..0dd044e50 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.xib @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingBubbleTableViewCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingBubbleTableViewCell.h new file mode 100644 index 000000000..27d886cbe --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingBubbleTableViewCell.h @@ -0,0 +1,29 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomBubbleTableViewCell.h" + +/** + `MXKRoomIncomingBubbleTableViewCell` inherits from 'MXKRoomBubbleTableViewCell' class in order to handle specific + options related to incoming messages (like typing badge). + + In order to optimize bubbles rendering, we advise to define a .xib for each layout. + */ +@interface MXKRoomIncomingBubbleTableViewCell : MXKRoomBubbleTableViewCell + +@property (weak, nonatomic) IBOutlet UIImageView *typingBadge; + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingBubbleTableViewCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingBubbleTableViewCell.m new file mode 100644 index 000000000..0f3c85106 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingBubbleTableViewCell.m @@ -0,0 +1,65 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIncomingBubbleTableViewCell.h" + +#import "MXKRoomBubbleCellData.h" + +#import "NSBundle+MatrixKit.h" + +@implementation MXKRoomIncomingBubbleTableViewCell + +- (void)finalizeInit +{ + [super finalizeInit]; + self.readReceiptsAlignment = ReadReceiptAlignmentRight; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + self.typingBadge.image = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_keyboard"]; +} + +- (void)render:(MXKCellData *)cellData +{ + [super render:cellData]; + + if (bubbleData) + { + // Handle here typing badge (if any) + if (self.typingBadge) + { + if (bubbleData.isTyping) + { + self.typingBadge.hidden = NO; + [self.typingBadge.superview bringSubviewToFront:self.typingBadge]; + } + else + { + self.typingBadge.hidden = YES; + } + } + } +} + +- (void)didEndDisplay +{ + [super didEndDisplay]; + self.readReceiptsAlignment = ReadReceiptAlignmentRight; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.h new file mode 100644 index 000000000..9fe96c917 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.h @@ -0,0 +1,24 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIncomingBubbleTableViewCell.h" + +/** + `MXKRoomIncomingTextMsgBubbleCell` displays incoming message bubbles with sender's information. + */ +@interface MXKRoomIncomingTextMsgBubbleCell : MXKRoomIncomingBubbleTableViewCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.m new file mode 100644 index 000000000..253e7141f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.m @@ -0,0 +1,21 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIncomingTextMsgBubbleCell.h" + +@implementation MXKRoomIncomingTextMsgBubbleCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.xib b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.xib new file mode 100644 index 000000000..311dc21a7 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.xib @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.h new file mode 100644 index 000000000..41c3be5cf --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.h @@ -0,0 +1,24 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIncomingTextMsgBubbleCell.h" + +/** + `MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell` displays incoming message bubbles without sender's information. + */ +@interface MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell : MXKRoomIncomingTextMsgBubbleCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.m new file mode 100644 index 000000000..4dd146546 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.m @@ -0,0 +1,21 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.h" + +@implementation MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.xib b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.xib new file mode 100644 index 000000000..93308ad9a --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.xib @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.h new file mode 100644 index 000000000..222d626af --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.h @@ -0,0 +1,26 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomOutgoingBubbleTableViewCell.h" + +/** + `MXKRoomOutgoingAttachmentBubbleCell` displays outgoing attachment bubbles. + */ +@interface MXKRoomOutgoingAttachmentBubbleCell : MXKRoomOutgoingBubbleTableViewCell + +@property (weak, nonatomic) IBOutlet UIActivityIndicatorView *activityIndicator; + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.m new file mode 100644 index 000000000..9a3e1cfe1 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.m @@ -0,0 +1,144 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomOutgoingAttachmentBubbleCell.h" + +#import "MXEvent+MatrixKit.h" + +#import "MXKRoomBubbleCellData.h" +#import "MXKImageView.h" +#import "MXKPieChartView.h" + +@implementation MXKRoomOutgoingAttachmentBubbleCell + +- (void)dealloc +{ + [self stopAnimating]; +} + +- (void)render:(MXKCellData *)cellData +{ + [super render:cellData]; + + if (bubbleData) + { + // Do not display activity indicator on outgoing attachments (These attachments are supposed to be stored locally) + // Some download may append to retrieve the actual thumbnail after posting an image. + self.attachmentView.hideActivityIndicator = YES; + + // Check if the attachment is uploading + MXKRoomBubbleComponent *component = bubbleData.bubbleComponents.firstObject; + if (component.event.sentState == MXEventSentStatePreparing || component.event.sentState == MXEventSentStateEncrypting || component.event.sentState == MXEventSentStateUploading) + { + // Retrieve the uploadId embedded in the fake url + bubbleData.uploadId = component.event.content[@"url"]; + + self.attachmentView.alpha = 0.5; + + // Start showing upload progress + [self startUploadAnimating]; + } + else if (component.event.sentState == MXEventSentStateSending) + { + self.attachmentView.alpha = 0.5; + [self.activityIndicator startAnimating]; + } + else if (component.event.sentState == MXEventSentStateFailed) + { + self.attachmentView.alpha = 0.5; + [self.activityIndicator stopAnimating]; + } + else + { + self.attachmentView.alpha = 1; + [self.activityIndicator stopAnimating]; + } + } +} + +- (void)didEndDisplay +{ + [super didEndDisplay]; + + // Hide potential loading wheel + [self stopAnimating]; +} + +-(void)startUploadAnimating +{ + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:nil]; + + [self.activityIndicator startAnimating]; + + MXMediaLoader *uploader = [MXMediaManager existingUploaderWithId:bubbleData.uploadId]; + if (uploader) + { + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMediaLoaderStateDidChange:) name:kMXMediaLoaderStateDidChangeNotification object:uploader]; + + if (uploader.statisticsDict) + { + [self.activityIndicator stopAnimating]; + [self updateProgressUI:uploader.statisticsDict]; + + // Check whether the upload is ended + if (self.progressChartView.progress == 1.0) + { + [self stopAnimating]; + } + } + } + else + { + self.progressView.hidden = YES; + } +} + +-(void)stopAnimating +{ + self.progressView.hidden = YES; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:nil]; + [self.activityIndicator stopAnimating]; +} + +- (void)onMediaLoaderStateDidChange:(NSNotification *)notif +{ + MXMediaLoader *loader = (MXMediaLoader*)notif.object; + + // Consider only the progress of the current upload. + if ([loader.uploadId isEqualToString:bubbleData.uploadId]) + { + switch (loader.state) { + case MXMediaLoaderStateUploadInProgress: + { + [self.activityIndicator stopAnimating]; + [self updateProgressUI:loader.statisticsDict]; + + // the upload is ended + if (self.progressChartView.progress == 1.0) + { + [self stopAnimating]; + } + break; + } + default: + break; + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.xib b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.xib new file mode 100644 index 000000000..d4246c3e3 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.xib @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.h new file mode 100644 index 000000000..61522176f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.h @@ -0,0 +1,24 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomOutgoingAttachmentBubbleCell.h" + +/** + `MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell` displays outgoing attachment with thumbnail, without user's name. + */ +@interface MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell : MXKRoomOutgoingAttachmentBubbleCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.m new file mode 100644 index 000000000..eb9f8ac67 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.m @@ -0,0 +1,21 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.h" + +@implementation MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.xib b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.xib new file mode 100644 index 000000000..e02de0455 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.xib @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingBubbleTableViewCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingBubbleTableViewCell.h new file mode 100644 index 000000000..dd9d05285 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingBubbleTableViewCell.h @@ -0,0 +1,27 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomBubbleTableViewCell.h" + +/** + `MXKRoomOutgoingBubbleTableViewCell` inherits from 'MXKRoomBubbleTableViewCell' class in order to handle specific + options related to outgoing messages (like unsent labels, upload progress in case of attachment). + + In order to optimize bubbles rendering, we advise to define a .xib for each layout. + */ +@interface MXKRoomOutgoingBubbleTableViewCell : MXKRoomBubbleTableViewCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingBubbleTableViewCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingBubbleTableViewCell.m new file mode 100644 index 000000000..9c74f6aff --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingBubbleTableViewCell.m @@ -0,0 +1,117 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomOutgoingBubbleTableViewCell.h" + +#import "MXEvent+MatrixKit.h" + +#import "NSBundle+Matrixkit.h" + +#import "MXKRoomBubbleCellData.h" + +#import "MXKSwiftHeader.h" + +@implementation MXKRoomOutgoingBubbleTableViewCell + +- (void)render:(MXKCellData *)cellData +{ + [super render:cellData]; + + if (bubbleData) + { + // Add unsent label for failed components (except if the app customizes it) + if (self.bubbleInfoContainer && (bubbleData.useCustomUnsentButton == NO)) + { + for (MXKRoomBubbleComponent *component in bubbleData.bubbleComponents) + { + if (component.event.sentState == MXEventSentStateFailed) + { + UIButton *unsentButton = [[UIButton alloc] initWithFrame:CGRectMake(0, component.position.y, 58 , 20)]; + + [unsentButton setTitle:[MatrixKitL10n unsent] forState:UIControlStateNormal]; + [unsentButton setTitle:[MatrixKitL10n unsent] forState:UIControlStateSelected]; + [unsentButton setTitleColor:[UIColor redColor] forState:UIControlStateNormal]; + [unsentButton setTitleColor:[UIColor redColor] forState:UIControlStateSelected]; + + unsentButton.backgroundColor = [UIColor whiteColor]; + unsentButton.titleLabel.font = [UIFont systemFontOfSize:14]; + + [unsentButton addTarget:self action:@selector(onResendToggle:) forControlEvents:UIControlEventTouchUpInside]; + + [self.bubbleInfoContainer addSubview:unsentButton]; + self.bubbleInfoContainer.hidden = NO; + self.bubbleInfoContainer.userInteractionEnabled = YES; + + // ensure that bubbleInfoContainer is at front to catch the tap event + [self.bubbleInfoContainer.superview bringSubviewToFront:self.bubbleInfoContainer]; + } + } + } + } +} + +- (void)didEndDisplay +{ + [super didEndDisplay]; + + self.bubbleInfoContainer.userInteractionEnabled = NO; +} + +#pragma mark - User actions + +- (IBAction)onResendToggle:(id)sender +{ + if ([sender isKindOfClass:[UIButton class]] && self.delegate) + { + MXEvent *selectedEvent = nil; + + NSArray *bubbleComponents = bubbleData.bubbleComponents; + + if (bubbleComponents.count == 1) + { + MXKRoomBubbleComponent *component = [bubbleComponents firstObject]; + selectedEvent = component.event; + } + else if (bubbleComponents.count) + { + // Here the selected view is a textView (attachment has no more than one component) + + // Look for the selected component + UIButton *unsentButton = (UIButton *)sender; + for (MXKRoomBubbleComponent *component in bubbleComponents) + { + // Ignore components without display. + if (!component.attributedTextMessage) + { + continue; + } + + if (unsentButton.frame.origin.y == component.position.y) + { + selectedEvent = component.event; + break; + } + } + } + + if (selectedEvent) + { + [self.delegate cell:self didRecognizeAction:kMXKRoomBubbleCellUnsentButtonPressed userInfo:@{kMXKRoomBubbleCellEventKey:selectedEvent}]; + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.h new file mode 100644 index 000000000..f6665daeb --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.h @@ -0,0 +1,24 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomOutgoingBubbleTableViewCell.h" + +/** + `MXKRoomOutgoingTextMsgBubbleCell` displays outgoing message bubbles with user's picture. + */ +@interface MXKRoomOutgoingTextMsgBubbleCell : MXKRoomOutgoingBubbleTableViewCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.m new file mode 100644 index 000000000..232813182 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.m @@ -0,0 +1,21 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomOutgoingTextMsgBubbleCell.h" + +@implementation MXKRoomOutgoingTextMsgBubbleCell + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.xib b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.xib new file mode 100644 index 000000000..e6a54821f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.xib @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.h new file mode 100644 index 000000000..685471b71 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.h @@ -0,0 +1,24 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomOutgoingTextMsgBubbleCell.h" + +/** + `MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell` displays outgoing message bubbles without user's name. + */ +@interface MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell : MXKRoomOutgoingTextMsgBubbleCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.m new file mode 100644 index 000000000..41250df3f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.m @@ -0,0 +1,21 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.h" + +@implementation MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.xib b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.xib new file mode 100644 index 000000000..b626c5077 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.xib @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h new file mode 100644 index 000000000..3572b95e5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h @@ -0,0 +1,358 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import + +#import "MXKView.h" + +/** + List the predefined modes to handle the size of attached images + */ +typedef enum : NSUInteger +{ + /** + Prompt the user to select the compression level + */ + MXKRoomInputToolbarCompressionModePrompt, + + /** + The compression level is fixed for the following modes + */ + MXKRoomInputToolbarCompressionModeSmall, + MXKRoomInputToolbarCompressionModeMedium, + MXKRoomInputToolbarCompressionModeLarge, + + /** + No compression, the original image is sent + */ + MXKRoomInputToolbarCompressionModeNone + +} MXKRoomInputToolbarCompressionMode; + + +@class MXKRoomInputToolbarView; +@protocol MXKRoomInputToolbarViewDelegate + +/** + Tells the delegate that an alert must be presented. + + @param toolbarView the room input toolbar view. + @param alertController the alert to present. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView presentAlertController:(UIAlertController*)alertController; + +/** + Tells the delegate that the visibility of the status bar must be changed. + + @param toolbarView the room input toolbar view. + @param isHidden tell whether the status bar must be hidden or not. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView hideStatusBar:(BOOL)isHidden; + +@optional + +/** + Tells the delegate that the user is typing or has finished typing. + + @param toolbarView the room input toolbar view + @param typing YES if the user is typing inside the message composer. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView isTyping:(BOOL)typing; + +/** + Tells the delegate that toolbar height has been updated. + + @param toolbarView the room input toolbar view. + @param height the updated height of toolbar view. + @param completion a block object to be executed when height change is taken into account. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView heightDidChanged:(CGFloat)height completion:(void (^)(BOOL finished))completion; + +/** + Tells the delegate that the user wants to send a text message. + + @param toolbarView the room input toolbar view. + @param textMessage the string to send. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendTextMessage:(NSString*)textMessage; + +/** + Tells the delegate that the user wants to send an image. + + @param toolbarView the room input toolbar view. + @param image the UIImage hosting the image data to send. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendImage:(UIImage*)image; + +/** + Tells the delegate that the user wants to send an image. + + @param toolbarView the room input toolbar view. + @param imageData the full-sized image data of the image. + @param mimetype image mime type + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendImage:(NSData*)imageData withMimeType:(NSString*)mimetype; + +/** + Tells the delegate that the user wants to send a video. + + @param toolbarView the room input toolbar view. + @param videoLocalURL the local filesystem path of the video to send. + @param videoThumbnail the UIImage hosting a video thumbnail. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendVideo:(NSURL*)videoLocalURL withThumbnail:(UIImage*)videoThumbnail; + +/** + Tells the delegate that the user wants to send a video. + + @param toolbarView the room input toolbar view. + @param videoAsset the AVAsset that represents the video to send. + @param videoThumbnail the UIImage hosting a video thumbnail. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendVideoAsset:(AVAsset*)videoAsset withThumbnail:(UIImage*)videoThumbnail; + +/** + Tells the delegate that the user wants to send a file. + + @param toolbarView the room input toolbar view. + @param fileLocalURL the local filesystem path of the file to send. + @param mimetype file mime type + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendFile:(NSURL*)fileLocalURL withMimeType:(NSString*)mimetype; + +/** + Tells the delegate that the user wants invite a matrix user. + + Note: `Invite matrix user` option is displayed in actions list only if the delegate implements this method. + + @param toolbarView the room input toolbar view. + @param mxUserId the Matrix user id. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView inviteMatrixUser:(NSString*)mxUserId; + +/** + Tells the delegate that the user wants to place a voice or a video call. + + @param toolbarView the room input toolbar view. + @param video YES to make a video call. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView placeCallWithVideo:(BOOL)video; + +/** + Tells the delegate that the user wants to hangup the current call. + + @param toolbarView the room input toolbar view. + */ +- (void)roomInputToolbarViewHangupCall:(MXKRoomInputToolbarView*)toolbarView; + +/** + Tells the delegate to present a view controller modally. + + Note: Media attachment is available only if the delegate implements this method. + + @param toolbarView the room input toolbar view. + @param viewControllerToPresent the view controller to present. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView presentViewController:(UIViewController*)viewControllerToPresent; + +/** + Tells the delegate to dismiss the view controller that was presented modally + + @param toolbarView the room input toolbar view. + @param flag Pass YES to animate the transition. + @param completion The block to execute after the view controller is dismissed. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView dismissViewControllerAnimated:(BOOL)flag completion:(void (^)(void))completion; + +/** + Tells the delegate to start or stop an activity indicator. + + @param toolbarView the room input toolbar view + @param isAnimating YES if the activity indicator should run. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView updateActivityIndicator:(BOOL)isAnimating; + +@end + +/** + `MXKRoomInputToolbarView` instance is a view used to handle all kinds of available inputs + for a room (message composer, attachments selection...). + + By default the right button of the toolbar offers the following options: attach media, invite new members. + By default the left button is used to send the content of the message composer. + By default 'messageComposerContainer' is empty. + */ +@interface MXKRoomInputToolbarView : MXKView { + /** + The message composer container view. Your own message composer may be added inside this container. + */ + UIView *messageComposerContainer; + +@protected + UIView *inputAccessoryView; +} + +/** + * Returns the `UINib` object initialized for the tool bar view. + * + * @return The initialized `UINib` object or `nil` if there were errors during + * initialization or the nib file could not be located. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKRoomInputToolbarView-inherited` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKRoomInputToolbarView-inherited` object if successful, `nil` otherwise. + */ ++ (instancetype)roomInputToolbarView; + +/** + The delegate notified when inputs are ready. + */ +@property (weak, nonatomic) id delegate; + +/** + A custom button displayed on the left of the toolbar view. + */ +@property (weak, nonatomic) IBOutlet UIButton *leftInputToolbarButton; + +/** + A custom button displayed on the right of the toolbar view. + */ +@property (weak, nonatomic) IBOutlet UIButton *rightInputToolbarButton; + +/** + Layout constraint between the top of the message composer container and the top of its superview. + The first view is the container, the second is the superview. + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *messageComposerContainerTopConstraint; + +/** + Layout constraint between the bottom of the message composer container and the bottom of its superview. + The first view is the superview, the second is the container. + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *messageComposerContainerBottomConstraint; + +/** + Tell whether the sent images and videos should be automatically saved in the user's photos library. NO by default. + */ +@property (nonatomic) BOOL enableAutoSaving; + +/** + Tell whether the text is editable. YES by default. + */ +@property(nonatomic, getter=isEditable) BOOL editable; + +/** + `onTouchUpInside` action is registered on `Touch Up Inside` event for both buttons (left and right input toolbar buttons). + Override this method to customize user interaction handling + + @param button the event sender + */ +- (IBAction)onTouchUpInside:(UIButton*)button; + +/** + Handle image attachment + Save the image in user's photos library when 'isPhotoLibraryAsset' flag is NO and auto saving is enabled. + + @param imageData the full-sized image data of the selected image. + @param mimetype the image MIME type (nil if unknown). + @param compressionMode the compression mode to apply on this image. This option is considered only for jpeg image. + @param isPhotoLibraryAsset tell whether the image has been selected from the user's photos library or not. + */ +- (void)sendSelectedImage:(NSData*)imageData withMimeType:(NSString *)mimetype andCompressionMode:(MXKRoomInputToolbarCompressionMode)compressionMode isPhotoLibraryAsset:(BOOL)isPhotoLibraryAsset; + +/** + Handle video attachment. + Save the video in user's photos library when 'isPhotoLibraryAsset' flag is NO and auto saving is enabled. + + @param selectedVideo the local url of the video to send. + @param isPhotoLibraryAsset tell whether the video has been selected from user's photos library. + */ +- (void)sendSelectedVideo:(NSURL*)selectedVideo isPhotoLibraryAsset:(BOOL)isPhotoLibraryAsset; + +/** + Handle video attachment. + Save the video in user's photos library when 'isPhotoLibraryAsset' flag is NO and auto saving is enabled. + + @param selectedVideo an AVAsset that represents the video to send. + @param isPhotoLibraryAsset tell whether the video has been selected from user's photos library. + */ +- (void)sendSelectedVideoAsset:(AVAsset*)selectedVideo isPhotoLibraryAsset:(BOOL)isPhotoLibraryAsset; + +/** + Handle multiple media attachments according to the compression mode. + + @param assets the selected assets. + @param compressionMode the compression mode to apply on the media. This option is considered only for jpeg image. + */ +- (void)sendSelectedAssets:(NSArray*)assets withCompressionMode:(MXKRoomInputToolbarCompressionMode)compressionMode; + +/** + The maximum height of the toolbar. + A value <= 0 means no limit. + */ +@property CGFloat maxHeight; + +/** + The current text message in message composer. + */ +@property NSString *textMessage; + +/** + The string that should be displayed when there is no other text in message composer. + This property may be ignored when message composer does not support placeholder display. + */ +@property (nonatomic) NSString *placeholder; + +/** + The custom accessory view associated with the message composer. This view is + actually used to retrieve the keyboard view. Indeed the keyboard view is the superview of + the accessory view when the message composer become the first responder. + */ +@property (readonly) UIView *inputAccessoryView; + +/** + Display the keyboard. + */ +- (BOOL)becomeFirstResponder; + +/** + Force dismiss keyboard. + */ +- (void)dismissKeyboard; + +/** + Dispose any resources and listener. + */ +- (void)destroy; + +/** + Paste a text in textMessage. + + The text is pasted at the current cursor location in the message composer or it + replaces the currently selected text. + + @param text the text to paste. + */ +- (void)pasteText:(NSString*)text; + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m new file mode 100644 index 000000000..4c3924d96 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m @@ -0,0 +1,1399 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomInputToolbarView.h" +#import "MXKSwiftHeader.h" +#import "MXKAppSettings.h" + +@import MatrixSDK.MXMediaManager; +@import MediaPlayer; +@import MobileCoreServices; +@import Photos; + +#import "MXKImageView.h" + +#import "MXKTools.h" + +#import "NSBundle+MatrixKit.h" +#import "MXKConstants.h" + +@interface MXKRoomInputToolbarView() +{ + /** + Alert used to list options. + */ + UIAlertController *optionsListView; + + /** + Current media picker + */ + UIImagePickerController *mediaPicker; + + /** + Array of validation views (MXKImageView instances) + */ + NSMutableArray *validationViews; + + /** + Handle images attachment + */ + UIAlertController *compressionPrompt; + NSMutableArray *pendingImages; +} + +@property (nonatomic) IBOutlet UIView *messageComposerContainer; + +@end + +@implementation MXKRoomInputToolbarView +@synthesize messageComposerContainer, inputAccessoryView; + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRoomInputToolbarView class]) + bundle:[NSBundle bundleForClass:[MXKRoomInputToolbarView class]]]; +} + ++ (instancetype)roomInputToolbarView +{ + if ([[self class] nib]) + { + return [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + } + else + { + return [[self alloc] init]; + } +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + // Finalize setup + [self setTranslatesAutoresizingMaskIntoConstraints: NO]; + + // Disable send button + self.rightInputToolbarButton.enabled = NO; + + // Enable text edition by default + self.editable = YES; + + // Localize string + [_rightInputToolbarButton setTitle:[MatrixKitL10n send] forState:UIControlStateNormal]; + [_rightInputToolbarButton setTitle:[MatrixKitL10n send] forState:UIControlStateHighlighted]; + + validationViews = [NSMutableArray array]; +} + +- (void)dealloc +{ + inputAccessoryView = nil; + + [self destroy]; +} + +#pragma mark - Override MXKView + +-(void)customizeViewRendering +{ + [super customizeViewRendering]; + + // Reset default container background color + messageComposerContainer.backgroundColor = [UIColor clearColor]; + + // Set default toolbar background color + self.backgroundColor = [UIColor colorWithRed:0.9 green:0.9 blue:0.9 alpha:1.0]; +} + +#pragma mark - + +- (IBAction)onTouchUpInside:(UIButton*)button +{ + if (button == self.leftInputToolbarButton) + { + if (optionsListView) + { + [optionsListView dismissViewControllerAnimated:NO completion:nil]; + optionsListView = nil; + } + + // Option button has been pressed + // List available options + __weak typeof(self) weakSelf = self; + + // Check whether media attachment is supported + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:presentViewController:)]) + { + optionsListView = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + + [optionsListView addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n attachMedia] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->optionsListView = nil; + + // Open media gallery + self->mediaPicker = [[UIImagePickerController alloc] init]; + self->mediaPicker.delegate = self; + self->mediaPicker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary; + self->mediaPicker.allowsEditing = NO; + self->mediaPicker.mediaTypes = [NSArray arrayWithObjects:(NSString *)kUTTypeImage, (NSString *)kUTTypeMovie, nil]; + [self.delegate roomInputToolbarView:self presentViewController:self->mediaPicker]; + } + + }]]; + + [optionsListView addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n captureMedia] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->optionsListView = nil; + + // Open Camera + self->mediaPicker = [[UIImagePickerController alloc] init]; + self->mediaPicker.delegate = self; + self->mediaPicker.sourceType = UIImagePickerControllerSourceTypeCamera; + self->mediaPicker.allowsEditing = NO; + self->mediaPicker.mediaTypes = [NSArray arrayWithObjects:(NSString *)kUTTypeImage, (NSString *)kUTTypeMovie, nil]; + [self.delegate roomInputToolbarView:self presentViewController:self->mediaPicker]; + } + + }]]; + } + else + { + MXLogDebug(@"[MXKRoomInputToolbarView] Attach media is not supported"); + } + + // Check whether user invitation is supported + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:inviteMatrixUser:)]) + { + if (!optionsListView) + { + optionsListView = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + } + + [optionsListView addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n inviteUser] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Ask for userId to invite + self->optionsListView = [UIAlertController alertControllerWithTitle:[MatrixKitL10n userIdTitle] message:nil preferredStyle:UIAlertControllerStyleAlert]; + + + [self->optionsListView addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->optionsListView = nil; + } + + }]]; + + [self->optionsListView addTextFieldWithConfigurationHandler:^(UITextField *textField) { + + textField.secureTextEntry = NO; + textField.placeholder = [MatrixKitL10n userIdPlaceholder]; + + }]; + + [self->optionsListView addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n invite] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + UITextField *textField = [self->optionsListView textFields].firstObject; + NSString *userId = textField.text; + + self->optionsListView = nil; + + if (userId.length) + { + [self.delegate roomInputToolbarView:self inviteMatrixUser:userId]; + } + } + + }]]; + + [self.delegate roomInputToolbarView:self presentAlertController:self->optionsListView]; + } + + }]]; + } + else + { + MXLogDebug(@"[MXKRoomInputToolbarView] Invitation is not supported"); + } + + if (optionsListView) + { + + [self->optionsListView addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->optionsListView = nil; + } + + }]]; + + [optionsListView popoverPresentationController].sourceView = button; + [optionsListView popoverPresentationController].sourceRect = button.bounds; + [self.delegate roomInputToolbarView:self presentAlertController:optionsListView]; + } + else + { + MXLogDebug(@"[MXKRoomInputToolbarView] No option is supported"); + } + } + else if (button == self.rightInputToolbarButton && self.textMessage.length) + { + // This forces an autocorrect event to happen when "Send" is pressed, which is necessary to accept a pending correction on send + self.textMessage = [NSString stringWithFormat:@"%@ ", self.textMessage]; + self.textMessage = [self.textMessage substringToIndex:[self.textMessage length]-1]; + + NSString *message = self.textMessage; + + // Reset message, disable view animation during the update to prevent placeholder distorsion. + [UIView setAnimationsEnabled:NO]; + self.textMessage = nil; + [UIView setAnimationsEnabled:YES]; + + // Send button has been pressed + if (message.length && [self.delegate respondsToSelector:@selector(roomInputToolbarView:sendTextMessage:)]) + { + [self.delegate roomInputToolbarView:self sendTextMessage:message]; + } + } +} + +- (void)setPlaceholder:(NSString *)inPlaceholder +{ + _placeholder = inPlaceholder; +} + +- (BOOL)becomeFirstResponder +{ + return NO; +} + +- (void)dismissKeyboard +{ + +} + +- (void)dismissCompressionPrompt +{ + if (compressionPrompt) + { + [compressionPrompt dismissViewControllerAnimated:NO completion:nil]; + compressionPrompt = nil; + } + + if (pendingImages.count) + { + NSData *firstImage = pendingImages.firstObject; + [pendingImages removeObjectAtIndex:0]; + [self sendImage:firstImage withCompressionMode:MXKRoomInputToolbarCompressionModePrompt]; + } +} + +- (void)destroy +{ + [self dismissValidationViews]; + validationViews = nil; + + if (optionsListView) + { + [optionsListView dismissViewControllerAnimated:NO completion:nil]; + optionsListView = nil; + } + + [self dismissMediaPicker]; + + self.delegate = nil; + + pendingImages = nil; + [self dismissCompressionPrompt]; +} + +- (void)pasteText:(NSString *)text +{ + // We cannot do more than appending text to self.textMessage + // Let 'MXKRoomInputToolbarView' children classes do the job + self.textMessage = [NSString stringWithFormat:@"%@%@", self.textMessage, text]; +} + + +#pragma mark - MXKFileSizes + +/** + Structure representing the file sizes of a media according to different level of + compression. + */ +typedef struct +{ + NSUInteger small; + NSUInteger medium; + NSUInteger large; + NSUInteger original; + +} MXKFileSizes; + +void MXKFileSizes_init(MXKFileSizes *sizes) +{ + memset(sizes, 0, sizeof(MXKFileSizes)); +} + +MXKFileSizes MXKFileSizes_add(MXKFileSizes sizes1, MXKFileSizes sizes2) +{ + MXKFileSizes sizes; + sizes.small = sizes1.small + sizes2.small; + sizes.medium = sizes1.medium + sizes2.medium; + sizes.large = sizes1.large + sizes2.large; + sizes.original = sizes1.original + sizes2.original; + + return sizes; +} + +NSString* MXKFileSizes_description(MXKFileSizes sizes) +{ + return [NSString stringWithFormat:@"small: %tu - medium: %tu - large: %tu - original: %tu", sizes.small, sizes.medium, sizes.large, sizes.original]; +} + +- (void)availableCompressionSizesForAsset:(PHAsset*)asset onComplete:(void(^)(MXKFileSizes sizes))onComplete +{ + __block MXKFileSizes sizes; + MXKFileSizes_init(&sizes); + + if (asset.mediaType == PHAssetMediaTypeImage) + { + PHImageRequestOptions *options = [[PHImageRequestOptions alloc] init]; + options.synchronous = NO; + options.networkAccessAllowed = YES; + + [[PHImageManager defaultManager] requestImageDataForAsset:asset options:options resultHandler:^(NSData * _Nullable imageData, NSString * _Nullable dataUTI, UIImageOrientation orientation, NSDictionary * _Nullable info) { + + if (imageData) + { + MXLogDebug(@"[MXKRoomInputToolbarView] availableCompressionSizesForAsset: Got image data"); + + UIImage *image = [UIImage imageWithData:imageData]; + + MXKImageCompressionSizes compressionSizes = [MXKTools availableCompressionSizesForImage:image originalFileSize:imageData.length]; + + sizes.small = compressionSizes.small.fileSize; + sizes.medium = compressionSizes.medium.fileSize; + sizes.large = compressionSizes.large.fileSize; + sizes.original = compressionSizes.original.fileSize; + + onComplete(sizes); + } + else + { + MXLogDebug(@"[MXKRoomInputToolbarView] availableCompressionSizesForAsset: Failed to get image data"); + + // Notify user + NSError *error = info[@"PHImageErrorKey"]; + if (error.userInfo[NSUnderlyingErrorKey]) + { + error = error.userInfo[NSUnderlyingErrorKey]; + } + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + + onComplete(sizes); + } + + }]; + } + else if (asset.mediaType == PHAssetMediaTypeVideo) + { + PHVideoRequestOptions *options = [[PHVideoRequestOptions alloc] init]; + options.networkAccessAllowed = YES; + + [[PHImageManager defaultManager] requestAVAssetForVideo:asset options:options resultHandler:^(AVAsset *asset, AVAudioMix *audioMix, NSDictionary *info) { + + if ([asset isKindOfClass:[AVURLAsset class]]) + { + MXLogDebug(@"[MXKRoomInputToolbarView] availableCompressionSizesForAsset: Got video data"); + AVURLAsset* urlAsset = (AVURLAsset*)asset; + + NSNumber *size; + [urlAsset.URL getResourceValue:&size forKey:NSURLFileSizeKey error:nil]; + + sizes.original = size.unsignedIntegerValue; + sizes.small = sizes.original; + sizes.medium = sizes.original; + sizes.large = sizes.original; + + dispatch_async(dispatch_get_main_queue(), ^{ + onComplete(sizes); + }); + } + else + { + MXLogDebug(@"[MXKRoomInputToolbarView] availableCompressionSizesForAsset: Failed to get video data"); + + // Notify user + NSError *error = info[@"PHImageErrorKey"]; + if (error.userInfo[NSUnderlyingErrorKey]) + { + error = error.userInfo[NSUnderlyingErrorKey]; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + onComplete(sizes); + + }); + } + + }]; + } + else + { + MXLogDebug(@"[MXKRoomInputToolbarView] availableCompressionSizesForAsset: unexpected media type"); + onComplete(sizes); + } +} + + +- (void)availableCompressionSizesForAssets:(NSMutableArray*)checkedAssets index:(NSUInteger)index appendTo:(MXKFileSizes)sizes onComplete:(void(^)(NSArray*checkedAssets, MXKFileSizes fileSizes))onComplete +{ + [self availableCompressionSizesForAsset:checkedAssets[index] onComplete:^(MXKFileSizes assetSizes) { + + MXKFileSizes intermediateSizes; + NSUInteger nextIndex; + + if (assetSizes.original == 0) + { + // Ignore this asset + [checkedAssets removeObjectAtIndex:index]; + intermediateSizes = sizes; + nextIndex = index; + } + else + { + intermediateSizes = MXKFileSizes_add(sizes, assetSizes); + nextIndex = index + 1; + } + + if (nextIndex == checkedAssets.count) + { + // Filter the sizes that are similar + if (intermediateSizes.medium >= intermediateSizes.large || intermediateSizes.large >= intermediateSizes.original) + { + intermediateSizes.large = 0; + } + if (intermediateSizes.small >= intermediateSizes.medium || intermediateSizes.medium >= intermediateSizes.original) + { + intermediateSizes.medium = 0; + } + if (intermediateSizes.small >= intermediateSizes.original) + { + intermediateSizes.small = 0; + } + + onComplete(checkedAssets, intermediateSizes); + } + else + { + [self availableCompressionSizesForAssets:checkedAssets index:nextIndex appendTo:intermediateSizes onComplete:onComplete]; + } + }]; +} + +- (void)availableCompressionSizesForAssets:(NSArray*)assets onComplete:(void(^)(NSArray*checkedAssets, MXKFileSizes fileSizes))onComplete +{ + __block MXKFileSizes sizes; + MXKFileSizes_init(&sizes); + + NSMutableArray *checkedAssets = [NSMutableArray arrayWithArray:assets]; + + [self availableCompressionSizesForAssets:checkedAssets index:0 appendTo:sizes onComplete:onComplete]; +} + +#pragma mark - Attachment handling + +- (void)sendSelectedImage:(NSData*)imageData withMimeType:(NSString *)mimetype andCompressionMode:(MXKRoomInputToolbarCompressionMode)compressionMode isPhotoLibraryAsset:(BOOL)isPhotoLibraryAsset +{ + // Check condition before saving this media in user's library + if (_enableAutoSaving && !isPhotoLibraryAsset) + { + // Save the original image in user's photos library + UIImage *image = [UIImage imageWithData:imageData]; + [MXMediaManager saveImageToPhotosLibrary:image success:nil failure:nil]; + } + + // Send data without compression if the image type is not jpeg + // Force compression for a heic image so that we generate jpeg from it + if (mimetype + && [mimetype isEqualToString:@"image/jpeg"] == NO + && [mimetype isEqualToString:@"image/heic"] == NO + && [self.delegate respondsToSelector:@selector(roomInputToolbarView:sendImage:withMimeType:)]) + { + [self.delegate roomInputToolbarView:self sendImage:imageData withMimeType:mimetype]; + } + else + { + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:sendImage:)]) + { + [self sendImage:imageData withCompressionMode:compressionMode]; + } + else + { + MXLogDebug(@"[MXKRoomInputToolbarView] Attach image is not supported"); + } + } +} + +- (void)sendImage:(NSData*)imageData withCompressionMode:(MXKRoomInputToolbarCompressionMode)compressionMode +{ + if (optionsListView) + { + [optionsListView dismissViewControllerAnimated:NO completion:nil]; + optionsListView = nil; + } + + if (compressionPrompt && compressionMode == MXKRoomInputToolbarCompressionModePrompt) + { + // Delay the image sending + if (!pendingImages) + { + pendingImages = [NSMutableArray arrayWithObject:imageData]; + } + else + { + [pendingImages addObject:imageData]; + } + return; + } + + // Get available sizes for this image + UIImage *image = [UIImage imageWithData:imageData]; + MXKImageCompressionSizes compressionSizes = [MXKTools availableCompressionSizesForImage:image originalFileSize:imageData.length]; + + // Apply the compression mode + if (compressionMode == MXKRoomInputToolbarCompressionModePrompt + && (compressionSizes.small.fileSize || compressionSizes.medium.fileSize || compressionSizes.large.fileSize)) + { + __weak typeof(self) weakSelf = self; + + compressionPrompt = [UIAlertController alertControllerWithTitle:[MatrixKitL10n attachmentSizePromptTitle] + message:[MatrixKitL10n attachmentSizePromptMessage] + preferredStyle:UIAlertControllerStyleActionSheet]; + + if (compressionSizes.small.fileSize) + { + NSString *fileSizeString = [MXTools fileSizeToString:compressionSizes.small.fileSize]; + + NSString *title = [MatrixKitL10n attachmentSmall:fileSizeString]; + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Send the small image + UIImage *smallImage = [MXKTools reduceImage:image toFitInSize:CGSizeMake(MXKTOOLS_SMALL_IMAGE_SIZE, MXKTOOLS_SMALL_IMAGE_SIZE)]; + [self.delegate roomInputToolbarView:self sendImage:smallImage]; + + [self dismissCompressionPrompt]; + } + + }]]; + } + + if (compressionSizes.medium.fileSize) + { + NSString *fileSizeString = [MXTools fileSizeToString:compressionSizes.medium.fileSize]; + + NSString *title = [MatrixKitL10n attachmentMedium:fileSizeString]; + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Send the medium image + UIImage *mediumImage = [MXKTools reduceImage:image toFitInSize:CGSizeMake(MXKTOOLS_MEDIUM_IMAGE_SIZE, MXKTOOLS_MEDIUM_IMAGE_SIZE)]; + [self.delegate roomInputToolbarView:self sendImage:mediumImage]; + + [self dismissCompressionPrompt]; + } + + }]]; + } + + if (compressionSizes.large.fileSize) + { + NSString *fileSizeString = [MXTools fileSizeToString:compressionSizes.large.fileSize]; + + NSString *title = [MatrixKitL10n attachmentLarge:fileSizeString]; + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Send the large image + UIImage *largeImage = [MXKTools reduceImage:image toFitInSize:CGSizeMake(compressionSizes.actualLargeSize, compressionSizes.actualLargeSize)]; + [self.delegate roomInputToolbarView:self sendImage:largeImage]; + + [self dismissCompressionPrompt]; + } + + }]]; + } + + NSString *fileSizeString = [MXTools fileSizeToString:compressionSizes.original.fileSize]; + + NSString *title = [MatrixKitL10n attachmentOriginal:fileSizeString]; + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Send the original image + [self.delegate roomInputToolbarView:self sendImage:image]; + + [self dismissCompressionPrompt]; + } + + }]]; + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleCancel + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + [self dismissCompressionPrompt]; + } + + }]]; + + [compressionPrompt popoverPresentationController].sourceView = self; + [compressionPrompt popoverPresentationController].sourceRect = self.bounds; + [self.delegate roomInputToolbarView:self presentAlertController:compressionPrompt]; + } + else + { + // By default the original image is sent + UIImage *finalImage = image; + + switch (compressionMode) + { + case MXKRoomInputToolbarCompressionModePrompt: + // Here the image size is too small to need compression - send the original image + break; + + case MXKRoomInputToolbarCompressionModeSmall: + if (compressionSizes.small.fileSize) + { + finalImage = [MXKTools reduceImage:image toFitInSize:CGSizeMake(MXKTOOLS_SMALL_IMAGE_SIZE, MXKTOOLS_SMALL_IMAGE_SIZE)]; + } + break; + + case MXKRoomInputToolbarCompressionModeMedium: + if (compressionSizes.medium.fileSize) + { + finalImage = [MXKTools reduceImage:image toFitInSize:CGSizeMake(MXKTOOLS_MEDIUM_IMAGE_SIZE, MXKTOOLS_MEDIUM_IMAGE_SIZE)]; + } + break; + + case MXKRoomInputToolbarCompressionModeLarge: + if (compressionSizes.large.fileSize) + { + finalImage = [MXKTools reduceImage:image toFitInSize:CGSizeMake(compressionSizes.actualLargeSize, compressionSizes.actualLargeSize)]; + } + break; + + default: + // no compression, send original + break; + } + + // Send the image + [self.delegate roomInputToolbarView:self sendImage:finalImage]; + } +} + +- (void)sendSelectedVideo:(NSURL*)selectedVideo isPhotoLibraryAsset:(BOOL)isPhotoLibraryAsset +{ + AVURLAsset *videoAsset = [AVURLAsset assetWithURL:selectedVideo]; + [self sendSelectedVideoAsset:videoAsset isPhotoLibraryAsset:isPhotoLibraryAsset]; +} + +- (void)sendSelectedVideoAsset:(AVAsset*)selectedVideo isPhotoLibraryAsset:(BOOL)isPhotoLibraryAsset +{ + // Check condition before saving this media in user's library + if (_enableAutoSaving && !isPhotoLibraryAsset) + { + if ([selectedVideo isKindOfClass:[AVURLAsset class]]) + { + AVURLAsset *urlAsset = (AVURLAsset*)selectedVideo; + [MXMediaManager saveMediaToPhotosLibrary:[urlAsset URL] isImage:NO success:nil failure:nil]; + } + else + { + MXLogError(@"[RoomInputToolbarView] Unable to save video, incorrect asset type.") + } + } + + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:sendVideoAsset:withThumbnail:)]) + { + // Retrieve the video frame at 1 sec to define the video thumbnail + AVAssetImageGenerator *assetImageGenerator = [AVAssetImageGenerator assetImageGeneratorWithAsset:selectedVideo]; + assetImageGenerator.appliesPreferredTrackTransform = YES; + CMTime time = CMTimeMake(1, 1); + CGImageRef imageRef = [assetImageGenerator copyCGImageAtTime:time actualTime:NULL error:nil]; + + // Finalize video attachment + UIImage* videoThumbnail = [[UIImage alloc] initWithCGImage:imageRef]; + CFRelease(imageRef); + + [self.delegate roomInputToolbarView:self sendVideoAsset:selectedVideo withThumbnail:videoThumbnail]; + } + else + { + MXLogDebug(@"[RoomInputToolbarView] Attach video is not supported"); + } +} + +- (void)sendSelectedAssets:(NSArray*)assets withCompressionMode:(MXKRoomInputToolbarCompressionMode)compressionMode +{ + // Get data about the selected assets + if (assets.count) + { + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:updateActivityIndicator:)]) + { + [self.delegate roomInputToolbarView:self updateActivityIndicator:YES]; + } + + [self availableCompressionSizesForAssets:assets onComplete:^(NSArray*checkedAssets, MXKFileSizes fileSizes) { + + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:updateActivityIndicator:)]) + { + [self.delegate roomInputToolbarView:self updateActivityIndicator:NO]; + } + + if (checkedAssets.count) + { + [self sendSelectedAssets:checkedAssets withFileSizes:fileSizes andCompressionMode:compressionMode]; + } + + }]; + } +} + +- (void)sendSelectedAssets:(NSArray*)assets withFileSizes:(MXKFileSizes)fileSizes andCompressionMode:(MXKRoomInputToolbarCompressionMode)compressionMode +{ + if (compressionMode == MXKRoomInputToolbarCompressionModePrompt + && (fileSizes.small || fileSizes.medium || fileSizes.large)) + { + // Ask the user for the compression value + compressionPrompt = [UIAlertController alertControllerWithTitle:[MatrixKitL10n attachmentSizePromptTitle] + message:[MatrixKitL10n attachmentSizePromptMessage] + preferredStyle:UIAlertControllerStyleActionSheet]; + + __weak typeof(self) weakSelf = self; + + if (fileSizes.small) + { + NSString *title = [MatrixKitL10n attachmentSmall:[MXTools fileSizeToString:fileSizes.small]]; + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + [self dismissCompressionPrompt]; + + [self sendSelectedAssets:assets withFileSizes:fileSizes andCompressionMode:MXKRoomInputToolbarCompressionModeSmall]; + } + + }]]; + } + + if (fileSizes.medium) + { + NSString *title = [MatrixKitL10n attachmentMedium:[MXTools fileSizeToString:fileSizes.medium]]; + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + [self dismissCompressionPrompt]; + + [self sendSelectedAssets:assets withFileSizes:fileSizes andCompressionMode:MXKRoomInputToolbarCompressionModeMedium]; + } + + }]]; + } + + if (fileSizes.large) + { + NSString *title = [MatrixKitL10n attachmentLarge:[MXTools fileSizeToString:fileSizes.large]]; + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + [self dismissCompressionPrompt]; + + [self sendSelectedAssets:assets withFileSizes:fileSizes andCompressionMode:MXKRoomInputToolbarCompressionModeLarge]; + } + + }]]; + } + + NSString *title = [MatrixKitL10n attachmentOriginal:[MXTools fileSizeToString:fileSizes.original]]; + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + [self dismissCompressionPrompt]; + + [self sendSelectedAssets:assets withFileSizes:fileSizes andCompressionMode:MXKRoomInputToolbarCompressionModeNone]; + } + + }]]; + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleCancel + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + [self dismissCompressionPrompt]; + } + + }]]; + + [compressionPrompt popoverPresentationController].sourceView = self; + [compressionPrompt popoverPresentationController].sourceRect = self.bounds; + [self.delegate roomInputToolbarView:self presentAlertController:compressionPrompt]; + } + else + { + // Send all media with the selected compression mode + for (PHAsset *asset in assets) + { + if (asset.mediaType == PHAssetMediaTypeImage) + { + // Retrieve the full sized image data + PHImageRequestOptions *options = [[PHImageRequestOptions alloc] init]; + options.synchronous = NO; + options.networkAccessAllowed = YES; + + [[PHImageManager defaultManager] requestImageDataForAsset:asset options:options resultHandler:^(NSData * _Nullable imageData, NSString * _Nullable dataUTI, UIImageOrientation orientation, NSDictionary * _Nullable info) { + + if (imageData) + { + MXLogDebug(@"[MXKRoomInputToolbarView] sendSelectedAssets: Got image data"); + + CFStringRef uti = (__bridge CFStringRef)dataUTI; + NSString *mimeType = (__bridge_transfer NSString *) UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType); + + [self sendSelectedImage:imageData withMimeType:mimeType andCompressionMode:compressionMode isPhotoLibraryAsset:YES]; + } + else + { + MXLogDebug(@"[MXKRoomInputToolbarView] sendSelectedAssets: Failed to get image data"); + + // Notify user + NSError *error = info[@"PHImageErrorKey"]; + if (error.userInfo[NSUnderlyingErrorKey]) + { + error = error.userInfo[NSUnderlyingErrorKey]; + } + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + } + + }]; + } + else if (asset.mediaType == PHAssetMediaTypeVideo) + { + PHVideoRequestOptions *options = [[PHVideoRequestOptions alloc] init]; + options.networkAccessAllowed = YES; + + [[PHImageManager defaultManager] requestAVAssetForVideo:asset options:options resultHandler:^(AVAsset *asset, AVAudioMix *audioMix, NSDictionary *info) { + + if ([asset isKindOfClass:[AVURLAsset class]]) + { + MXLogDebug(@"[MXKRoomInputToolbarView] sendSelectedAssets: Got video data"); + AVURLAsset* urlAsset = (AVURLAsset*)asset; + + dispatch_async(dispatch_get_main_queue(), ^{ + + [self sendSelectedVideo:urlAsset.URL isPhotoLibraryAsset:YES]; + + }); + } + else + { + MXLogDebug(@"[MXKRoomInputToolbarView] sendSelectedAssets: Failed to get video data"); + + // Notify user + NSError *error = info[@"PHImageErrorKey"]; + if (error.userInfo[NSUnderlyingErrorKey]) + { + error = error.userInfo[NSUnderlyingErrorKey]; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + + }); + } + + }]; + } + } + } +} + +#pragma mark - UIImagePickerControllerDelegate + +- (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info +{ + [self dismissMediaPicker]; + + NSString *mediaType = [info objectForKey:UIImagePickerControllerMediaType]; + if ([mediaType isEqualToString:(NSString *)kUTTypeImage]) + { + UIImage *selectedImage = [info objectForKey:UIImagePickerControllerOriginalImage]; + if (selectedImage) + { + // Media picker does not offer a preview + // so add a preview to let the user validates his selection + if (picker.sourceType == UIImagePickerControllerSourceTypePhotoLibrary) + { + __weak typeof(self) weakSelf = self; + + MXKImageView *imageValidationView = [[MXKImageView alloc] initWithFrame:CGRectZero]; + imageValidationView.stretchable = YES; + + // the user validates the image + [imageValidationView setRightButtonTitle:[MatrixKitL10n ok] handler:^(MXKImageView* imageView, NSString* buttonTitle) + { + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Dismiss the image view + [self dismissValidationViews]; + + NSURL *imageLocalURL = [info objectForKey:UIImagePickerControllerReferenceURL]; + if (imageLocalURL) + { + CFStringRef uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, (__bridge CFStringRef)[imageLocalURL.path pathExtension] , NULL); + NSString *mimetype = (__bridge_transfer NSString *) UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType); + CFRelease(uti); + + NSData *imageData = [NSData dataWithContentsOfFile:imageLocalURL.path]; + + // attach the selected image + [self sendSelectedImage:imageData withMimeType:mimetype andCompressionMode:MXKRoomInputToolbarCompressionModePrompt isPhotoLibraryAsset:YES]; + } + } + + }]; + + // the user wants to use an other image + [imageValidationView setLeftButtonTitle:[MatrixKitL10n cancel] handler:^(MXKImageView* imageView, NSString* buttonTitle) + { + if (weakSelf) + { + typeof(self) self = weakSelf; + + // dismiss the image view + [self dismissValidationViews]; + + // Open again media gallery + self->mediaPicker = [[UIImagePickerController alloc] init]; + self->mediaPicker.delegate = self; + self->mediaPicker.sourceType = picker.sourceType; + self->mediaPicker.allowsEditing = NO; + self->mediaPicker.mediaTypes = picker.mediaTypes; + [self.delegate roomInputToolbarView:self presentViewController:self->mediaPicker]; + } + }]; + + imageValidationView.image = selectedImage; + + [validationViews addObject:imageValidationView]; + [imageValidationView showFullScreen]; + [self.delegate roomInputToolbarView:self hideStatusBar:YES]; + } + else + { + // Suggest compression before sending image + NSData *imageData = UIImageJPEGRepresentation(selectedImage, 0.9); + [self sendSelectedImage:imageData withMimeType:nil andCompressionMode:MXKRoomInputToolbarCompressionModePrompt isPhotoLibraryAsset:NO]; + } + } + } + else if ([mediaType isEqualToString:(NSString *)kUTTypeMovie]) + { + NSURL* selectedVideo = [info objectForKey:UIImagePickerControllerMediaURL]; + + [self sendSelectedVideo:selectedVideo isPhotoLibraryAsset:(picker.sourceType == UIImagePickerControllerSourceTypePhotoLibrary)]; + } +} + +- (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker +{ + [self dismissMediaPicker]; +} + +- (void)dismissValidationViews +{ + if (validationViews.count) + { + for (MXKImageView *validationView in validationViews) + { + [validationView dismissSelection]; + [validationView removeFromSuperview]; + } + + [validationViews removeAllObjects]; + + // Restore status bar + [self.delegate roomInputToolbarView:self hideStatusBar:NO]; + } +} + +- (void)dismissValidationView:(MXKImageView*)validationView +{ + [validationView dismissSelection]; + [validationView removeFromSuperview]; + + if (validationViews.count) + { + [validationViews removeObject:validationView]; + + if (!validationViews.count) + { + // Restore status bar + [self.delegate roomInputToolbarView:self hideStatusBar:NO]; + } + } +} + +- (void)dismissMediaPicker +{ + if (mediaPicker) + { + mediaPicker.delegate = nil; + + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:dismissViewControllerAnimated:completion:)]) + { + [self.delegate roomInputToolbarView:self dismissViewControllerAnimated:NO completion:^{ + self->mediaPicker = nil; + }]; + } + } +} + +#pragma mark - Clipboard - Handle image/data paste from general pasteboard + +- (void)paste:(id)sender +{ + UIPasteboard *pasteboard = MXKPasteboardManager.shared.pasteboard; + if (pasteboard.numberOfItems) + { + [self dismissValidationViews]; + [self dismissKeyboard]; + + __weak typeof(self) weakSelf = self; + + for (NSDictionary* dict in pasteboard.items) + { + NSArray* allKeys = dict.allKeys; + for (NSString* key in allKeys) + { + NSString* MIMEType = (__bridge_transfer NSString *) UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)key, kUTTagClassMIMEType); + if ([MIMEType hasPrefix:@"image/"] && [self.delegate respondsToSelector:@selector(roomInputToolbarView:sendImage:)]) + { + UIImage *pasteboardImage; + if ([[dict objectForKey:key] isKindOfClass:UIImage.class]) + { + pasteboardImage = [dict objectForKey:key]; + } + // WebP images from Safari appear on the pasteboard as NSData rather than UIImages. + else if ([[dict objectForKey:key] isKindOfClass:NSData.class]) + { + pasteboardImage = [UIImage imageWithData:[dict objectForKey:key]]; + } + else { + MXLogError(@"[MXKRoomInputToolbarView] Unsupported image format %@ for mimetype %@ pasted.", MIMEType, NSStringFromClass([[dict objectForKey:key] class])); + } + + if (pasteboardImage) + { + MXKImageView *imageValidationView = [[MXKImageView alloc] initWithFrame:CGRectZero]; + imageValidationView.stretchable = YES; + + // the user validates the image + [imageValidationView setRightButtonTitle:[MatrixKitL10n ok] handler:^(MXKImageView* imageView, NSString* buttonTitle) + { + if (weakSelf) + { + typeof(self) self = weakSelf; + [self dismissValidationView:imageView]; + [self.delegate roomInputToolbarView:self sendImage:pasteboardImage]; + } + }]; + + // the user wants to use an other image + [imageValidationView setLeftButtonTitle:[MatrixKitL10n cancel] handler:^(MXKImageView* imageView, NSString* buttonTitle) + { + // Dismiss the image validation view. + if (weakSelf) + { + typeof(self) self = weakSelf; + [self dismissValidationView:imageView]; + } + }]; + + imageValidationView.image = pasteboardImage; + + [validationViews addObject:imageValidationView]; + [imageValidationView showFullScreen]; + [self.delegate roomInputToolbarView:self hideStatusBar:YES]; + } + + break; + } + else if ([MIMEType hasPrefix:@"video/"] && [self.delegate respondsToSelector:@selector(roomInputToolbarView:sendVideo:withThumbnail:)]) + { + NSData *pasteboardVideoData = [dict objectForKey:key]; + // Get a unique cache path to store this video + NSString *cacheFilePath = [MXMediaManager temporaryCachePathInFolder:nil withType:MIMEType]; + + if ([MXMediaManager writeMediaData:pasteboardVideoData toFilePath:cacheFilePath]) + { + NSURL *videoLocalURL = [NSURL fileURLWithPath:cacheFilePath isDirectory:NO]; + + // Retrieve the video frame at 1 sec to define the video thumbnail + AVURLAsset *urlAsset = [[AVURLAsset alloc] initWithURL:videoLocalURL options:nil]; + AVAssetImageGenerator *assetImageGenerator = [AVAssetImageGenerator assetImageGeneratorWithAsset:urlAsset]; + assetImageGenerator.appliesPreferredTrackTransform = YES; + CMTime time = CMTimeMake(1, 1); + CGImageRef imageRef = [assetImageGenerator copyCGImageAtTime:time actualTime:NULL error:nil]; + UIImage* videoThumbnail = [[UIImage alloc] initWithCGImage:imageRef]; + CFRelease (imageRef); + + MXKImageView *videoValidationView = [[MXKImageView alloc] initWithFrame:CGRectZero]; + videoValidationView.stretchable = YES; + + // the user validates the image + [videoValidationView setRightButtonTitle:[MatrixKitL10n ok] handler:^(MXKImageView* imageView, NSString* buttonTitle) + { + if (weakSelf) + { + typeof(self) self = weakSelf; + [self dismissValidationView:imageView]; + + [self.delegate roomInputToolbarView:self sendVideo:videoLocalURL withThumbnail:videoThumbnail]; + } + }]; + + // the user wants to use an other image + [videoValidationView setLeftButtonTitle:[MatrixKitL10n cancel] handler:^(MXKImageView* imageView, NSString* buttonTitle) + { + // Dismiss the video validation view. + if (weakSelf) + { + typeof(self) self = weakSelf; + [self dismissValidationView:imageView]; + } + }]; + + videoValidationView.image = videoThumbnail; + + [validationViews addObject:videoValidationView]; + [videoValidationView showFullScreen]; + [self.delegate roomInputToolbarView:self hideStatusBar:YES]; + + // Add video icon + UIImageView *videoIconView = [[UIImageView alloc] initWithImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_video"]]; + videoIconView.center = videoValidationView.center; + videoIconView.autoresizingMask = UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleBottomMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin; + [videoValidationView addSubview:videoIconView]; + } + break; + } + else if ([MIMEType hasPrefix:@"application/"] && [self.delegate respondsToSelector:@selector(roomInputToolbarView:sendFile:withMimeType:)]) + { + NSData *pasteboardDocumentData = [dict objectForKey:key]; + // Get a unique cache path to store this data + NSString *cacheFilePath = [MXMediaManager temporaryCachePathInFolder:nil withType:MIMEType]; + + if ([MXMediaManager writeMediaData:pasteboardDocumentData toFilePath:cacheFilePath]) + { + NSURL *localURL = [NSURL fileURLWithPath:cacheFilePath isDirectory:NO]; + + MXKImageView *docValidationView = [[MXKImageView alloc] initWithFrame:CGRectZero]; + docValidationView.stretchable = YES; + + // the user validates the image + [docValidationView setRightButtonTitle:[MatrixKitL10n ok] handler:^(MXKImageView* imageView, NSString* buttonTitle) + { + if (weakSelf) + { + typeof(self) self = weakSelf; + [self dismissValidationView:imageView]; + + [self.delegate roomInputToolbarView:self sendFile:localURL withMimeType:MIMEType]; + } + }]; + + // the user wants to use an other image + [docValidationView setLeftButtonTitle:[MatrixKitL10n cancel] handler:^(MXKImageView* imageView, NSString* buttonTitle) + { + // Dismiss the validation view. + if (weakSelf) + { + typeof(self) self = weakSelf; + [self dismissValidationView:imageView]; + } + }]; + + docValidationView.image = nil; + + [validationViews addObject:docValidationView]; + [docValidationView showFullScreen]; + [self.delegate roomInputToolbarView:self hideStatusBar:YES]; + + // Create a fake name based on fileData to keep the same name for the same file. + NSString *dataHash = [pasteboardDocumentData mx_MD5]; + if (dataHash.length > 7) + { + // Crop + dataHash = [dataHash substringToIndex:7]; + } + NSString *extension = [MXTools fileExtensionFromContentType:MIMEType]; + NSString *filename = [NSString stringWithFormat:@"file_%@%@", dataHash, extension]; + + // Display this file name + UITextView *fileNameTextView = [[UITextView alloc] initWithFrame:CGRectZero]; + fileNameTextView.text = filename; + fileNameTextView.font = [UIFont systemFontOfSize:17]; + [fileNameTextView sizeToFit]; + fileNameTextView.center = docValidationView.center; + fileNameTextView.autoresizingMask = UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleBottomMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin; + + docValidationView.backgroundColor = [UIColor whiteColor]; + [docValidationView addSubview:fileNameTextView]; + } + break; + } + } + } + } +} + +- (BOOL)canPerformAction:(SEL)action withSender:(id)sender +{ + if (action == @selector(paste:) && MXKAppSettings.standardAppSettings.messageDetailsAllowPastingMedia) + { + // Check whether some data listed in general pasteboard can be paste + UIPasteboard *pasteboard = MXKPasteboardManager.shared.pasteboard; + if (pasteboard.numberOfItems) + { + for (NSDictionary* dict in pasteboard.items) + { + NSArray* allKeys = dict.allKeys; + for (NSString* key in allKeys) + { + NSString* MIMEType = (__bridge_transfer NSString *) UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)key, kUTTagClassMIMEType); + + if ([MIMEType hasPrefix:@"image/"] && [self.delegate respondsToSelector:@selector(roomInputToolbarView:sendImage:)]) + { + return YES; + } + + if ([MIMEType hasPrefix:@"video/"] && [self.delegate respondsToSelector:@selector(roomInputToolbarView:sendVideo:withThumbnail:)]) + { + return YES; + } + + if ([MIMEType hasPrefix:@"application/"] && [self.delegate respondsToSelector:@selector(roomInputToolbarView:sendFile:withMimeType:)]) + { + return YES; + } + } + } + } + } + return NO; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.xib b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.xib new file mode 100644 index 000000000..a4821a346 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.xib @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.h b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.h new file mode 100644 index 000000000..b10b54fa6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.h @@ -0,0 +1,33 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomInputToolbarView.h" + +#import + +/** + `MXKRoomInputToolbarViewWithHPGrowingText` is a MXKRoomInputToolbarView-inherited class in which message + composer is based on `HPGrowingTextView`. + + Toolbar buttons are not overridden by this class. We keep the default implementation. + */ +@interface MXKRoomInputToolbarViewWithHPGrowingText : MXKRoomInputToolbarView +{ +@protected + HPGrowingTextView *growingTextView; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.m b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.m new file mode 100644 index 000000000..fe5f87ff7 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.m @@ -0,0 +1,187 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomInputToolbarViewWithHPGrowingText.h" + +@interface MXKRoomInputToolbarViewWithHPGrowingText() +{ + // HPGrowingTextView triggers growingTextViewDidChange event when it recomposes itself + // Save the last edited text to prevent unexpected typing events + NSString* lastEditedText; +} + +/** + Message composer defined in `messageComposerContainer`. + */ +@property (nonatomic) IBOutlet HPGrowingTextView *growingTextView; + +@end + +@implementation MXKRoomInputToolbarViewWithHPGrowingText +@synthesize growingTextView; + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRoomInputToolbarViewWithHPGrowingText class]) + bundle:[NSBundle bundleForClass:[MXKRoomInputToolbarViewWithHPGrowingText class]]]; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + // Handle message composer based on HPGrowingTextView use + growingTextView.delegate = self; + + [growingTextView setTranslatesAutoresizingMaskIntoConstraints: NO]; + + // Add an accessory view to the text view in order to retrieve keyboard view. + inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; + growingTextView.internalTextView.inputAccessoryView = self.inputAccessoryView; + + // on IOS 8, the growing textview animation could trigger weird UI animations + // indeed, the messages tableView can be refreshed while its height is updated (e.g. when setting a message) + growingTextView.animateHeightChange = NO; + + lastEditedText = nil; +} + +- (void)dealloc +{ + [self destroy]; +} + +-(void)customizeViewRendering +{ + [super customizeViewRendering]; + + // set text input font + growingTextView.font = [UIFont systemFontOfSize:14]; + + // draw a rounded border around the textView + growingTextView.layer.cornerRadius = 5; + growingTextView.layer.borderWidth = 1; + growingTextView.layer.borderColor = [UIColor lightGrayColor].CGColor; + growingTextView.clipsToBounds = YES; + growingTextView.backgroundColor = [UIColor whiteColor]; +} + +- (void)destroy +{ + if (growingTextView) + { + growingTextView.delegate = nil; + growingTextView = nil; + } + + [super destroy]; +} + +- (void)setMaxHeight:(CGFloat)maxHeight +{ + growingTextView.maxHeight = maxHeight - (self.messageComposerContainerTopConstraint.constant + self.messageComposerContainerBottomConstraint.constant); + [growingTextView refreshHeight]; + + super.maxHeight = maxHeight; +} + +- (NSString*)textMessage +{ + return growingTextView.text; +} + +- (void)setTextMessage:(NSString *)textMessage +{ + growingTextView.text = textMessage; + self.rightInputToolbarButton.enabled = textMessage.length; +} + +- (void)pasteText:(NSString *)text +{ + self.textMessage = [growingTextView.text stringByReplacingCharactersInRange:growingTextView.selectedRange withString:text]; +} + +- (void)setPlaceholder:(NSString *)inPlaceholder +{ + [super setPlaceholder:inPlaceholder]; + growingTextView.placeholder = inPlaceholder; +} + +- (BOOL)becomeFirstResponder +{ + return [growingTextView becomeFirstResponder]; +} + +- (void)dismissKeyboard +{ + [growingTextView resignFirstResponder]; +} + +#pragma mark - HPGrowingTextView delegate + +- (void)growingTextViewDidEndEditing:(HPGrowingTextView *)sender +{ + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:isTyping:)]) + { + [self.delegate roomInputToolbarView:self isTyping:NO]; + } +} + +- (void)growingTextViewDidChange:(HPGrowingTextView *)sender +{ + NSString *msg = growingTextView.text; + + // HPGrowingTextView triggers growingTextViewDidChange event when it recomposes itself. + // Save the last edited text to prevent unexpected typing events + if (![lastEditedText isEqualToString:msg]) + { + lastEditedText = msg; + if (msg.length) + { + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:isTyping:)]) + { + [self.delegate roomInputToolbarView:self isTyping:YES]; + } + self.rightInputToolbarButton.enabled = YES; + } + else + { + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:isTyping:)]) + { + [self.delegate roomInputToolbarView:self isTyping:NO]; + } + self.rightInputToolbarButton.enabled = NO; + } + } +} + +- (void)growingTextView:(HPGrowingTextView *)growingTextView willChangeHeight:(float)height +{ + // Update growing text's superview (toolbar view) + CGFloat updatedHeight = height + (self.messageComposerContainerTopConstraint.constant + self.messageComposerContainerBottomConstraint.constant); + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:heightDidChanged:completion:)]) + { + [self.delegate roomInputToolbarView:self heightDidChanged:updatedHeight completion:nil]; + } +} + +- (BOOL)growingTextView:(HPGrowingTextView *)growingTextView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text +{ + return self.isEditable; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.xib b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.xib new file mode 100644 index 000000000..3f4117499 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.xib @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.h b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.h new file mode 100644 index 000000000..22299422f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.h @@ -0,0 +1,32 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomInputToolbarView.h" + +/** + `MXKRoomInputToolbarViewWithSimpleTextView` is a MXKRoomInputToolbarView-inherited class in which message + composer is a UITextView instance with a fixed heigth. + + Toolbar buttons are not overridden by this class. We keep the default implementation. + */ +@interface MXKRoomInputToolbarViewWithSimpleTextView : MXKRoomInputToolbarView + +/** + Message composer defined in `messageComposerContainer`. + */ +@property (weak, nonatomic) IBOutlet UITextView *messageComposerTextView; + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.m b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.m new file mode 100644 index 000000000..3cdb38eda --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.m @@ -0,0 +1,123 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomInputToolbarViewWithSimpleTextView.h" + +@implementation MXKRoomInputToolbarViewWithSimpleTextView + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRoomInputToolbarViewWithSimpleTextView class]) + bundle:[NSBundle bundleForClass:[MXKRoomInputToolbarViewWithSimpleTextView class]]]; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + // Add an accessory view to the text view in order to retrieve keyboard view. + inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; + self.messageComposerTextView.inputAccessoryView = self.inputAccessoryView; +} + +-(void)customizeViewRendering +{ + [super customizeViewRendering]; + + // Set default message composer background color + self.messageComposerTextView.backgroundColor = [UIColor whiteColor]; +} + +- (NSString*)textMessage +{ + return _messageComposerTextView.text; +} + +- (void)setTextMessage:(NSString *)textMessage +{ + _messageComposerTextView.text = textMessage; + self.rightInputToolbarButton.enabled = textMessage.length; +} + +- (void)pasteText:(NSString *)text +{ + self.textMessage = [_messageComposerTextView.text stringByReplacingCharactersInRange:_messageComposerTextView.selectedRange withString:text]; +} + +- (BOOL)becomeFirstResponder +{ + return [_messageComposerTextView becomeFirstResponder]; +} + +- (void)dismissKeyboard +{ + if (_messageComposerTextView) + { + [_messageComposerTextView resignFirstResponder]; + } +} + +#pragma mark - UITextViewDelegate + +- (void)textViewDidEndEditing:(UITextView *)textView +{ + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:isTyping:)]) + { + [self.delegate roomInputToolbarView:self isTyping:NO]; + } +} + +- (void)textViewDidChange:(UITextView *)textView +{ + NSString *msg = textView.text; + + if (msg.length) + { + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:isTyping:)]) + { + [self.delegate roomInputToolbarView:self isTyping:YES]; + } + self.rightInputToolbarButton.enabled = YES; + } + else + { + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:isTyping:)]) + { + [self.delegate roomInputToolbarView:self isTyping:NO]; + } + self.rightInputToolbarButton.enabled = NO; + } +} + +- (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text +{ + if (!self.isEditable) + { + return NO; + } + + // Hanlde here `Done` key pressed + if([text isEqualToString:@"\n"]) + { + [textView resignFirstResponder]; + return NO; + } + + return YES; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.xib b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.xib new file mode 100644 index 000000000..d01e943be --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.xib @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.h b/Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.h new file mode 100644 index 000000000..b6dc414fd --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.h @@ -0,0 +1,28 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKRecentTableViewCell.h" + +/** + `MXKInterleavedRecentTableViewCell` instances display a room in the context of the recents list. + */ +@interface MXKInterleavedRecentTableViewCell : MXKRecentTableViewCell + +@property (weak, nonatomic) IBOutlet UIView* userFlag; + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.m b/Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.m new file mode 100644 index 000000000..a4b17d16e --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.m @@ -0,0 +1,64 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKInterleavedRecentTableViewCell.h" + +#import "MXKSessionRecentsDataSource.h" + +#import "MXKAccountManager.h" + +@implementation MXKInterleavedRecentTableViewCell + +#pragma mark - Class methods + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + CAShapeLayer *userFlagMaskLayer = [[CAShapeLayer alloc] init]; + userFlagMaskLayer.frame = _userFlag.bounds; + + UIBezierPath *path = [[UIBezierPath alloc] init]; + [path moveToPoint:CGPointMake(0, 0)]; + [path addLineToPoint:CGPointMake(_userFlag.frame.size.width, _userFlag.frame.size.height)]; + [path addLineToPoint:CGPointMake(_userFlag.frame.size.width, 0)]; + [path closePath]; + + userFlagMaskLayer.path = path.CGPath; + _userFlag.layer.mask = userFlagMaskLayer; +} + +- (void)render:(MXKCellData *)cellData +{ + [super render:cellData]; + + // Highlight the room owner by using his tint color. + if (roomCellData) + { + MXKAccount *account = [[MXKAccountManager sharedManager] accountForUserId:roomCellData.mxSession.myUserId]; + if (account) + { + _userFlag.backgroundColor = account.userTintColor; + } + else + { + _userFlag.backgroundColor = [UIColor clearColor]; + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.xib b/Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.xib new file mode 100644 index 000000000..51e427937 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.xib @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.h b/Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.h new file mode 100644 index 000000000..c868bffd5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.h @@ -0,0 +1,36 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKTableViewCell.h" + +@interface MXKPublicRoomTableViewCell : MXKTableViewCell + +@property (weak, nonatomic) IBOutlet UILabel *roomDisplayName; +@property (weak, nonatomic) IBOutlet UILabel *memberCount; +@property (weak, nonatomic) IBOutlet UILabel *roomTopic; + +@property (nonatomic, getter=isHighlightedPublicRoom) BOOL highlightedPublicRoom; + +/** + Configure the cell in order to display the public room. + + @param publicRoom the public room to render. + */ +- (void)render:(MXPublicRoom*)publicRoom; + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.m b/Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.m new file mode 100644 index 000000000..d6321f100 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.m @@ -0,0 +1,75 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKPublicRoomTableViewCell.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@implementation MXKPublicRoomTableViewCell + +- (void)render:(MXPublicRoom*)publicRoom +{ + // Check whether this public room has topic + if (publicRoom.topic) + { + _roomTopic.hidden = NO; + _roomTopic.text = [MXTools stripNewlineCharacters:publicRoom.topic]; + } + else + { + _roomTopic.hidden = YES; + } + + // Set room display name + _roomDisplayName.text = [publicRoom displayname]; + + // Set member count + if (publicRoom.numJoinedMembers > 1) + { + _memberCount.text = [MatrixKitL10n numMembersOther:@(publicRoom.numJoinedMembers).stringValue]; + } + else if (publicRoom.numJoinedMembers == 1) + { + _memberCount.text = [MatrixKitL10n numMembersOne:@(1).stringValue]; + } + else + { + _memberCount.text = nil; + } +} + +- (void)setHighlightedPublicRoom:(BOOL)highlightedPublicRoom +{ + // Highlight? + if (highlightedPublicRoom) + { + _roomDisplayName.font = [UIFont boldSystemFontOfSize:20]; + _roomTopic.font = [UIFont boldSystemFontOfSize:17]; + self.backgroundColor = [UIColor colorWithRed:1.0 green:1.0 blue:0.9 alpha:1.0]; + } + else + { + _roomDisplayName.font = [UIFont systemFontOfSize:19]; + _roomTopic.font = [UIFont systemFontOfSize:16]; + self.backgroundColor = [UIColor clearColor]; + } + _highlightedPublicRoom = highlightedPublicRoom; +} + +@end + diff --git a/Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.xib b/Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.xib new file mode 100644 index 000000000..9577dfaa8 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.xib @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.h b/Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.h new file mode 100644 index 000000000..98cd4f962 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.h @@ -0,0 +1,39 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +#import "MXKCellRendering.h" + +#import "MXKRecentCellDataStoring.h" + +/** + `MXKRecentTableViewCell` instances display a room in the context of the recents list. + */ +@interface MXKRecentTableViewCell : MXKTableViewCell +{ +@protected + /** + The current cell data displayed by the table view cell + */ + id roomCellData; +} + +@property (weak, nonatomic) IBOutlet UILabel *roomTitle; +@property (weak, nonatomic) IBOutlet UILabel *lastEventDescription; +@property (weak, nonatomic) IBOutlet UILabel *lastEventDate; + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.m b/Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.m new file mode 100644 index 000000000..3fdce7f75 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.m @@ -0,0 +1,99 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRecentTableViewCell.h" + +#import "MXKSessionRecentsDataSource.h" + +@implementation MXKRecentTableViewCell +@synthesize delegate; + +#pragma mark - Class methods + +- (void)render:(MXKCellData *)cellData +{ + roomCellData = (id)cellData; + if (roomCellData) + { + + // Report computed values as is + _roomTitle.text = roomCellData.roomDisplayname; + _lastEventDate.text = roomCellData.lastEventDate; + + // Manage lastEventAttributedTextMessage optional property + if ([roomCellData respondsToSelector:@selector(lastEventAttributedTextMessage)]) + { + _lastEventDescription.attributedText = roomCellData.lastEventAttributedTextMessage; + } + else + { + _lastEventDescription.text = roomCellData.lastEventTextMessage; + } + + // Set in bold public room name + if ([roomCellData.roomSummary.joinRule isEqualToString:kMXRoomJoinRulePublic]) + { + _roomTitle.font = [UIFont boldSystemFontOfSize:20]; + } + else + { + _roomTitle.font = [UIFont systemFontOfSize:19]; + } + + // Set background color and unread count + if (roomCellData.hasUnread) + { + if (0 < roomCellData.highlightCount) + { + self.backgroundColor = [UIColor colorWithRed:0.9 green:0.9 blue:1 alpha:1.0]; + } + else + { + self.backgroundColor = [UIColor colorWithRed:1 green:0.9 blue:0.9 alpha:1.0]; + } + } + else + { + self.backgroundColor = [UIColor clearColor]; + } + + } + else + { + _lastEventDescription.text = @""; + } +} + +- (MXKCellData*)renderedCellData +{ + return roomCellData; +} + ++ (CGFloat)heightForCellData:(MXKCellData *)cellData withMaximumWidth:(CGFloat)maxWidth +{ + // The height is fixed + return 70; +} + +- (void)prepareForReuse +{ + [super prepareForReuse]; + + roomCellData = nil; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.xib b/Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.xib new file mode 100644 index 000000000..9d3dd58c5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.xib @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.h b/Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.h new file mode 100644 index 000000000..ce99f5076 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.h @@ -0,0 +1,66 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" +#import "MXKCellRendering.h" + +@class MXKImageView; +@class MXKPieChartView; +@class MXSession; + +/** + `MXKRoomMemberTableViewCell` instances display a user in the context of the room member list. + */ +@interface MXKRoomMemberTableViewCell : MXKTableViewCell { + +@protected + /** + */ + MXSession *mxSession; + + /** + */ + NSString *memberId; + + /** + YES when last activity time is displayed and must be refreshed regularly. + */ + BOOL shouldUpdateActivityInfo; +} + +@property (strong, nonatomic) IBOutlet MXKImageView *pictureView; +@property (weak, nonatomic) IBOutlet UILabel *userLabel; +@property (weak, nonatomic) IBOutlet UIView *powerContainer; +@property (weak, nonatomic) IBOutlet UIImageView *typingBadge; + +/** + The default picture displayed when no picture is available. + */ +@property (nonatomic) UIImage *picturePlaceholder; + +/** + Update last activity information if any. + */ +- (void)updateActivityInfo; + +/** + Stringify the last activity date/time of the member. + + @return a string which described the last activity time of the member. + */ +- (NSString*)lastActiveTime; + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.m b/Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.m new file mode 100644 index 000000000..d312f27c1 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.m @@ -0,0 +1,299 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomMemberTableViewCell.h" + +@import MatrixSDK; + +#import "MXKAccount.h" +#import "MXKImageView.h" +#import "MXKPieChartView.h" +#import "MXKRoomMemberCellDataStoring.h" +#import "MXKRoomMemberListDataSource.h" +#import "MXKTools.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@interface MXKRoomMemberTableViewCell () +{ + NSRange lastSeenRange; + + MXKPieChartView* pieChartView; +} + +@end + +@implementation MXKRoomMemberTableViewCell + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + self.typingBadge.image = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_keyboard"]; +} + +- (void)customizeTableViewCellRendering +{ + [super customizeTableViewCellRendering]; + + self.pictureView.defaultBackgroundColor = [UIColor clearColor]; +} + +- (UIImage*)picturePlaceholder +{ + return [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"default-profile"]; +} + +- (void)render:(MXKCellData *)cellData +{ + // Sanity check: accept only object of MXKRoomMemberCellData classes or sub-classes + NSParameterAssert([cellData isKindOfClass:[MXKRoomMemberCellData class]]); + + MXKRoomMemberCellData *memberCellData = (MXKRoomMemberCellData*)cellData; + if (memberCellData) + { + mxSession = memberCellData.mxSession; + memberId = memberCellData.roomMember.userId; + + self.userLabel.text = memberCellData.memberDisplayName; + + // Disable by default activity update mechanism (This is required in case of a reused cell). + shouldUpdateActivityInfo = NO; + + // User thumbnail + self.pictureView.mediaFolder = kMXMediaManagerAvatarThumbnailFolder; + self.pictureView.enableInMemoryCache = YES; + // Consider here the member avatar is stored unencrypted on Matrix media repo + [self.pictureView setImageURI:memberCellData.roomMember.avatarUrl + withType:nil + andImageOrientation:UIImageOrientationUp + toFitViewSize:self.pictureView.frame.size + withMethod:MXThumbnailingMethodCrop + previewImage:self.picturePlaceholder + mediaManager:mxSession.mediaManager]; + + // Shade invited users + if (memberCellData.roomMember.membership == MXMembershipInvite) + { + for (UIView *view in self.subviews) + { + view.alpha = 0.3; + } + } + else + { + for (UIView *view in self.subviews) + { + view.alpha = 1; + } + } + + // Display the power level pie + [self setPowerContainerValue:memberCellData.powerLevel]; + + // Prepare presence string and thumbnail border color + NSString* presenceText = nil; + UIColor* thumbnailBorderColor = nil; + + // Customize banned and left (kicked) members + if (memberCellData.roomMember.membership == MXMembershipLeave || memberCellData.roomMember.membership == MXMembershipBan) + { + self.backgroundColor = [UIColor colorWithRed:0.8 green:0.8 blue:0.8 alpha:1.0]; + presenceText = (memberCellData.roomMember.membership == MXMembershipLeave) ? [MatrixKitL10n membershipLeave] : [MatrixKitL10n membershipBan]; + } + else + { + self.backgroundColor = [UIColor whiteColor]; + + // get the user presence and his thumbnail border color + if (memberCellData.roomMember.membership == MXMembershipInvite) + { + thumbnailBorderColor = [UIColor lightGrayColor]; + presenceText = [MatrixKitL10n membershipInvite]; + } + else + { + // Get the user that corresponds to this member + MXUser *user = [mxSession userWithUserId:memberId]; + // existing user ? + if (user) + { + thumbnailBorderColor = [MXKAccount presenceColor:user.presence]; + presenceText = [self lastActiveTime]; + // Keep last seen range to update it + lastSeenRange = NSMakeRange(self.userLabel.text.length + 2, presenceText.length); + shouldUpdateActivityInfo = (presenceText.length != 0); + } + } + } + + // if the thumbnail is defined + if (thumbnailBorderColor) + { + self.pictureView.layer.borderWidth = 2; + self.pictureView.layer.borderColor = thumbnailBorderColor.CGColor; + } + else + { + // remove the border + // else it draws black border + self.pictureView.layer.borderWidth = 0; + } + + // and the presence text (if any) + if (presenceText) + { + NSString* extraText = [NSString stringWithFormat:@"(%@)", presenceText]; + self.userLabel.text = [NSString stringWithFormat:@"%@ %@", self.userLabel.text, extraText]; + + NSRange range = [self.userLabel.text rangeOfString:extraText]; + UIFont* font = self.userLabel.font; + + // Create the attributes + NSDictionary *attrs = [NSDictionary dictionaryWithObjectsAndKeys: + font, NSFontAttributeName, + self.userLabel.textColor, NSForegroundColorAttributeName, nil]; + + NSDictionary *subAttrs = [NSDictionary dictionaryWithObjectsAndKeys: + font, NSFontAttributeName, + [UIColor lightGrayColor], NSForegroundColorAttributeName, nil]; + + // Create the attributed string (text + attributes) + NSMutableAttributedString *attributedText =[[NSMutableAttributedString alloc] initWithString:self.userLabel.text attributes:attrs]; + [attributedText setAttributes:subAttrs range:range]; + + // Set it in our UILabel and we are done! + [self.userLabel setAttributedText:attributedText]; + } + + // Set typing badge visibility + if (memberCellData.isTyping) + { + self.typingBadge.hidden = NO; + [self.typingBadge.superview bringSubviewToFront:self.typingBadge]; + } + else + { + self.typingBadge.hidden = YES; + } + } +} + ++ (CGFloat)heightForCellData:(MXKCellData *)cellData withMaximumWidth:(CGFloat)maxWidth +{ + // The height is fixed + return 50; +} + +- (NSString*)lastActiveTime +{ + NSString* lastActiveTime = nil; + + // Get the user that corresponds to this member + MXUser *user = [mxSession userWithUserId:memberId]; + if (user) + { + // Prepare last active ago string + lastActiveTime = [MXKTools formatSecondsIntervalFloored:(user.lastActiveAgo / 1000)]; + + // Check presence + switch (user.presence) + { + case MXPresenceOffline: + { + lastActiveTime = [MatrixKitL10n offline]; + break; + } + case MXPresenceUnknown: + { + lastActiveTime = nil; + break; + } + case MXPresenceOnline: + case MXPresenceUnavailable: + default: + break; + } + + } + + return lastActiveTime; +} + +- (void)setPowerContainerValue:(CGFloat)progress +{ + // no power level -> hide the pie + if (0 == progress) + { + self.powerContainer.hidden = YES; + return; + } + + // display it + self.powerContainer.hidden = NO; + self.powerContainer.backgroundColor = [UIColor clearColor]; + + if (!pieChartView) + { + pieChartView = [[MXKPieChartView alloc] initWithFrame:self.powerContainer.bounds]; + [self.powerContainer addSubview:pieChartView]; + } + + pieChartView.progress = progress; +} + +- (void)updateActivityInfo +{ + // Check whether update is required. + if (shouldUpdateActivityInfo) + { + NSString *lastSeen = [self lastActiveTime]; + NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithAttributedString:self.userLabel.attributedText]; + if (lastSeen.length) + { + [attributedText replaceCharactersInRange:lastSeenRange withString:lastSeen]; + + // Update last seen range + lastSeenRange.length = lastSeen.length; + } + else + { + // remove presence info + lastSeenRange.location -= 1; + lastSeenRange.length += 2; + [attributedText deleteCharactersInRange:lastSeenRange]; + + shouldUpdateActivityInfo = NO; + } + + [self.userLabel setAttributedText:attributedText]; + } +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + // Round image view + [_pictureView.layer setCornerRadius:_pictureView.frame.size.width / 2]; + _pictureView.clipsToBounds = YES; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.xib b/Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.xib new file mode 100644 index 000000000..1f0178c9a --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.xib @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.h b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.h new file mode 100644 index 000000000..6b3b3f8de --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.h @@ -0,0 +1,120 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKView.h" + +@class MXKRoomTitleView; +@protocol MXKRoomTitleViewDelegate + +/** + Tells the delegate that an alert must be presented. + + @param titleView the room title view. + @param alertController the alert to present. + */ +- (void)roomTitleView:(MXKRoomTitleView*)titleView presentAlertController:(UIAlertController*)alertController; + +/** + Asks the delegate if editing should begin + + @param titleView the room title view. + @return YES if an editing session should be initiated; otherwise, NO to disallow editing. + */ +- (BOOL)roomTitleViewShouldBeginEditing:(MXKRoomTitleView*)titleView; + +@optional + +/** + Tells the delegate that the saving of user's changes is in progress or is finished. + + @param titleView the room title view. + @param saving YES if a request is running to save user's changes. + */ +- (void)roomTitleView:(MXKRoomTitleView*)titleView isSaving:(BOOL)saving; + +@end + +/** + 'MXKRoomTitleView' instance displays editable room display name. + */ +@interface MXKRoomTitleView : MXKView +{ +@protected + /** + Potential alert. + */ + UIAlertController *currentAlert; + + /** + Test fields input accessory. + */ + UIView *inputAccessoryView; +} + +@property (weak, nonatomic) IBOutlet UITextField *displayNameTextField; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *displayNameTextFieldTopConstraint; + +@property (strong, nonatomic) MXRoom *mxRoom; +@property (nonatomic) BOOL editable; +@property (nonatomic) BOOL isEditing; + +/** + * Returns the `UINib` object initialized for the room title view. + * + * @return The initialized `UINib` object or `nil` if there were errors during + * initialization or the nib file could not be located. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKRoomTitleView-inherited` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKRoomTitleView-inherited` object if successful, `nil` otherwise. + */ ++ (instancetype)roomTitleView; + +/** + The delegate notified when inputs are ready. + */ +@property (weak, nonatomic) id delegate; + +/** + The custom accessory view associated to all text field of this 'MXKRoomTitleView' instance. + This view is actually used to retrieve the keyboard view. Indeed the keyboard view is the superview of + this accessory view when a text field become the first responder. + */ +@property (readonly) UIView *inputAccessoryView; + +/** + Dismiss keyboard. + */ +- (void)dismissKeyboard; + +/** + Force title view refresh. + */ +- (void)refreshDisplay; + +/** + Dispose view resources and listener. + */ +- (void)destroy; + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.m b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.m new file mode 100644 index 000000000..5ea74fc37 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.m @@ -0,0 +1,279 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomTitleView.h" + +#import "MXKConstants.h" + +#import "NSBundle+MatrixKit.h" +#import "MXRoom+Sync.h" + +#import "MXKSwiftHeader.h" + +@interface MXKRoomTitleView () +{ + // Observer kMXRoomSummaryDidChangeNotification to keep updated the room name. + __weak id mxRoomSummaryDidChangeObserver; +} +@end + +@implementation MXKRoomTitleView +@synthesize inputAccessoryView; + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRoomTitleView class]) + bundle:[NSBundle bundleForClass:[MXKRoomTitleView class]]]; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + [self setTranslatesAutoresizingMaskIntoConstraints: NO]; + + // Add an accessory view to the text view in order to retrieve keyboard view. + inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; + self.displayNameTextField.inputAccessoryView = inputAccessoryView; + + self.displayNameTextField.enabled = NO; + self.displayNameTextField.returnKeyType = UIReturnKeyDone; + self.displayNameTextField.hidden = YES; +} + ++ (instancetype)roomTitleView +{ + return [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; +} + +- (void)dealloc +{ + inputAccessoryView = nil; +} + +#pragma mark - Override MXKView + +-(void)customizeViewRendering +{ + [super customizeViewRendering]; + + self.autoresizingMask = UIViewAutoresizingFlexibleWidth; +} + +#pragma mark - + +- (void)refreshDisplay +{ + if (_mxRoom) + { + // Replace empty string by nil : avoid having the placeholder 'Room name" when there is no displayname + self.displayNameTextField.text = (_mxRoom.summary.displayname.length) ? _mxRoom.summary.displayname : nil; + } + else + { + self.displayNameTextField.text = [MatrixKitL10n roomPleaseSelect]; + self.displayNameTextField.enabled = NO; + } + self.displayNameTextField.hidden = NO; +} + +- (void)destroy +{ + self.delegate = nil; + self.mxRoom = nil; + + if (mxRoomSummaryDidChangeObserver) + { + [NSNotificationCenter.defaultCenter removeObserver:mxRoomSummaryDidChangeObserver]; + mxRoomSummaryDidChangeObserver = nil; + } +} + +- (void)dismissKeyboard +{ + // Hide the keyboard + [self.displayNameTextField resignFirstResponder]; +} + +#pragma mark - + +- (void)setMxRoom:(MXRoom *)mxRoom +{ + // Check whether the room is actually changed + if (_mxRoom != mxRoom) + { + // Remove potential listener + if (mxRoomSummaryDidChangeObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:mxRoomSummaryDidChangeObserver]; + mxRoomSummaryDidChangeObserver = nil; + } + + if (mxRoom) + { + MXWeakify(self); + + // Register a listener to handle the room name change + mxRoomSummaryDidChangeObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXRoomSummaryDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXStrongifyAndReturnIfNil(self); + + // Check whether the text field is editing before refreshing title view + if (!self.isEditing) + { + [self refreshDisplay]; + } + + }]; + } + _mxRoom = mxRoom; + } + // Force refresh + [self refreshDisplay]; +} + +- (void)setEditable:(BOOL)editable +{ + self.displayNameTextField.enabled = editable; +} + +- (BOOL)isEditing +{ + return self.displayNameTextField.isEditing; +} + +#pragma mark - UITextField delegate + +- (BOOL)textFieldShouldBeginEditing:(UITextField *)textField +{ + // check if the deleaget allows the edition + if (!self.delegate || [self.delegate roomTitleViewShouldBeginEditing:self]) + { + NSString *alertMsg = nil; + + if (textField == self.displayNameTextField) + { + // Check whether the user has enough power to rename the room + MXRoomPowerLevels *powerLevels = _mxRoom.dangerousSyncState.powerLevels; + + NSInteger userPowerLevel = [powerLevels powerLevelOfUserWithUserID:_mxRoom.mxSession.myUser.userId]; + if (userPowerLevel >= [powerLevels minimumPowerLevelForSendingEventAsStateEvent:kMXEventTypeStringRoomName]) + { + // Only the room name is edited here, update the text field with the room name + textField.text = _mxRoom.summary.displayname; + textField.backgroundColor = [UIColor whiteColor]; + } + else + { + alertMsg = [MatrixKitL10n roomErrorNameEditionNotAuthorized]; + } + } + + if (alertMsg) + { + // Alert user + __weak typeof(self) weakSelf = self; + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + } + + currentAlert = [UIAlertController alertControllerWithTitle:nil message:alertMsg preferredStyle:UIAlertControllerStyleAlert]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + }]]; + + [self.delegate roomTitleView:self presentAlertController:currentAlert]; + return NO; + } + return YES; + } + else + { + return NO; + } +} + +- (void)textFieldDidEndEditing:(UITextField *)textField +{ + if (textField == self.displayNameTextField) + { + textField.backgroundColor = [UIColor clearColor]; + + NSString *roomName = textField.text; + if ((roomName.length || _mxRoom.summary.displayname.length) && [roomName isEqualToString:_mxRoom.summary.displayname] == NO) + { + if ([self.delegate respondsToSelector:@selector(roomTitleView:isSaving:)]) + { + [self.delegate roomTitleView:self isSaving:YES]; + } + + __weak typeof(self) weakSelf = self; + [_mxRoom setName:roomName success:^{ + + if (weakSelf) + { + typeof(weakSelf)strongSelf = weakSelf; + if ([strongSelf.delegate respondsToSelector:@selector(roomTitleView:isSaving:)]) + { + [strongSelf.delegate roomTitleView:strongSelf isSaving:NO]; + } + } + + } failure:^(NSError *error) { + + if (weakSelf) + { + typeof(weakSelf)strongSelf = weakSelf; + if ([strongSelf.delegate respondsToSelector:@selector(roomTitleView:isSaving:)]) + { + [strongSelf.delegate roomTitleView:strongSelf isSaving:NO]; + } + + // Revert change + textField.text = strongSelf.mxRoom.summary.displayname; + MXLogDebug(@"[MXKRoomTitleView] Rename room failed"); + // Notify MatrixKit user + NSString *myUserId = strongSelf.mxRoom.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + } + + }]; + } + else + { + // No change on room name, restore title with room displayName + textField.text = _mxRoom.summary.displayname; + } + } +} + +- (BOOL)textFieldShouldReturn:(UITextField*) textField +{ + // "Done" key has been pressed + [textField resignFirstResponder]; + return YES; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.xib b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.xib new file mode 100644 index 000000000..8746fee25 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.xib @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.h b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.h new file mode 100644 index 000000000..31a2c8894 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.h @@ -0,0 +1,36 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomTitleView.h" + +/** + 'MXKRoomTitleViewWithTopic' inherits 'MXKRoomTitleView' to add an editable room topic field. + */ +@interface MXKRoomTitleViewWithTopic : MXKRoomTitleView { +} + +@property (weak, nonatomic) IBOutlet UITextField *topicTextField; + +@property (nonatomic) BOOL hiddenTopic; + +/** + Stop topic animation. + + @return YES if the animation has been stopped. + */ +- (BOOL)stopTopicAnimation; + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.m b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.m new file mode 100644 index 000000000..9fbe1dec9 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.m @@ -0,0 +1,520 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomTitleViewWithTopic.h" + +#import "MXKConstants.h" + +#import "NSBundle+MatrixKit.h" +#import "MXRoom+Sync.h" + +#import "MXKSwiftHeader.h" + +@interface MXKRoomTitleViewWithTopic () +{ + id roomTopicListener; + + // the topic can be animated if it is longer than the screen size + UIScrollView* scrollView; + UILabel* label; + UIView* topicTextFieldMaskView; + + // do not start the topic animation asap + NSTimer * animationTimer; +} +@end + +@implementation MXKRoomTitleViewWithTopic + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRoomTitleViewWithTopic class]) + bundle:[NSBundle bundleForClass:[MXKRoomTitleViewWithTopic class]]]; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + // Add an accessory view to the text view in order to retrieve keyboard view. + self.topicTextField.inputAccessoryView = inputAccessoryView; + + self.displayNameTextField.returnKeyType = UIReturnKeyNext; + self.topicTextField.enabled = NO; + self.topicTextField.returnKeyType = UIReturnKeyDone; + self.hiddenTopic = YES; +} + +- (void)refreshDisplay +{ + [super refreshDisplay]; + + if (self.mxRoom) + { + // Remove new line characters + NSString *topic = [MXTools stripNewlineCharacters:self.mxRoom.summary.topic]; + // replace empty string by nil: avoid having the placeholder when there is no topic + self.topicTextField.text = (topic.length ? topic : nil); + } + else + { + self.topicTextField.text = nil; + } + + self.hiddenTopic = (!self.topicTextField.text.length); +} + +- (void)destroy +{ + // stop any animation + [self stopTopicAnimation]; + + [super destroy]; +} + +- (void)dismissKeyboard +{ + // Hide the keyboard + [self.topicTextField resignFirstResponder]; + + // restart the animation + [self stopTopicAnimation]; + + [super dismissKeyboard]; +} + +#pragma mark - + +- (void)setMxRoom:(MXRoom *)mxRoom +{ + // Make sure we can access synchronously to self.mxRoom and mxRoom data + // to avoid race conditions + MXWeakify(self); + [mxRoom.mxSession preloadRoomsData:self.mxRoom ? @[self.mxRoom.roomId, mxRoom.roomId] : @[mxRoom.roomId] onComplete:^{ + MXStrongifyAndReturnIfNil(self); + + // Check whether the room is actually changed + if (self.mxRoom != mxRoom) + { + // Remove potential listener + if (self->roomTopicListener && self.mxRoom) + { + MXWeakify(self); + [self.mxRoom liveTimeline:^(MXEventTimeline *liveTimeline) { + MXStrongifyAndReturnIfNil(self); + + [liveTimeline removeListener:self->roomTopicListener]; + self->roomTopicListener = nil; + }]; + } + + if (mxRoom) + { + // Register a listener to handle messages related to room name + self->roomTopicListener = [mxRoom listenToEventsOfTypes:@[kMXEventTypeStringRoomTopic] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + + // Consider only live events + if (direction == MXTimelineDirectionForwards) + { + [self refreshDisplay]; + } + }]; + } + } + + super.mxRoom = mxRoom; + }]; +} + +- (void)setEditable:(BOOL)editable +{ + self.topicTextField.enabled = editable; + + super.editable = editable; +} + +- (void)setHiddenTopic:(BOOL)hiddenTopic +{ + [self stopTopicAnimation]; + if (hiddenTopic) + { + self.topicTextField.hidden = YES; + self.displayNameTextFieldTopConstraint.constant = 10; + } + else + { + self.topicTextField.hidden = NO; + self.displayNameTextFieldTopConstraint.constant = 0; + } +} + +- (BOOL)isEditing +{ + return (super.isEditing || self.topicTextField.isEditing); +} + +#pragma mark - + +// start with delay +- (void)startTopicAnimation +{ + // stop any pending timer + if (animationTimer) + { + [animationTimer invalidate]; + animationTimer = nil; + } + + // already animated the topic + if (scrollView) + { + return; + } + + // compute the text width + UIFont* font = self.topicTextField.font; + + // see font description + if (!font) + { + font = [UIFont systemFontOfSize:12]; + } + + NSDictionary *attributes = @{NSFontAttributeName: font}; + + CGSize stringSize = CGSizeMake(CGFLOAT_MAX, self.topicTextField.frame.size.height); + + stringSize = [self.topicTextField.text boundingRectWithSize:stringSize + options:NSStringDrawingUsesLineFragmentOrigin|NSStringDrawingUsesFontLeading + attributes:attributes + context:nil].size; + + // does not need to animate the text + if (stringSize.width < self.topicTextField.frame.size.width) + { + return; + } + + // put the text in a scrollView to animat it + scrollView = [[UIScrollView alloc] initWithFrame: self.topicTextField.frame]; + label = [[UILabel alloc] initWithFrame:self.topicTextField.frame]; + label.text = self.topicTextField.text; + label.textColor = self.topicTextField.textColor; + label.font = self.topicTextField.font; + + // move to the top left + CGRect topicTextFieldFrame = self.topicTextField.frame; + topicTextFieldFrame.origin = CGPointZero; + label.frame = topicTextFieldFrame; + + self.topicTextField.hidden = YES; + [scrollView addSubview:label]; + [self insertSubview:scrollView belowSubview:topicTextFieldMaskView]; + + // update the size + [label sizeToFit]; + + // offset + CGPoint offset = scrollView.contentOffset; + offset.x = label.frame.size.width - scrollView.frame.size.width; + + // duration (magic computation to give more time if the text is longer) + CGFloat duration = label.frame.size.width / scrollView.frame.size.width * 3; + + // animate the topic once to display its full content + [UIView animateWithDuration:duration delay:0 options:UIViewAnimationOptionAutoreverse | UIViewAnimationOptionCurveLinear animations:^{ + [self->scrollView setContentOffset:offset animated:NO]; + } completion:^(BOOL finished) + { + [self stopTopicAnimation]; + }]; +} + +- (BOOL)stopTopicAnimation +{ + // stop running timers + if (animationTimer) + { + [animationTimer invalidate]; + animationTimer = nil; + } + + // if there is an animation is progress + if (scrollView) + { + self.topicTextField.hidden = NO; + + [scrollView.layer removeAllAnimations]; + [scrollView removeFromSuperview]; + scrollView = nil; + label = nil; + + [self addSubview:self.topicTextField]; + + // must be done to be able to restart the animation + // the Z order is not kept + [self bringSubviewToFront:topicTextFieldMaskView]; + + return YES; + } + + return NO; +} + +- (void)editTopic +{ + [self stopTopicAnimation]; + + dispatch_async(dispatch_get_main_queue(), ^{ + [self.topicTextField becomeFirstResponder]; + }); +} + +- (void)layoutSubviews +{ + // add a mask to trap the tap events + // it is faster (and simpliest) than subclassing the scrollview or the textField + // any other gesture could also be trapped here + if (!topicTextFieldMaskView) + { + topicTextFieldMaskView = [[UIView alloc] initWithFrame:self.topicTextField.frame]; + topicTextFieldMaskView.backgroundColor = [UIColor clearColor]; + [self addSubview:topicTextFieldMaskView]; + + // tap -> switch to text edition + UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(editTopic)]; + [tap setNumberOfTouchesRequired:1]; + [tap setNumberOfTapsRequired:1]; + [tap setDelegate:self]; + [topicTextFieldMaskView addGestureRecognizer:tap]; + + // long tap -> animate the topic + UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(startTopicAnimation)]; + [topicTextFieldMaskView addGestureRecognizer:longPress]; + } + + + // mother class call + [super layoutSubviews]; +} + +- (void)setFrame:(CGRect)frame +{ + // mother class call + [super setFrame:frame]; + + // stop any running animation if the frame is updated (screen rotation for example) + if (!CGRectEqualToRect(CGRectIntegral(frame), CGRectIntegral(self.frame))) + { + // stop any running application + [self stopTopicAnimation]; + } + + // update the mask frame + if (self.topicTextField.hidden) + { + topicTextFieldMaskView.frame = CGRectZero; + } + else + { + topicTextFieldMaskView.frame = self.topicTextField.frame; + } + + // topicTextField switches becomes the first responder or it is not anymore the first responder + if (self.topicTextField.isFirstResponder != (topicTextFieldMaskView.hidden)) + { + topicTextFieldMaskView.hidden = self.topicTextField.isFirstResponder; + + // move topicTextFieldMaskView to the foreground + // when topicTextField has been the first responder, it lets a view over topicTextFieldMaskView + // so restore the expected Z order + if (!topicTextFieldMaskView.hidden) + { + [self bringSubviewToFront:topicTextFieldMaskView]; + } + } +} + +#pragma mark - UITextField delegate + +- (BOOL)textFieldShouldBeginEditing:(UITextField *)textField +{ + // check if the deleaget allows the edition + if (!self.delegate || [self.delegate roomTitleViewShouldBeginEditing:self]) + { + NSString *alertMsg = nil; + + if (textField == self.displayNameTextField) + { + // Check whether the user has enough power to rename the room + MXRoomPowerLevels *powerLevels = self.mxRoom.dangerousSyncState.powerLevels; + NSInteger userPowerLevel = [powerLevels powerLevelOfUserWithUserID:self.mxRoom.mxSession.myUser.userId]; + if (userPowerLevel >= [powerLevels minimumPowerLevelForSendingEventAsStateEvent:kMXEventTypeStringRoomName]) + { + // Only the room name is edited here, update the text field with the room name + textField.text = self.mxRoom.summary.displayname; + textField.backgroundColor = [UIColor whiteColor]; + } + else + { + alertMsg = [MatrixKitL10n roomErrorNameEditionNotAuthorized]; + } + + // Check whether the user is allowed to change room topic + if (userPowerLevel >= [powerLevels minimumPowerLevelForSendingEventAsStateEvent:kMXEventTypeStringRoomTopic]) + { + // Show topic text field even if the current value is nil + self.hiddenTopic = NO; + if (alertMsg) + { + // Here the user can only update the room topic, switch on room topic field (without displaying alert) + alertMsg = nil; + [self.topicTextField becomeFirstResponder]; + return NO; + } + } + } + else if (textField == self.topicTextField) + { + // Check whether the user has enough power to edit room topic + MXRoomPowerLevels *powerLevels = self.mxRoom.dangerousSyncState.powerLevels; + NSInteger userPowerLevel = [powerLevels powerLevelOfUserWithUserID:self.mxRoom.mxSession.myUser.userId]; + if (userPowerLevel >= [powerLevels minimumPowerLevelForSendingEventAsStateEvent:kMXEventTypeStringRoomTopic]) + { + textField.backgroundColor = [UIColor whiteColor]; + [self stopTopicAnimation]; + } + else + { + alertMsg = [MatrixKitL10n roomErrorTopicEditionNotAuthorized]; + } + } + + if (alertMsg) + { + // Alert user + __weak typeof(self) weakSelf = self; + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + } + currentAlert = [UIAlertController alertControllerWithTitle:nil message:alertMsg preferredStyle:UIAlertControllerStyleAlert]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + }]]; + + [self.delegate roomTitleView:self presentAlertController:currentAlert]; + return NO; + } + return YES; + } + else + { + return NO; + } +} + +- (void)textFieldDidEndEditing:(UITextField *)textField +{ + if (textField == self.topicTextField) + { + textField.backgroundColor = [UIColor clearColor]; + + NSString *topic = textField.text; + if ((topic.length || self.mxRoom.summary.topic.length) && [topic isEqualToString:self.mxRoom.summary.topic] == NO) + { + if ([self.delegate respondsToSelector:@selector(roomTitleView:isSaving:)]) + { + [self.delegate roomTitleView:self isSaving:YES]; + } + __weak typeof(self) weakSelf = self; + [self.mxRoom setTopic:topic success:^{ + + if (weakSelf) + { + typeof(weakSelf)strongSelf = weakSelf; + if ([strongSelf.delegate respondsToSelector:@selector(roomTitleView:isSaving:)]) + { + [strongSelf.delegate roomTitleView:strongSelf isSaving:NO]; + } + + // Hide topic field if empty + strongSelf.hiddenTopic = !textField.text.length; + } + + } failure:^(NSError *error) { + + if (weakSelf) + { + typeof(weakSelf)strongSelf = weakSelf; + if ([strongSelf.delegate respondsToSelector:@selector(roomTitleView:isSaving:)]) + { + [strongSelf.delegate roomTitleView:strongSelf isSaving:NO]; + } + + // Revert change + NSString *topic = [MXTools stripNewlineCharacters:strongSelf.mxRoom.summary.topic]; + textField.text = (topic.length ? topic : nil); + + // Hide topic field if empty + strongSelf.hiddenTopic = !textField.text.length; + + MXLogDebug(@"[MXKRoomTitleViewWithTopic] Topic room change failed"); + // Notify MatrixKit user + NSString *myUserId = strongSelf.mxRoom.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + } + + }]; + } + else + { + // Hide topic field if empty + self.hiddenTopic = !topic.length; + } + } + else + { + // Let super handle displayName text field + [super textFieldDidEndEditing:textField]; + } +} + +- (BOOL)textFieldShouldReturn:(UITextField*) textField +{ + if (textField == self.displayNameTextField) + { + // "Next" key has been pressed + [self.topicTextField becomeFirstResponder]; + } + else + { + // "Done" key has been pressed + [textField resignFirstResponder]; + } + return YES; +} + + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.xib b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.xib new file mode 100644 index 000000000..1d354955b --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.xib @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.h b/Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.h new file mode 100644 index 000000000..562e7b87a --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.h @@ -0,0 +1,34 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +#import "MXKCellRendering.h" +#import "MXKImageView.h" + +/** + Each `MXKSearchTableViewCell` instance displays a search result. + */ +@interface MXKSearchTableViewCell : MXKTableViewCell + +@property (weak, nonatomic) IBOutlet UILabel *title; +@property (weak, nonatomic) IBOutlet UILabel *message; +@property (weak, nonatomic) IBOutlet UILabel *date; + +@property (weak, nonatomic) IBOutlet MXKImageView *attachmentImageView; +@property (weak, nonatomic) IBOutlet UIImageView *iconImage; + +@end diff --git a/Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.m b/Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.m new file mode 100644 index 000000000..0c5617acd --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.m @@ -0,0 +1,74 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKSearchTableViewCell.h" + +@import MatrixSDK.MXMediaManager; + +#import "MXKSearchCellDataStoring.h" + +@implementation MXKSearchTableViewCell + +#pragma mark - Class methods + +- (void)render:(MXKCellData *)cellData +{ + // Sanity check: accept only object of MXKRoomBubbleCellData classes or sub-classes + NSParameterAssert([cellData conformsToProtocol:@protocol(MXKSearchCellDataStoring)]); + + id searchCellData = (id)cellData; + if (searchCellData) + { + _title.text = searchCellData.title; + _date.text = searchCellData.date; + _message.text = searchCellData.message; + + if (_attachmentImageView) + { + _attachmentImageView.image = nil; + self.attachmentImageView.defaultBackgroundColor = [UIColor clearColor]; + + if (searchCellData.isAttachmentWithThumbnail) + { + [self.attachmentImageView setAttachmentThumb:searchCellData.attachment]; + self.attachmentImageView.defaultBackgroundColor = [UIColor whiteColor]; + } + } + + if (_iconImage) + { + _iconImage.image = searchCellData.attachmentIcon; + } + } + else + { + _title.text = nil; + _date.text = nil; + _message.text = @""; + + _attachmentImageView.image = nil; + _iconImage.image = nil; + } +} + ++ (CGFloat)heightForCellData:(MXKCellData *)cellData withMaximumWidth:(CGFloat)maxWidth +{ + // The height is fixed + return 70; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.xib b/Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.xib new file mode 100644 index 000000000..94b9f51f8 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.xib @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/RiotTests/MatrixKitTests/Assets/test.png b/RiotTests/MatrixKitTests/Assets/test.png new file mode 100644 index 000000000..886158d3a Binary files /dev/null and b/RiotTests/MatrixKitTests/Assets/test.png differ diff --git a/RiotTests/MatrixKitTests/EncryptedAttachmentsTest.m b/RiotTests/MatrixKitTests/EncryptedAttachmentsTest.m new file mode 100644 index 000000000..411257fb9 --- /dev/null +++ b/RiotTests/MatrixKitTests/EncryptedAttachmentsTest.m @@ -0,0 +1,114 @@ +/* + Copyright 2016 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXEncryptedAttachments.h" +#import "MXEncryptedContentFile.h" +#import "MXBase64Tools.h" + +@interface EncryptedAttachmentsTest : XCTestCase + +@end + + +@implementation EncryptedAttachmentsTest + +- (void)setUp { + [super setUp]; +} + +- (void)tearDown { + [super tearDown]; +} + +- (void)testDecrypt { + NSArray *testVectors = + @[ + @[@"", @{ + @"v": @"v1", + @"hashes": @{ + @"sha256": @"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU" + }, + @"key": @{ + @"alg": @"A256CTR", + @"k": @"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + @"key_ops": @[@"encrypt", @"decrypt"], + @"kty": @"oct" + }, + @"iv": @"AAAAAAAAAAAAAAAAAAAAAA" + }, @""], + @[@"5xJZTt5cQicm+9f4", @{ + @"v": @"v1", + @"hashes": @{ + @"sha256": @"YzF08lARDdOCzJpzuSwsjTNlQc4pHxpdHcXiD/wpK6k" + }, @"key": @{ + @"alg": @"A256CTR", + @"k": @"__________________________________________8", + @"key_ops": @[@"encrypt", @"decrypt"], + @"kty": @"oct" + }, @"iv": @"//////////8AAAAAAAAAAA" + }, @"SGVsbG8sIFdvcmxk"], + @[@"zhtFStAeFx0s+9L/sSQO+WQMtldqYEHqTxMduJrCIpnkyer09kxJJuA4K+adQE4w+7jZe/vR9kIcqj9rOhDR8Q", @{ + @"v": @"v2", + @"hashes": @{ + @"sha256": @"IOq7/dHHB+mfHfxlRY5XMeCWEwTPmlf4cJcgrkf6fVU" + }, + @"key": @{ + @"kty": @"oct", + @"key_ops": @[@"encrypt",@"decrypt"], + @"k": @"__________________________________________8", + @"alg": @"A256CTR" + }, + @"iv": @"//////////8AAAAAAAAAAA" + }, @"YWxwaGFudW1lcmljYWxseWFscGhhbnVtZXJpY2FsbHlhbHBoYW51bWVyaWNhbGx5YWxwaGFudW1lcmljYWxseQ"], + @[@"tJVNBVJ/vl36UQt4Y5e5m84bRUrQHhcdLPvS/7EkDvlkDLZXamBB6k8THbiawiKZ5Mnq9PZMSSbgOCvmnUBOMA", @{ + @"v": @"v1", + @"hashes": @{ + @"sha256": @"LYG/orOViuFwovJpv2YMLSsmVKwLt7pY3f8SYM7KU5E" + }, + @"key": @{ + @"kty": @"oct", + @"key_ops": @[@"encrypt",@"decrypt"], + @"k": @"__________________________________________8", + @"alg": @"A256CTR" + }, + @"iv": @"/////////////////////w" + }, @"YWxwaGFudW1lcmljYWxseWFscGhhbnVtZXJpY2FsbHlhbHBoYW51bWVyaWNhbGx5YWxwaGFudW1lcmljYWxseQ"] + ]; + + for (NSArray *vector in testVectors) { + NSString *inputCiphertext = vector[0]; + MXEncryptedContentFile *inputInfo = [MXEncryptedContentFile modelFromJSON:vector[1]]; + NSString *want = vector[2]; + + NSData *ctData = [[NSData alloc] initWithBase64EncodedString:[MXBase64Tools padBase64:inputCiphertext] options:0]; + NSInputStream *inputStream = [NSInputStream inputStreamWithData:ctData]; + NSOutputStream *outputStream = [NSOutputStream outputStreamToMemory]; + + [MXEncryptedAttachments decryptAttachment:inputInfo inputStream:inputStream outputStream:outputStream success:^{ + NSData *gotData = [outputStream propertyForKey:NSStreamDataWrittenToMemoryStreamKey]; + + NSData *wantData = [[NSData alloc] initWithBase64EncodedString:[MXBase64Tools padBase64:want] options:0]; + + XCTAssertEqualObjects(wantData, gotData, "Decrypted data did not match expectation."); + } failure:^(NSError *error) { + XCTFail(); + }]; + } +} + +@end diff --git a/RiotTests/MatrixKitTests/Info.plist b/RiotTests/MatrixKitTests/Info.plist new file mode 100644 index 000000000..ba72822e8 --- /dev/null +++ b/RiotTests/MatrixKitTests/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1 + + diff --git a/RiotTests/MatrixKitTests/MXKEventFormatter+Tests.h b/RiotTests/MatrixKitTests/MXKEventFormatter+Tests.h new file mode 100644 index 000000000..8aa5a9c1e --- /dev/null +++ b/RiotTests/MatrixKitTests/MXKEventFormatter+Tests.h @@ -0,0 +1,24 @@ +/* + Copyright 2021 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKEventFormatter.h" + +@interface MXKEventFormatter (Tests) + +- (NSString*)userDisplayNameFromContentInEvent:(MXEvent*)event withMembershipFilter:(NSString *)filter; +- (NSString*)userAvatarUrlFromContentInEvent:(MXEvent*)event withMembershipFilter:(NSString *)filter; + +@end diff --git a/RiotTests/MatrixKitTests/MXKEventFormatterTests.m b/RiotTests/MatrixKitTests/MXKEventFormatterTests.m new file mode 100644 index 000000000..fb3e28af7 --- /dev/null +++ b/RiotTests/MatrixKitTests/MXKEventFormatterTests.m @@ -0,0 +1,435 @@ +/* + Copyright 2016 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + + +#import + +#import "MatrixKit.h" +#import "MXKEventFormatter+Tests.h" + +@import DTCoreText; + +@interface MXEventFormatterTests : XCTestCase +{ + MXKEventFormatter *eventFormatter; + MXEvent *anEvent; + CGFloat maxHeaderSize; +} + +@end + +@implementation MXEventFormatterTests + +- (void)setUp +{ + [super setUp]; + + // Create a minimal event formatter + // Note: it may not be enough for testing all MXKEventFormatter methods + eventFormatter = [[MXKEventFormatter alloc] initWithMatrixSession:nil]; + + eventFormatter.treatMatrixUserIdAsLink = YES; + eventFormatter.treatMatrixRoomIdAsLink = YES; + eventFormatter.treatMatrixRoomAliasAsLink = YES; + eventFormatter.treatMatrixEventIdAsLink = YES; + + anEvent = [[MXEvent alloc] init]; + anEvent.roomId = @"aRoomId"; + anEvent.eventId = @"anEventId"; + anEvent.wireType = kMXEventTypeStringRoomMessage; + anEvent.originServerTs = (uint64_t) ([[NSDate date] timeIntervalSince1970] * 1000); + anEvent.wireContent = @{ + @"msgtype": kMXMessageTypeText, + @"body": @"deded", + }; + + maxHeaderSize = ceil(eventFormatter.defaultTextFont.pointSize * 1.2); +} + +- (void)tearDown +{ + [super tearDown]; +} + +- (void)testRenderHTMLStringWithHeaders +{ + // Given HTML strings with h1/h2/h3 tags + NSString *h1HTML = @"

Large Heading

"; + NSString *h2HTML = @"

Smaller Heading

"; + NSString *h3HTML = @"

Acceptable Heading

"; + + // When rendering these strings as attributed strings + NSAttributedString *h1AttributedString = [eventFormatter renderHTMLString:h1HTML forEvent:anEvent withRoomState:nil]; + NSAttributedString *h2AttributedString = [eventFormatter renderHTMLString:h2HTML forEvent:anEvent withRoomState:nil]; + NSAttributedString *h3AttributedString = [eventFormatter renderHTMLString:h3HTML forEvent:anEvent withRoomState:nil]; + + // Then the h1/h2 fonts should be reduced in size to match h3. + XCTAssertEqualObjects(h1AttributedString.string, @"Large Heading", @"The text from an H1 tag should be preserved when removing formatting."); + XCTAssertEqualObjects(h2AttributedString.string, @"Smaller Heading", @"The text from an H2 tag should be preserved when removing formatting."); + XCTAssertEqualObjects(h3AttributedString.string, @"Acceptable Heading", @"The text from an H3 tag should not change."); + + [h1AttributedString enumerateAttributesInRange:NSMakeRange(0, h1AttributedString.length) + options:0 + usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { + UIFont *font = attributes[NSFontAttributeName]; + XCTAssertGreaterThan(font.pointSize, eventFormatter.defaultTextFont.pointSize, @"H1 tags should be larger than the default body size."); + XCTAssertLessThanOrEqual(font.pointSize, maxHeaderSize, @"H1 tags shouldn't exceed the max header size."); + }]; + + [h2AttributedString enumerateAttributesInRange:NSMakeRange(0, h2AttributedString.length) + options:0 + usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { + UIFont *font = attributes[NSFontAttributeName]; + XCTAssertGreaterThan(font.pointSize, eventFormatter.defaultTextFont.pointSize, @"H2 tags should be larger than the default body size."); + XCTAssertLessThanOrEqual(font.pointSize, maxHeaderSize, @"H2 tags shouldn't exceed the max header size."); + }]; + + [h3AttributedString enumerateAttributesInRange:NSMakeRange(0, h3AttributedString.length) + options:0 + usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { + UIFont *font = attributes[NSFontAttributeName]; + XCTAssertGreaterThan(font.pointSize, eventFormatter.defaultTextFont.pointSize, @"H3 tags should be included and be larger than the default body size."); + XCTAssertLessThanOrEqual(font.pointSize, maxHeaderSize, @"H3 tags shouldn't exceed the max header size."); + }]; +} + +- (void)testRenderHTMLStringWithPreCode +{ + NSString *html = @"
1\n2\n3\n4\n
"; + NSAttributedString *as = [eventFormatter renderHTMLString:html forEvent:anEvent withRoomState:nil]; + + NSString *a = as.string; + + // \R : any newlines + NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"\\R" options:0 error:0]; + XCTAssertEqual(3, [regex numberOfMatchesInString:a options:0 range:NSMakeRange(0, a.length)], "renderHTMLString must keep line break in
 and  blocks");
+
+    [as enumerateAttributesInRange:NSMakeRange(0, as.length) options:(0) usingBlock:^(NSDictionary * _Nonnull attrs, NSRange range, BOOL * _Nonnull stop) {
+
+        UIFont *font = attrs[NSFontAttributeName];
+        XCTAssertEqualObjects(font.fontName, @"Menlo-Regular", "The font for 
 and  should be monospace");
+    }];
+}
+
+- (void)testRenderHTMLStringWithLink
+{
+    // Given an HTML string with a link inside of it.
+    NSString *html = @"This text contains a link.";
+    
+    // When rendering this string as an attributed string.
+    NSAttributedString *attributedString = [eventFormatter renderHTMLString:html forEvent:anEvent withRoomState:nil];
+    
+    // Then the attributed string should contain all of the text,
+    XCTAssertEqualObjects(attributedString.string, @"This text contains a link.", @"The text should be preserved when adding a link.");
+    
+    // and the link should be added as an attachment.
+    __block BOOL didFindLink = NO;
+    [attributedString enumerateAttribute:NSLinkAttributeName
+                                 inRange:NSMakeRange(0, attributedString.length)
+                                 options:0
+                              usingBlock:^(id _Nullable value, NSRange range, BOOL * _Nonnull stop) {
+        if ([value isKindOfClass:NSURL.class])
+        {
+            NSURL *url = (NSURL *)value;
+            XCTAssertEqualObjects(url, [NSURL URLWithString:@"https://www.matrix.org/"], @"href links should be included in the text.");
+            didFindLink = YES;
+        }
+    }];
+    
+    XCTAssertTrue(didFindLink, @"There should be a link in the attributed string.");
+}
+
+- (void)testRenderHTMLStringWithLinkInHeader
+{
+    // Given HTML strings with links contained within h1/h2 tags.
+    NSString *h1HTML = @"

Matrix.org

"; + NSString *h3HTML = @"

Matrix.org

"; + + // When rendering these strings as attributed strings. + NSAttributedString *h1AttributedString = [eventFormatter renderHTMLString:h1HTML forEvent:anEvent withRoomState:nil]; + NSAttributedString *h3AttributedString = [eventFormatter renderHTMLString:h3HTML forEvent:anEvent withRoomState:nil]; + + // Then the attributed string should contain all of the text, + XCTAssertEqualObjects(h1AttributedString.string, @"Matrix.org", @"The text from an H1 tag should be preserved when removing formatting."); + XCTAssertEqualObjects(h3AttributedString.string, @"Matrix.org", @"The text from an H3 tag should not change."); + + // and be formatted as a header with the link added as an attachment. + __block BOOL didFindH1Link = NO; + [h1AttributedString enumerateAttributesInRange:NSMakeRange(0, h1AttributedString.length) + options:0 + usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { + UIFont *font = attributes[NSFontAttributeName]; + NSURL *url = attributes[NSLinkAttributeName]; + + if (font) + { + XCTAssertGreaterThan(font.pointSize, eventFormatter.defaultTextFont.pointSize, @"H1 tags should be larger than the default body size."); + XCTAssertLessThanOrEqual(font.pointSize, maxHeaderSize, @"H1 tags shouldn't exceed the max header size."); + } + + if (url) + { + XCTAssertEqualObjects(url, [NSURL URLWithString:@"https://www.matrix.org/"], @"href links should be included in the text."); + didFindH1Link = YES; + } + }]; + + __block BOOL didFindH3Link = NO; + [h3AttributedString enumerateAttributesInRange:NSMakeRange(0, h3AttributedString.length) + options:0 + usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { + UIFont *font = attributes[NSFontAttributeName]; + NSURL *url = attributes[NSLinkAttributeName]; + + if (font) + { + XCTAssertGreaterThan(font.pointSize, eventFormatter.defaultTextFont.pointSize, @"H3 tags should be included and be larger than the default."); + } + + if (url) + { + XCTAssertEqualObjects(url, [NSURL URLWithString:@"https://www.matrix.org/"], @"href links should be included in the text."); + didFindH3Link = YES; + } + }]; + + XCTAssertTrue(didFindH1Link, @"There should be a link in the sanitised attributed string."); + XCTAssertTrue(didFindH3Link, @"There should be a link in the attributed string."); +} + +- (void)testRenderHTMLStringWithIFrame +{ + // Given an HTML string containing an unsupported iframe. + NSString *html = @""; + + // When rendering this string as an attributed string. + NSAttributedString *attributedString = [eventFormatter renderHTMLString:html forEvent:anEvent withRoomState:nil]; + + // Then the attributed string should have the iframe stripped and not include any attachments. + BOOL hasAttachment = [attributedString containsAttachmentsInRange:NSMakeRange(0, attributedString.length)]; + XCTAssertFalse(hasAttachment, @"iFrame attachments should be removed as they're not included in the allowedHTMLTags array."); +} + +- (void)testRenderHTMLStringWithMXReply +{ + // Given an HTML string representing a matrix reply. + NSString *html = @"
In reply to @alice:matrix.org
Original message.
This is a reply."; + + // When rendering this string as an attributed string. + NSAttributedString *attributedString = [eventFormatter renderHTMLString:html forEvent:anEvent withRoomState:nil]; + + // Then the attributed string should contain all of the text, + NSString *plainString = [attributedString.string stringByReplacingOccurrencesOfString:@"\U00002028" withString:@"\n"]; + XCTAssertEqualObjects(plainString, @"In reply to @alice:matrix.org\nOriginal message.\nThis is a reply.", + @"The reply string should include who the original message was from, what they said, and the reply itself."); + + // and format the author and original message inside of a quotation block. + __block BOOL didTestReplyText = NO; + __block BOOL didTestQuoteBlock = NO; + [attributedString enumerateAttributesInRange:NSMakeRange(0, attributedString.length) + options:0 + usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { + NSString *substring = [attributedString attributedSubstringFromRange:range].string; + + if ([substring isEqualToString:@"This is a reply."]) + { + XCTAssertNil(attributes[DTTextBlocksAttribute], @"The reply text should not appear within a block"); + didTestReplyText = YES; + } + else + { + XCTAssertNotNil(attributes[DTTextBlocksAttribute], @"The rest of the string should be within a block"); + didTestQuoteBlock = YES; + } + }]; + + XCTAssertTrue(didTestReplyText && didTestQuoteBlock, @"Both a quote and a reply should be in the attributed string."); +} + +- (void)testRenderHTMLStringWithMXReplyQuotingInvalidMessage +{ + // Given an HTML string representing a matrix reply where the original message has invalid HTML. + NSString *html = @"
In reply to @alice:matrix.org

Heading with invalid content

This is a reply."; + + // When rendering this string as an attributed string. + NSAttributedString *attributedString = [eventFormatter renderHTMLString:html forEvent:anEvent withRoomState:nil]; + + // Then the attributed string should contain all of the text, + NSString *plainString = [attributedString.string stringByReplacingOccurrencesOfString:@"\U00002028" withString:@"\n"]; + XCTAssertEqualObjects(plainString, @"In reply to @alice:matrix.org\nHeading with invalid content\nThis is a reply.", + @"The reply string should include who the original message was from, what they said, and the reply itself."); + + // and format the author and original message inside of a quotation block. This check + // is to catch any incorrectness in the sanitizing where the original message becomes + // indented but is missing the block quote mark attribute. + __block BOOL didTestReplyText = NO; + __block BOOL didTestQuoteBlock = NO; + [attributedString enumerateAttributesInRange:NSMakeRange(0, attributedString.length) + options:0 + usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { + + NSString *substring = [attributedString attributedSubstringFromRange:range].string; + + if ([substring isEqualToString:@"This is a reply."]) + { + XCTAssertNil(attributes[DTTextBlocksAttribute], @"The reply text should not appear within a block"); + didTestReplyText = YES; + } + else + { + XCTAssertNotNil(attributes[DTTextBlocksAttribute], @"The rest of the string should be within a block"); + XCTAssertNotNil(attributes[kMXKToolsBlockquoteMarkAttribute], @"The block should have the blockquote style applied"); + didTestQuoteBlock = YES; + } + }]; + + XCTAssertTrue(didTestReplyText && didTestQuoteBlock, @"Both a quote and a reply should be in the attributed string."); +} + +- (void)testRenderHTMLStringWithImageHandler +{ + MXWeakify(self); + + // Given an HTML string that contains an image tag inline. + NSURL *imageURL = [NSURL URLWithString:@"https://matrix.org/images/matrix-logo.svg"]; + NSString *html = [NSString stringWithFormat:@"Look at this logo: Very nice.", imageURL.absoluteString]; + + // When rendering this string as an attributed string using an appropriate image handler block. + eventFormatter.allowedHTMLTags = [eventFormatter.allowedHTMLTags arrayByAddingObject:@"img"]; + eventFormatter.htmlImageHandler = ^NSURL *(NSString *sourceURL, CGFloat width, CGFloat height) { + MXStrongifyAndReturnValueIfNil(self, nil); + + // Replace the image URL with one from the tests bundle + NSBundle *bundle = [NSBundle bundleForClass:self.class];; + return [bundle URLForResource:@"test" withExtension:@"png"]; + }; + NSAttributedString *attributedString = [eventFormatter renderHTMLString:html forEvent:anEvent withRoomState:nil]; + + // Then the attributed string should contain all of the text, + NSString *plainString = [attributedString.string stringByReplacingOccurrencesOfString:@"\U0000fffc" withString:@""]; + XCTAssertEqualObjects(plainString, @"Look at this logo: Very nice.", @"The string should include the original text."); + + // and have the image included as an attachment. + __block BOOL hasImageAttachment = NO; + [attributedString enumerateAttribute:NSAttachmentAttributeName + inRange:NSMakeRange(0, attributedString.length) + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + if ([value image]) + { + hasImageAttachment = YES; + } + }]; + + XCTAssertTrue(hasImageAttachment, @"There should be an attachment that contains the image."); +} + +- (void)testMarkdownFormatting +{ + NSString *html = [eventFormatter htmlStringFromMarkdownString:@"Line One.\nLine Two."]; + + BOOL hardBreakExists = [html rangeOfString:@"
"].location != NSNotFound; + BOOL openParagraphExists = [html rangeOfString:@"

"].location != NSNotFound; + BOOL closeParagraphExists = [html rangeOfString:@"

"].location != NSNotFound; + + // Check for some known error cases + XCTAssert(hardBreakExists, "The soft break (\\n) must be converted to a hard break (
)."); + XCTAssert(!openParagraphExists && !closeParagraphExists, "The html must not contain any opening or closing paragraph tags."); +} + +#pragma mark - Links + +- (void)testRoomAliasLink +{ + NSString *s = @"Matrix HQ room is at #matrix:matrix.org."; + NSAttributedString *as = [eventFormatter renderString:s forEvent:anEvent]; + + NSRange linkRange = [s rangeOfString:@"#matrix:matrix.org"]; + + __block NSUInteger ranges = 0; + __block BOOL linkCreated = NO; + + [as enumerateAttributesInRange:NSMakeRange(0, as.length) options:(0) usingBlock:^(NSDictionary * _Nonnull attrs, NSRange range, BOOL * _Nonnull stop) { + + ranges++; + + if (NSEqualRanges(linkRange, range)) + { + linkCreated = (attrs[NSLinkAttributeName] != nil); + } + }]; + + XCTAssertEqual(ranges, 3, @"A sub-component must have been found"); + XCTAssert(linkCreated, @"Link not created as expected: %@", as); +} + +- (void)testLinkWithRoomAliasLink +{ + NSString *s = @"Matrix HQ room is at https://matrix.to/#/room/#matrix:matrix.org."; + NSAttributedString *as = [eventFormatter renderString:s forEvent:anEvent]; + + __block NSUInteger ranges = 0; + + [as enumerateAttributesInRange:NSMakeRange(0, as.length) options:(0) usingBlock:^(NSDictionary * _Nonnull attrs, NSRange range, BOOL * _Nonnull stop) { + + ranges++; + }]; + + XCTAssertEqual(ranges, 1, @"There should be no link in this case. We let the UI manage the link"); +} + +#pragma mark - Event sender/target info + +- (void)testUserDisplayNameFromEventContent { + MXEvent *event = [self eventFromJSON:@"{\"sender\":\"@alice:matrix.org\",\"content\":{\"displayname\":\"bob\",\"membership\":\"invite\"},\"origin_server_ts\":1616488993287,\"state_key\":\"@bob:matrix.org\",\"room_id\":\"!foofoofoofoofoofoo:matrix.org\",\"event_id\":\"$lGK3budX5w009ErtQwE9ZFhwyUUAV9DqEN5yb2fI4Do\",\"type\":\"m.room.member\",\"unsigned\":{}}"]; + XCTAssertEqualObjects([eventFormatter userDisplayNameFromContentInEvent:event withMembershipFilter:nil], @"bob"); + XCTAssertEqualObjects([eventFormatter userDisplayNameFromContentInEvent:event withMembershipFilter:@"invite"], @"bob"); + XCTAssertEqualObjects([eventFormatter userDisplayNameFromContentInEvent:event withMembershipFilter:@"join"], nil); +} + +- (void)testUserDisplayNameFromNonMembershipEventContent { + MXEvent *event = [self eventFromJSON:@"{\"sender\":\"@alice:matrix.org\",\"content\":{\"ciphertext\":\"foo\",\"sender_key\":\"bar\",\"device_id\":\"foobar\",\"algorithm\":\"m.megolm.v1.aes-sha2\"}},\"origin_server_ts\":1616488993287,\"state_key\":\"@bob:matrix.org\",\"room_id\":\"!foofoofoofoofoofoo:matrix.org\",\"event_id\":\"$lGK3budX5w009ErtQwE9ZFhwyUUAV9DqEN5yb2fI4Do\",\"type\":\"m.room.encrypted\",\"unsigned\":{}}"]; + XCTAssertEqualObjects([eventFormatter userDisplayNameFromContentInEvent:event withMembershipFilter:nil], nil); + XCTAssertEqualObjects([eventFormatter userDisplayNameFromContentInEvent:event withMembershipFilter:@"join"], nil); +} + +- (void)testUserAvatarUrlFromEventContent { + MXEvent *event = [self eventFromJSON:@"{\"sender\":\"@alice:matrix.org\",\"content\":{\"displayname\":\"bob\",\"avatar_url\":\"mxc://foo.bar\",\"membership\":\"join\"},\"origin_server_ts\":1616488993287,\"state_key\":\"@bob:matrix.org\",\"room_id\":\"!foofoofoofoofoofoo:matrix.org\",\"event_id\":\"$lGK3budX5w009ErtQwE9ZFhwyUUAV9DqEN5yb2fI4Do\",\"type\":\"m.room.member\",\"unsigned\":{}}"]; + XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:nil], @"mxc://foo.bar"); + XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:@"invite"], nil); + XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:@"join"], @"mxc://foo.bar"); +} + +- (void)testUserAvatarUrlFromEventWithNonMXCAvatarUrlContent { + MXEvent *event = [self eventFromJSON:@"{\"sender\":\"@alice:matrix.org\",\"content\":{\"displayname\":\"bob\",\"avatar_url\":\"http://foo.bar\",\"membership\":\"join\"},\"origin_server_ts\":1616488993287,\"state_key\":\"@bob:matrix.org\",\"room_id\":\"!foofoofoofoofoofoo:matrix.org\",\"event_id\":\"$lGK3budX5w009ErtQwE9ZFhwyUUAV9DqEN5yb2fI4Do\",\"type\":\"m.room.member\",\"unsigned\":{}}"]; + XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:nil], nil); + XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:@"invite"], nil); + XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:@"join"], nil); +} + +- (void)testUserAvatarUrlFromNonMembershipEventContent { + MXEvent *event = [self eventFromJSON:@"{\"sender\":\"@alice:matrix.org\",\"content\":{\"ciphertext\":\"foo\",\"sender_key\":\"bar\",\"device_id\":\"foobar\",\"algorithm\":\"m.megolm.v1.aes-sha2\"}},\"origin_server_ts\":1616488993287,\"state_key\":\"@bob:matrix.org\",\"room_id\":\"!foofoofoofoofoofoo:matrix.org\",\"event_id\":\"$lGK3budX5w009ErtQwE9ZFhwyUUAV9DqEN5yb2fI4Do\",\"type\":\"m.room.encrypted\",\"unsigned\":{}}"]; + XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:nil], nil); + XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:@"join"], nil); +} + +- (MXEvent *)eventFromJSON:(NSString *)json { + NSData *data = [json dataUsingEncoding:NSUTF8StringEncoding]; + NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + return [MXEvent modelFromJSON:dict]; +} + +@end diff --git a/RiotTests/MatrixKitTests/MXKRoomDataSource+Tests.h b/RiotTests/MatrixKitTests/MXKRoomDataSource+Tests.h new file mode 100644 index 000000000..a90ee2fac --- /dev/null +++ b/RiotTests/MatrixKitTests/MXKRoomDataSource+Tests.h @@ -0,0 +1,27 @@ +/* + Copyright 2021 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomDataSource.h" + +@interface MXKRoomDataSource (Tests) + +- (NSArray> *)getBubbles; +- (void)replaceBubbles:(NSArray> *)newBubbles; + +- (void)queueEventForProcessing:(MXEvent*)event withRoomState:(MXRoomState*)roomState direction:(MXTimelineDirection)direction; +- (void)processQueuedEvents:(void (^)(NSUInteger addedHistoryCellNb, NSUInteger addedLiveCellNb))onComplete; + +@end diff --git a/RiotTests/MatrixKitTests/MXKRoomDataSource+Tests.m b/RiotTests/MatrixKitTests/MXKRoomDataSource+Tests.m new file mode 100644 index 000000000..07f5fea57 --- /dev/null +++ b/RiotTests/MatrixKitTests/MXKRoomDataSource+Tests.m @@ -0,0 +1,29 @@ +/* + Copyright 2021 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomDataSource+Tests.h" + +@implementation MXKRoomDataSource (Tests) + +- (NSArray> *)getBubbles { + return bubbles; +} + +- (void)replaceBubbles:(NSArray> *)newBubbles { + bubbles = [NSMutableArray arrayWithArray:newBubbles]; +} + +@end diff --git a/RiotTests/MatrixKitTests/MXKRoomDataSourceTests.swift b/RiotTests/MatrixKitTests/MXKRoomDataSourceTests.swift new file mode 100644 index 000000000..871519a8b --- /dev/null +++ b/RiotTests/MatrixKitTests/MXKRoomDataSourceTests.swift @@ -0,0 +1,155 @@ +/* + Copyright 2021 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import XCTest + +@testable import MatrixKit + +class MXKRoomDataSourceTests: XCTestCase { + + // MARK: - Destruction tests + + func testDestroyRemovesAllBubbles() { + let dataSource = StubMXKRoomDataSource() + dataSource.destroy() + XCTAssert(dataSource.getBubbles()?.isEmpty != false) + } + + func testDestroyDeallocatesAllBubbles() throws { + let dataSource = StubMXKRoomDataSource() + weak var first = try XCTUnwrap(dataSource.getBubbles()?.first) + weak var last = try XCTUnwrap(dataSource.getBubbles()?.last) + dataSource.destroy() + XCTAssertNil(first) + XCTAssertNil(last) + } + + // MARK: - Collapsing tests + + func testCollapseBubblesWhenProcessingTogether() throws { + let dataSource = try FakeMXKRoomDataSource.make() + try dataSource.queueEvent1() + try dataSource.queueEvent2() + awaitEventProcessing(for: dataSource) + dataSource.verifyCollapsedEvents(2) + } + + func testCollapseBubblesWhenProcessingAlone() throws { + let dataSource = try FakeMXKRoomDataSource.make() + try dataSource.queueEvent1() + awaitEventProcessing(for: dataSource) + try dataSource.queueEvent2() + awaitEventProcessing(for: dataSource) + dataSource.verifyCollapsedEvents(2) + } + + private func awaitEventProcessing(for dataSource: MXKRoomDataSource) { + let e = expectation(description: "The wai-ai-ting is the hardest part") + dataSource.processQueuedEvents { _, _ in + e.fulfill() + } + waitForExpectations(timeout: 2) { error in + XCTAssertNil(error) + } + } + +} + +// MARK: - Test doubles + +private final class StubMXKRoomDataSource: MXKRoomDataSource { + + override init() { + super.init() + + let data1 = MXKRoomBubbleCellData() + let data2 = MXKRoomBubbleCellData() + let data3 = MXKRoomBubbleCellData() + + data1.nextCollapsableCellData = data2 + data2.prevCollapsableCellData = data1 + data2.nextCollapsableCellData = data3 + data3.prevCollapsableCellData = data2 + + replaceBubbles([data1, data2, data3]) + } + +} + +private final class FakeMXKRoomDataSource: MXKRoomDataSource { + + class func make() throws -> FakeMXKRoomDataSource { + let dataSource = try XCTUnwrap(FakeMXKRoomDataSource(roomId: "!foofoofoofoofoofoo:matrix.org", andMatrixSession: nil)) + dataSource.registerCellDataClass(CollapsibleBubbleCellData.self, forCellIdentifier: kMXKRoomBubbleCellDataIdentifier) + dataSource.eventFormatter = CountingEventFormatter(matrixSession: nil) + return dataSource + } + + override var state: MXKDataSourceState { + MXKDataSourceStateReady + } + + override var roomState: MXRoomState! { + nil + } + + func queueEvent1() throws { + try queueEvent(json: #"{"sender":"@alice:matrix.org","content":{"displayname":"bob","membership":"invite"},"origin_server_ts":1616488993287,"state_key":"@bob:matrix.org","room_id":"!foofoofoofoofoofoo:matrix.org","event_id":"$lGK3budX5w009ErtQwE9ZFhwyUUAV9DqEN5yb2fI4Do","type":"m.room.member","unsigned":{"age":1204610,"prev_sender":"@alice:matrix.org","prev_content":{"membership":"leave"},"replaces_state":"$9mQ6RtscXqHCxWqOElI-eP_kwpkuPd2Czm3UHviGoyE"}}"#) + } + + func queueEvent2() throws { + try queueEvent(json: #"{"sender":"@alice:matrix.org","content":{"displayname":"john","membership":"invite"},"origin_server_ts":1616488967295,"state_key":"@john:matrix.org","room_id":"!foofoofoofoofoofoo:matrix.org","event_id":"$-00slfAluxVTP2VWytgDThTmh3nLd0WJD6gzBo2scJM","type":"m.room.member","unsigned":{"age":1712006,"prev_sender":"@alice:matrix.org","prev_content":{"membership":"leave"},"replaces_state":"$NRNkCMKeKK5NtTfWkMfTlMr5Ygw60Q2CQYnJNkbzyrs"}}"#) + } + + private func queueEvent(json: String) throws { + let data = try XCTUnwrap(json.data(using: .utf8)) + let dict = try XCTUnwrap((try JSONSerialization.jsonObject(with: data, options: [])) as? [AnyHashable: Any]) + let event = MXEvent(fromJSON: dict) + queueEvent(forProcessing: event, with: nil, direction: __MXTimelineDirectionForwards) + } + + func verifyCollapsedEvents(_ number: Int) { + let message = getBubbles()?.first?.collapsedAttributedTextMessage.string + XCTAssertEqual(message, "\(number)") + } + +} + +private final class CollapsibleBubbleCellData: MXKRoomBubbleCellData { + + override init() { + super.init() + } + + required init!(event: MXEvent!, andRoomState roomState: MXRoomState!, andRoomDataSource roomDataSource: MXKRoomDataSource!) { + super.init(event: event, andRoomState: roomState, andRoomDataSource: roomDataSource) + collapsable = true + } + + override func collapse(with cellData: MXKRoomBubbleCellDataStoring!) -> Bool { + true + } + +} + +private final class CountingEventFormatter: MXKEventFormatter { + + override func attributedString(from events: [MXEvent]!, with roomState: MXRoomState!, error: UnsafeMutablePointer!) -> NSAttributedString! { + NSAttributedString(string: "\(events.count)") + } + +} diff --git a/RiotTests/MatrixKitTests/UTI/Files/Text.txt b/RiotTests/MatrixKitTests/UTI/Files/Text.txt new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/RiotTests/MatrixKitTests/UTI/Files/Text.txt @@ -0,0 +1 @@ + diff --git a/RiotTests/MatrixKitTests/UTI/MXKUTITests.swift b/RiotTests/MatrixKitTests/UTI/MXKUTITests.swift new file mode 100644 index 000000000..63cedf05e --- /dev/null +++ b/RiotTests/MatrixKitTests/UTI/MXKUTITests.swift @@ -0,0 +1,99 @@ +/* + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import XCTest + +@testable import MatrixKit + +import MobileCoreServices + +class MXKUTITests: XCTestCase { + + override func setUp() { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testUTIFromMimeType() { + guard let uti = MXKUTI(mimeType: "application/pdf") else { + XCTFail("uti should not be nil") + return + } + + let fileExtension = uti.fileExtension?.lowercased() ?? "" + + XCTAssertTrue(uti.isFile) + XCTAssertEqual(fileExtension, "pdf") + } + + func testUTIFromFileExtension() { + let uti = MXKUTI(fileExtension: "pdf") + + let fileExtension = uti.fileExtension?.lowercased() ?? "" + let mimeType = uti.mimeType ?? "" + + XCTAssertTrue(uti.isFile) + XCTAssertEqual(fileExtension, "pdf") + XCTAssertEqual(mimeType, "application/pdf") + } + + func testUTIFromLocalFileURLLoadingResourceValues() { + + let bundle = Bundle(for: type(of: self)) + + guard let localFileURL = bundle.url(forResource: "Text", withExtension: "txt") else { + XCTFail("localFileURL should not be nil") + return + } + + guard let uti = MXKUTI(localFileURL: localFileURL) else { + XCTFail("uti should not be nil") + return + } + + let fileExtension = uti.fileExtension?.lowercased() ?? "" + let mimeType = uti.mimeType ?? "" + + XCTAssertTrue(uti.isFile) + XCTAssertEqual(fileExtension, "txt") + XCTAssertEqual(mimeType, "text/plain") + } + + func testUTIFromLocalFileURL() { + + let bundle = Bundle(for: type(of: self)) + + guard let localFileURL = bundle.url(forResource: "Text", withExtension: "txt") else { + XCTFail("localFileURL should not be nil") + return + } + + guard let uti = MXKUTI(localFileURL: localFileURL, loadResourceValues: false) else { + XCTFail("uti should not be nil") + return + } + + let fileExtension = uti.fileExtension?.lowercased() ?? "" + let mimeType = uti.mimeType ?? "" + + XCTAssertTrue(uti.isFile) + XCTAssertEqual(fileExtension, "txt") + XCTAssertEqual(mimeType, "text/plain") + } +}