Fixed a number of reported bugs, some cleanup, added animated profile support
Added support for animated profile images (no ability to crop/resize) Updated the message trimming to only remove messages if the open group has 2000 messages or more Updated the message trimming setting to default to be on Updated the ContextMenu to fade out the snapshot as well (looked buggy if the device had even minor lag) Updated the ProfileManager to delete and re-download invalid avatar images (and updated the conversation screen to reload when avatars complete downloading) Updated the message request notification logic so it will show notifications when receiving a new message request as long as the user has read all the old ones (previously the user had to accept/reject all the old ones) Fixed a bug where the "trim open group messages" toggle was accessing UI off the main thread Fixed a bug where the "Chats" settings screen had a close button instead of a back button Fixed a bug where the 'viewsToMove' for the reply UI was inconsistent in some places Fixed an issue where the ProfileManager was doing all of it's validation (and writing to disk) within the database write closure which would block database writes unnecessarily Fixed a bug where a message request wouldn't be identified as such just because it wasn't visible in the conversations list Fixed a bug where opening a message request notification would result in the message request being in the wrong state (also wouldn't insert the 'MessageRequestsViewController' into the hierarchy) Fixed a bug where the avatar image wouldn't appear beside incoming closed group message in some situations cases Removed an error log that was essentially just spam Remove the logic to delete old profile images when calling save on a Profile (wouldn't get called if the row was modified directly and duplicates GarbageCollection logic) Remove the logic to send a notification when calling save on a Profile (wouldn't get called if the row was modified directly) Tweaked the message trimming description to be more accurate Cleaned up some duplicate logic used to determine if a notification should be shown Cleaned up some onion request logic (was passing the version info in some cases when not needed) Moved the push notification notify API call into the PushNotificationAPI class for consistency
This commit is contained in:
parent
6b9a19c761
commit
4afddd6fbb
|
@ -261,7 +261,6 @@
|
|||
B8DE1FB626C22FCB0079C9CE /* CallMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8DE1FB526C22FCB0079C9CE /* CallMessage.swift */; };
|
||||
B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8EB20ED2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift */; };
|
||||
B8EB20F02640F7F000773E52 /* OpenGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */; };
|
||||
B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F56425EC8453003BF8D4 /* Notification+Contacts.swift */; };
|
||||
B8F5F58325EC94A6003BF8D4 /* Collection+Subscripting.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */; };
|
||||
B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */; };
|
||||
B8F5F71A25F1B35C003BF8D4 /* MediaPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */; };
|
||||
|
@ -1296,7 +1295,6 @@
|
|||
B8EB20E6263F7E4B00773E52 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
B8EB20ED2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+OpenGroupInvitation.swift"; sourceTree = "<group>"; };
|
||||
B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupInvitationView.swift; sourceTree = "<group>"; };
|
||||
B8F5F56425EC8453003BF8D4 /* Notification+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Contacts.swift"; sourceTree = "<group>"; };
|
||||
B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Subscripting.swift"; sourceTree = "<group>"; };
|
||||
B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtractionNotification.swift; sourceTree = "<group>"; };
|
||||
B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlaceholderView.swift; sourceTree = "<group>"; };
|
||||
|
@ -2630,7 +2628,6 @@
|
|||
FD17D79A27F40ADA00122BE0 /* LegacyDatabase */,
|
||||
FD17D79427F3E03300122BE0 /* Migrations */,
|
||||
FD09796C27FA6C8B00936362 /* Models */,
|
||||
B8F5F56425EC8453003BF8D4 /* Notification+Contacts.swift */,
|
||||
);
|
||||
path = Database;
|
||||
sourceTree = "<group>";
|
||||
|
@ -5176,7 +5173,6 @@
|
|||
FDC438C927BB706500C60D73 /* SendDirectMessageRequest.swift in Sources */,
|
||||
C3A71D1F25589AC30043A11F /* WebSocketResources.pb.swift in Sources */,
|
||||
FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */,
|
||||
B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */,
|
||||
FD09796E27FA6D0000936362 /* Contact.swift in Sources */,
|
||||
C38D5E8D2575011E00B6A65C /* MessageSender+ClosedGroups.swift in Sources */,
|
||||
FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */,
|
||||
|
|
|
@ -152,6 +152,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
|
|||
|
||||
self.contactName = Profile.displayName(db, id: sessionId, threadVariant: .contact)
|
||||
self.profilePicture = ProfileManager.profileAvatar(db, id: sessionId)
|
||||
.map { UIImage(data: $0) }
|
||||
.defaulting(to: Identicon.generatePlaceholderIcon(seed: sessionId, text: self.contactName, size: 300))
|
||||
|
||||
WebRTCSession.current = self.webRTCSession
|
||||
|
|
|
@ -174,6 +174,7 @@ final class ContextMenuVC: UIViewController {
|
|||
animations: { [weak self] in
|
||||
self?.blurView.effect = nil
|
||||
self?.menuView.alpha = 0
|
||||
self?.snapshot.alpha = 0
|
||||
self?.timestampLabel.alpha = 0
|
||||
},
|
||||
completion: { [weak self] _ in
|
||||
|
|
|
@ -185,6 +185,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
|
||||
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: Profile.self,
|
||||
columns: [.profilePictureFileName],
|
||||
joinToPagedType: {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])")
|
||||
}()
|
||||
)
|
||||
],
|
||||
filterSQL: MessageViewModel.filterSQL(threadId: threadId),
|
||||
|
|
|
@ -38,6 +38,15 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
|
||||
// MARK: - UI Components
|
||||
|
||||
private lazy var viewsToMoveForReply: [UIView] = [
|
||||
bubbleView,
|
||||
bubbleBackgroundView,
|
||||
profilePictureView,
|
||||
replyButton,
|
||||
timerView,
|
||||
messageStatusImageView
|
||||
]
|
||||
|
||||
private lazy var profilePictureView: ProfilePictureView = {
|
||||
let result: ProfilePictureView = ProfilePictureView()
|
||||
result.set(.height, to: Values.verySmallProfilePictureSize)
|
||||
|
@ -619,8 +628,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
super.prepareForReuse()
|
||||
|
||||
unloadContent?()
|
||||
let viewsToMove = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView ]
|
||||
viewsToMove.forEach { $0.transform = .identity }
|
||||
viewsToMoveForReply.forEach { $0.transform = .identity }
|
||||
replyButton.alpha = 0
|
||||
timerView.prepareForReuse()
|
||||
}
|
||||
|
@ -726,9 +734,6 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
@objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) {
|
||||
guard let cellViewModel: MessageViewModel = self.viewModel else { return }
|
||||
|
||||
let viewsToMove: [UIView] = [
|
||||
bubbleView, bubbleBackgroundView, profilePictureView, replyButton, timerView, messageStatusImageView
|
||||
]
|
||||
let translationX = gestureRecognizer.translation(in: self).x.clamp(-CGFloat.greatestFiniteMagnitude, 0)
|
||||
|
||||
switch gestureRecognizer.state {
|
||||
|
@ -739,7 +744,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
let damping: CGFloat = 20
|
||||
let sign: CGFloat = -1
|
||||
let x = (damping * (sqrt(abs(translationX)) / sqrt(damping))) * sign
|
||||
viewsToMove.forEach { $0.transform = CGAffineTransform(translationX: x, y: 0) }
|
||||
viewsToMoveForReply.forEach { $0.transform = CGAffineTransform(translationX: x, y: 0) }
|
||||
if timerView.isHidden {
|
||||
replyButton.alpha = abs(translationX) / VisibleMessageCell.maxBubbleTranslationX
|
||||
} else {
|
||||
|
@ -778,10 +783,9 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
}
|
||||
|
||||
private func resetReply() {
|
||||
let viewsToMove = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView ]
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
viewsToMove.forEach { $0.transform = .identity }
|
||||
self.replyButton.alpha = 0
|
||||
UIView.animate(withDuration: 0.25) { [weak self] in
|
||||
self?.viewsToMoveForReply.forEach { $0.transform = .identity }
|
||||
self?.replyButton.alpha = 0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -53,8 +53,6 @@ CGFloat kIconViewLength = 24;
|
|||
return self;
|
||||
}
|
||||
|
||||
[self commonInit];
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
|
@ -65,8 +63,6 @@ CGFloat kIconViewLength = 24;
|
|||
return self;
|
||||
}
|
||||
|
||||
[self commonInit];
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
|
@ -77,32 +73,11 @@ CGFloat kIconViewLength = 24;
|
|||
return self;
|
||||
}
|
||||
|
||||
[self commonInit];
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)commonInit
|
||||
{
|
||||
|
||||
[self observeNotifications];
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
#pragma mark
|
||||
|
||||
- (void)observeNotifications
|
||||
{
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(otherUsersProfileDidChange:)
|
||||
name:NSNotification.otherUsersProfileDidChange
|
||||
object:nil];
|
||||
}
|
||||
|
||||
- (void)configureWithThreadId:(NSString *)threadId threadName:(NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf {
|
||||
self.threadId = threadId;
|
||||
self.threadName = threadName;
|
||||
|
@ -964,9 +939,10 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
#pragma mark - Notifications
|
||||
|
||||
// FIXME: When this screen gets refactored, make sure to observe changes for relevant profile image updates
|
||||
- (void)otherUsersProfileDidChange:(NSNotification *)notification
|
||||
{
|
||||
NSString *recipientId = notification.userInfo[NSNotification.profileRecipientIdKey];
|
||||
NSString *recipientId = @"";//notification.userInfo[NSNotification.profileRecipientIdKey];
|
||||
OWSAssertDebug(recipientId.length > 0);
|
||||
|
||||
if (recipientId.length > 0 && !self.isClosedGroup && !self.isOpenGroup && self.threadId == recipientId) {
|
||||
|
|
|
@ -518,6 +518,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
|
|||
show(
|
||||
threadViewModel.threadId,
|
||||
variant: threadViewModel.threadVariant,
|
||||
isMessageRequest: (threadViewModel.threadIsMessageRequest == true),
|
||||
with: .none,
|
||||
focusedInteractionId: nil,
|
||||
animated: true
|
||||
|
@ -651,6 +652,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
|
|||
func show(
|
||||
_ threadId: String,
|
||||
variant: SessionThread.Variant,
|
||||
isMessageRequest: Bool,
|
||||
with action: ConversationViewModel.Action,
|
||||
focusedInteractionId: Int64?,
|
||||
animated: Bool
|
||||
|
@ -659,8 +661,17 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
|
|||
presentedVC.dismiss(animated: false, completion: nil)
|
||||
}
|
||||
|
||||
let conversationVC: ConversationVC = ConversationVC(threadId: threadId, threadVariant: variant, focusedInteractionId: focusedInteractionId)
|
||||
self.navigationController?.setViewControllers([ self, conversationVC ], animated: animated)
|
||||
let finalViewControllers: [UIViewController] = [
|
||||
self,
|
||||
(isMessageRequest ? MessageRequestsViewController() : nil),
|
||||
ConversationVC(
|
||||
threadId: threadId,
|
||||
threadVariant: variant,
|
||||
focusedInteractionId: focusedInteractionId
|
||||
)
|
||||
].compactMap { $0 }
|
||||
|
||||
self.navigationController?.setViewControllers(finalViewControllers, animated: animated)
|
||||
}
|
||||
|
||||
@objc private func openSettings() {
|
||||
|
|
|
@ -142,14 +142,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
|
||||
AppReadiness.runNowOrWhenAppDidBecomeReady { [weak self] in
|
||||
self?.handleActivation()
|
||||
}
|
||||
|
||||
/// Clear all notifications whenever we become active
|
||||
///
|
||||
/// **Note:** It looks like when opening the app from a notification, `userNotificationCenter(didReceive)` is
|
||||
/// no longer always called before we become active so we need to dispatch this to run on the next run loop
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.clearAllNotificationsAndRestoreBadgeCount()
|
||||
|
||||
/// Clear all notifications whenever we become active once the app is ready
|
||||
///
|
||||
/// **Note:** It looks like when opening the app from a notification, `userNotificationCenter(didReceive)` is
|
||||
/// no longer always called before `applicationDidBecomeActive` we need to trigger the "clear notifications" logic
|
||||
/// within the `runNowOrWhenAppDidBecomeReady` callback and dispatch to the next run loop to ensure it runs after
|
||||
/// the notification has actually been handled
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.clearAllNotificationsAndRestoreBadgeCount()
|
||||
}
|
||||
}
|
||||
|
||||
// On every activation, clear old temp directories.
|
||||
|
|
|
@ -10,15 +10,21 @@ public struct SessionApp {
|
|||
// MARK: - View Convenience Methods
|
||||
|
||||
public static func presentConversation(for threadId: String, action: ConversationViewModel.Action = .none, animated: Bool) {
|
||||
let maybeThread: SessionThread? = Storage.shared.write { db in
|
||||
try SessionThread.fetchOrCreate(db, id: threadId, variant: .contact)
|
||||
let maybeThreadInfo: (thread: SessionThread, isMessageRequest: Bool)? = Storage.shared.write { db in
|
||||
let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: threadId, variant: .contact)
|
||||
|
||||
return (thread, thread.isMessageRequest(db))
|
||||
}
|
||||
|
||||
guard let variant: SessionThread.Variant = maybeThread?.variant else { return }
|
||||
guard
|
||||
let variant: SessionThread.Variant = maybeThreadInfo?.thread.variant,
|
||||
let isMessageRequest: Bool = maybeThreadInfo?.isMessageRequest
|
||||
else { return }
|
||||
|
||||
self.presentConversation(
|
||||
for: threadId,
|
||||
threadVariant: variant,
|
||||
isMessageRequest: isMessageRequest,
|
||||
action: action,
|
||||
focusInteractionId: nil,
|
||||
animated: animated
|
||||
|
@ -28,6 +34,7 @@ public struct SessionApp {
|
|||
public static func presentConversation(
|
||||
for threadId: String,
|
||||
threadVariant: SessionThread.Variant,
|
||||
isMessageRequest: Bool,
|
||||
action: ConversationViewModel.Action,
|
||||
focusInteractionId: Int64?,
|
||||
animated: Bool
|
||||
|
@ -37,6 +44,7 @@ public struct SessionApp {
|
|||
self.presentConversation(
|
||||
for: threadId,
|
||||
threadVariant: threadVariant,
|
||||
isMessageRequest: isMessageRequest,
|
||||
action: action,
|
||||
focusInteractionId: focusInteractionId,
|
||||
animated: animated
|
||||
|
@ -48,6 +56,7 @@ public struct SessionApp {
|
|||
homeViewController.wrappedValue?.show(
|
||||
threadId,
|
||||
variant: threadVariant,
|
||||
isMessageRequest: isMessageRequest,
|
||||
with: action,
|
||||
focusedInteractionId: focusInteractionId,
|
||||
animated: animated
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -659,5 +659,5 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -659,4 +659,4 @@
|
|||
"CHATS_TITLE" = "Chats";
|
||||
"MESSAGE_TRIMMING_TITLE" = "Message Trimming";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app";
|
||||
"MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete messages which are older than 6 months from open groups with over 2,000 messages when starting the app";
|
||||
|
|
|
@ -142,31 +142,11 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
|||
}
|
||||
|
||||
public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) {
|
||||
guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return }
|
||||
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true)
|
||||
|
||||
// If the thread is a message request and the user hasn't hidden message requests then we need
|
||||
// to check if this is the only message request thread (group threads can't be message requests
|
||||
// so just ignore those and if the user has hidden message requests then we want to show the
|
||||
// notification regardless of how many message requests there are)
|
||||
if thread.variant == .contact {
|
||||
if isMessageRequest && !db[.hasHiddenMessageRequests] {
|
||||
let numMessageRequestThreads: Int = (try? SessionThread
|
||||
.messageRequestsQuery(userPublicKey: userPublicKey, includeNonVisible: true)
|
||||
.fetchCount(db))
|
||||
.defaulting(to: 0)
|
||||
|
||||
// Allow this to show a notification if there are no message requests (ie. this is the first one)
|
||||
guard numMessageRequestThreads == 0 else { return }
|
||||
}
|
||||
else if isMessageRequest && db[.hasHiddenMessageRequests] {
|
||||
// If there are other interactions on this thread already then don't show the notification
|
||||
if ((try? thread.interactions.fetchCount(db)) ?? 0) > 1 { return }
|
||||
|
||||
db[.hasHiddenMessageRequests] = false
|
||||
}
|
||||
// Ensure we should be showing a notification for the thread
|
||||
guard thread.shouldShowNotification(db, for: interaction, isMessageRequest: isMessageRequest) else {
|
||||
return
|
||||
}
|
||||
|
||||
let identifier: String = interaction.notificationIdentifier(isBackgroundPoll: isBackgroundPoll)
|
||||
|
@ -180,11 +160,6 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
|||
// see https://developer.apple.com/documentation/uikit/uilocalnotification/1616646-alertbody
|
||||
// for more details.
|
||||
let messageText: String? = String.filterNotificationText(rawMessageText)
|
||||
|
||||
// Don't fire the notification if the current user isn't mentioned
|
||||
// and isOnlyNotifyingForMentions is on.
|
||||
guard !thread.onlyNotifyForMentions || interaction.hasMention else { return }
|
||||
|
||||
let notificationTitle: String?
|
||||
var notificationBody: String?
|
||||
|
||||
|
|
|
@ -170,7 +170,8 @@ final class DisplayNameVC: BaseVC {
|
|||
ProfileManager.updateLocal(
|
||||
queue: DispatchQueue.global(qos: .default),
|
||||
profileName: displayName,
|
||||
avatarImage: nil,
|
||||
image: nil,
|
||||
imageFilePath: nil,
|
||||
requiredSync: false
|
||||
)
|
||||
let pnModeVC = PNModeVC()
|
||||
|
|
|
@ -14,9 +14,6 @@ class ChatSettingsViewController: OWSTableViewController {
|
|||
self.updateTableContents()
|
||||
|
||||
ViewControllerUtilities.setUpDefaultSessionStyle(for: self, title: "CHATS_TITLE".localized(), hasCustomBackButton: false)
|
||||
|
||||
let closeButton: UIBarButtonItem = UIBarButtonItem(image: UIImage(named: "X"), style: .plain, target: self, action: #selector(close(_:)))
|
||||
self.navigationItem.leftBarButtonItem = closeButton
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
|
@ -47,9 +44,11 @@ class ChatSettingsViewController: OWSTableViewController {
|
|||
// MARK: - Actions
|
||||
|
||||
@objc private func didToggleTrimOpenGroupsSwitch(_ sender: UISwitch) {
|
||||
let switchIsOn: Bool = sender.isOn
|
||||
|
||||
Storage.shared.writeAsync(
|
||||
updates: { db in
|
||||
db[.trimOpenGroupMessagesOlderThanSixMonths] = !sender.isOn
|
||||
db[.trimOpenGroupMessagesOlderThanSixMonths] = !switchIsOn
|
||||
},
|
||||
completion: { [weak self] _, _ in
|
||||
self?.updateTableContents()
|
||||
|
|
|
@ -7,7 +7,6 @@ import SessionMessagingKit
|
|||
import SignalUtilitiesKit
|
||||
|
||||
final class SettingsVC: BaseVC, AvatarViewHelperDelegate {
|
||||
private var profilePictureToBeUploaded: UIImage?
|
||||
private var displayNameToBeUploaded: String?
|
||||
private var isEditingDisplayName = false { didSet { handleIsEditingDisplayNameChanged() } }
|
||||
|
||||
|
@ -419,34 +418,47 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
func avatarDidChange(_ image: UIImage) {
|
||||
let maxSize = Int(ProfileManager.maxAvatarDiameter)
|
||||
profilePictureToBeUploaded = image.resizedImage(toFillPixelSize: CGSize(width: maxSize, height: maxSize))
|
||||
updateProfile(isUpdatingDisplayName: false, isUpdatingProfilePicture: true)
|
||||
func avatarDidChange(_ image: UIImage?, filePath: String?) {
|
||||
updateProfile(
|
||||
profilePicture: image,
|
||||
profilePictureFilePath: filePath,
|
||||
isUpdatingDisplayName: false,
|
||||
isUpdatingProfilePicture: true
|
||||
)
|
||||
}
|
||||
|
||||
func clearAvatar() {
|
||||
profilePictureToBeUploaded = nil
|
||||
updateProfile(isUpdatingDisplayName: false, isUpdatingProfilePicture: true)
|
||||
updateProfile(
|
||||
profilePicture: nil,
|
||||
profilePictureFilePath: nil,
|
||||
isUpdatingDisplayName: false,
|
||||
isUpdatingProfilePicture: true
|
||||
)
|
||||
}
|
||||
|
||||
private func updateProfile(isUpdatingDisplayName: Bool, isUpdatingProfilePicture: Bool) {
|
||||
private func updateProfile(
|
||||
profilePicture: UIImage?,
|
||||
profilePictureFilePath: String?,
|
||||
isUpdatingDisplayName: Bool,
|
||||
isUpdatingProfilePicture: Bool
|
||||
) {
|
||||
let userDefaults = UserDefaults.standard
|
||||
let name: String? = (displayNameToBeUploaded ?? Profile.fetchOrCreateCurrentUser().name)
|
||||
let profilePicture: UIImage? = (profilePictureToBeUploaded ?? ProfileManager.profileAvatar(id: getUserHexEncodedPublicKey()))
|
||||
let imageFilePath: String? = (profilePictureFilePath ?? ProfileManager.profileAvatarFilepath(id: getUserHexEncodedPublicKey()))
|
||||
|
||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self, displayNameToBeUploaded, profilePictureToBeUploaded] modalActivityIndicator in
|
||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self, displayNameToBeUploaded] modalActivityIndicator in
|
||||
ProfileManager.updateLocal(
|
||||
queue: DispatchQueue.global(qos: .default),
|
||||
profileName: (name ?? ""),
|
||||
avatarImage: profilePicture,
|
||||
image: profilePicture,
|
||||
imageFilePath: imageFilePath,
|
||||
requiredSync: true,
|
||||
success: { db, updatedProfile in
|
||||
if displayNameToBeUploaded != nil {
|
||||
userDefaults[.lastDisplayNameUpdate] = Date()
|
||||
}
|
||||
|
||||
if profilePictureToBeUploaded != nil {
|
||||
if isUpdatingProfilePicture {
|
||||
userDefaults[.lastProfilePictureUpdate] = Date()
|
||||
}
|
||||
|
||||
|
@ -462,7 +474,6 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate {
|
|||
threadVariant: .contact
|
||||
)
|
||||
self?.displayNameLabel.text = name
|
||||
self?.profilePictureToBeUploaded = nil
|
||||
self?.displayNameToBeUploaded = nil
|
||||
}
|
||||
}
|
||||
|
@ -556,7 +567,12 @@ final class SettingsVC: BaseVC, AvatarViewHelperDelegate {
|
|||
}
|
||||
isEditingDisplayName = false
|
||||
displayNameToBeUploaded = displayName
|
||||
updateProfile(isUpdatingDisplayName: true, isUpdatingProfilePicture: false)
|
||||
updateProfile(
|
||||
profilePicture: nil,
|
||||
profilePictureFilePath: nil,
|
||||
isUpdatingDisplayName: true,
|
||||
isUpdatingProfilePicture: false
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func showEditProfilePictureUI() {
|
||||
|
|
|
@ -13,7 +13,7 @@ NS_ASSUME_NONNULL_BEGIN
|
|||
|
||||
- (nullable NSString *)avatarActionSheetTitle;
|
||||
|
||||
- (void)avatarDidChange:(UIImage *)image;
|
||||
- (void)avatarDidChange:(nullable UIImage *)image filePath:(nullable NSString *)filePath;
|
||||
|
||||
- (UIViewController *)fromViewController;
|
||||
|
||||
|
|
|
@ -123,19 +123,34 @@ NS_ASSUME_NONNULL_BEGIN
|
|||
|
||||
[SNAppearance switchToSessionAppearance];
|
||||
|
||||
|
||||
NSURL* imageURL = [info objectForKey:UIImagePickerControllerImageURL];
|
||||
UIImage *rawAvatar = [info objectForKey:UIImagePickerControllerOriginalImage];
|
||||
|
||||
|
||||
[self.delegate.fromViewController
|
||||
dismissViewControllerAnimated:YES
|
||||
completion:^{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
// Check if the user selected an animated image (if so then don't crop, just
|
||||
// set the avatar directly
|
||||
NSString *type;
|
||||
if ([imageURL getResourceValue:&type forKey:NSURLTypeIdentifierKey error:nil]) {
|
||||
if ([[MIMETypeUtil supportedAnimatedImageUTITypes] containsObject:type]) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.delegate avatarDidChange:nil filePath: imageURL.path];
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (rawAvatar) {
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
CropScaleImageViewController *vc = [[CropScaleImageViewController alloc]
|
||||
initWithSrcImage:rawAvatar
|
||||
successCompletion:^(UIImage *_Nonnull dstImage) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.delegate avatarDidChange:dstImage];
|
||||
[self.delegate avatarDidChange:dstImage filePath:nil];
|
||||
});
|
||||
}];
|
||||
[self.delegate.fromViewController presentViewController:vc
|
||||
|
|
|
@ -17,8 +17,6 @@ import UIKit
|
|||
internal func findFrontmostViewController(ignoringAlerts: Bool) -> UIViewController? {
|
||||
guard let window: UIWindow = CurrentAppContext().mainWindow else { return nil }
|
||||
|
||||
Logger.error("findFrontmostViewController: \(window)")
|
||||
|
||||
guard let viewController: UIViewController = window.rootViewController else {
|
||||
owsFailDebug("Missing root view controller.")
|
||||
return nil
|
||||
|
|
|
@ -1465,6 +1465,9 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
db[.hasSentAMessage] = (legacyPreferences[SMKLegacy.preferencesKeyHasSentAMessageKey] as? Bool == true)
|
||||
db[.isReadyForAppExtensions] = CurrentAppContext().appUserDefaults().bool(forKey: SMKLegacy.preferencesKeyIsReadyForAppExtensions)
|
||||
|
||||
// We want this setting to be on by default
|
||||
db[.trimOpenGroupMessagesOlderThanSixMonths] = true
|
||||
|
||||
Storage.update(progress: 1, for: self, in: target) // In case this is the last migration
|
||||
}
|
||||
|
||||
|
|
|
@ -1049,7 +1049,10 @@ extension Attachment {
|
|||
|
||||
uploadPromise
|
||||
.done(on: queue) { fileId in
|
||||
// Save the final upload info
|
||||
/// Save the final upload info
|
||||
///
|
||||
/// **Note:** We **MUST** use the `.with` function here to ensure the `isValid` flag is
|
||||
/// updated correctly
|
||||
let uploadedAttachment: Attachment? = Storage.shared.write { db in
|
||||
try updatedAttachment?
|
||||
.with(
|
||||
|
|
|
@ -72,31 +72,6 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco
|
|||
)
|
||||
"""
|
||||
}
|
||||
|
||||
// MARK: - PersistableRecord
|
||||
|
||||
public func save(_ db: Database) throws {
|
||||
let oldProfile: Profile? = try? Profile.fetchOne(db, id: id)
|
||||
|
||||
try performSave(db)
|
||||
|
||||
db.afterNextTransactionCommit { db in
|
||||
// Delete old profile picture if needed
|
||||
if let oldProfilePictureFileName: String = oldProfile?.profilePictureFileName, oldProfilePictureFileName != profilePictureFileName {
|
||||
let path: String = ProfileManager.profileAvatarFilepath(filename: oldProfilePictureFileName)
|
||||
|
||||
DispatchQueue.global(qos: .default).async {
|
||||
OWSFileSystem.deleteFileIfExists(path)
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: Remove this once the OWSConversationSettingsViewController has been refactored and is observing DB changes
|
||||
if id != getUserHexEncodedPublicKey(db) {
|
||||
let userInfo = [ Notification.Key.profileRecipientId.rawValue: id ]
|
||||
NotificationCenter.default.post(name: .otherUsersProfileDidChange, object: nil, userInfo: userInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Codable
|
||||
|
|
|
@ -174,7 +174,12 @@ public extension SessionThread {
|
|||
(includeNonVisible || shouldBeVisible) &&
|
||||
variant == .contact &&
|
||||
id != getUserHexEncodedPublicKey(db) && // Note to self
|
||||
(try? Contact.fetchOne(db, id: id))?.isApproved != true
|
||||
(try? Contact
|
||||
.filter(id: id)
|
||||
.select(.isApproved)
|
||||
.asRequest(of: Bool.self)
|
||||
.fetchOne(db))
|
||||
.defaulting(to: false) == false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -196,7 +201,7 @@ public extension SessionThread {
|
|||
"""
|
||||
}
|
||||
|
||||
static func unreadMessageRequestsThreadIdQuery(userPublicKey: String) -> SQLRequest<Int64> {
|
||||
static func unreadMessageRequestsThreadIdQuery(userPublicKey: String, includeNonVisible: Bool = false) -> SQLRequest<String> {
|
||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||
|
@ -210,7 +215,7 @@ public extension SessionThread {
|
|||
)
|
||||
LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])
|
||||
WHERE (
|
||||
\(SessionThread.isMessageRequest(userPublicKey: userPublicKey))
|
||||
\(SessionThread.isMessageRequest(userPublicKey: userPublicKey, includeNonVisible: includeNonVisible))
|
||||
)
|
||||
GROUP BY \(thread[.id])
|
||||
"""
|
||||
|
@ -245,6 +250,50 @@ public extension SessionThread {
|
|||
)
|
||||
}
|
||||
|
||||
func shouldShowNotification(_ db: Database, for interaction: Interaction, isMessageRequest: Bool) -> Bool {
|
||||
// Ensure that the thread isn't muted and either the thread isn't only notifying for mentions
|
||||
// or the user was actually mentioned
|
||||
guard
|
||||
Date().timeIntervalSince1970 > (self.mutedUntilTimestamp ?? 0) &&
|
||||
(
|
||||
self.variant == .contact ||
|
||||
!self.onlyNotifyForMentions ||
|
||||
interaction.hasMention
|
||||
)
|
||||
else { return false }
|
||||
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
|
||||
// No need to notify the user for self-send messages
|
||||
guard interaction.authorId != userPublicKey else { return false }
|
||||
|
||||
// If the thread is a message request then we only want to notify for the first message
|
||||
if self.variant == .contact && isMessageRequest {
|
||||
let hasHiddenMessageRequests: Bool = db[.hasHiddenMessageRequests]
|
||||
|
||||
// If the user hasn't hidden the message requests section then only show the notification if
|
||||
// all the other message request threads have been read
|
||||
if !hasHiddenMessageRequests {
|
||||
let numUnreadMessageRequestThreads: Int = (try? SessionThread
|
||||
.unreadMessageRequestsThreadIdQuery(userPublicKey: userPublicKey, includeNonVisible: true)
|
||||
.fetchCount(db))
|
||||
.defaulting(to: 1)
|
||||
|
||||
guard numUnreadMessageRequestThreads == 1 else { return false }
|
||||
}
|
||||
|
||||
// We only want to show a notification for the first interaction in the thread
|
||||
guard ((try? self.interactions.fetchCount(db)) ?? 0) <= 1 else { return false }
|
||||
|
||||
// Need to re-show the message requests section if it had been hidden
|
||||
if hasHiddenMessageRequests {
|
||||
db[.hasHiddenMessageRequests] = false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
static func displayName(
|
||||
threadId: String,
|
||||
variant: Variant,
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import SessionUtilitiesKit
|
||||
// FIXME: Remove these extensions once the OWSConversationSettingsViewModel is refactored to swift and uses proper database observation
|
||||
public extension Notification.Name {
|
||||
|
||||
static let otherUsersProfileDidChange = Notification.Name("otherUsersProfileDidChange")
|
||||
}
|
||||
|
||||
@objc public extension NSNotification {
|
||||
|
||||
@objc static let otherUsersProfileDidChange = Notification.Name.otherUsersProfileDidChange.rawValue as NSString
|
||||
}
|
||||
|
||||
extension Notification.Key {
|
||||
static let profileRecipientId = Notification.Key("profileRecipientId")
|
||||
}
|
||||
|
||||
@objc public extension NSNotification {
|
||||
static let profileRecipientIdKey = Notification.Key.profileRecipientId.rawValue as NSString
|
||||
}
|
|
@ -16,6 +16,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
public static var requiresThreadId: Bool = false
|
||||
public static let requiresInteractionId: Bool = false
|
||||
public static let approxSixMonthsInSeconds: TimeInterval = (6 * 30 * 24 * 60 * 60)
|
||||
private static let minInteractionsToTrim: Int = 2000
|
||||
|
||||
public static func run(
|
||||
_ job: Job,
|
||||
|
@ -68,6 +69,8 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
if typesToCollect.contains(.oldOpenGroupMessages) && db[.trimOpenGroupMessagesOlderThanSixMonths] {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
let threadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name)
|
||||
let minInteractionsToTrimSql: SQL = SQL("\(GarbageCollectionJob.minInteractionsToTrim)")
|
||||
|
||||
try db.execute(literal: """
|
||||
DELETE FROM \(Interaction.self)
|
||||
|
@ -78,7 +81,17 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
\(SQL("\(thread[.variant]) = \(SessionThread.Variant.openGroup)")) AND
|
||||
\(thread[.id]) = \(interaction[.threadId])
|
||||
)
|
||||
WHERE \(interaction[.timestampMs]) < \(timestampNow - approxSixMonthsInSeconds)
|
||||
JOIN (
|
||||
SELECT
|
||||
COUNT(\(interaction.alias[Column.rowID])) AS interactionCount,
|
||||
\(interaction[.threadId])
|
||||
FROM \(Interaction.self)
|
||||
GROUP BY \(interaction[.threadId])
|
||||
) AS interactionInfo ON interactionInfo.\(threadIdLiteral) = \(interaction[.threadId])
|
||||
WHERE (
|
||||
\(interaction[.timestampMs]) < \(timestampNow - approxSixMonthsInSeconds) AND
|
||||
interactionInfo.interactionCount >= \(minInteractionsToTrimSql)
|
||||
)
|
||||
)
|
||||
""")
|
||||
}
|
||||
|
|
|
@ -17,10 +17,7 @@ public enum NotifyPushServerJob: JobExecutor {
|
|||
failure: @escaping (Job, Error?, Bool) -> (),
|
||||
deferred: @escaping (Job) -> ()
|
||||
) {
|
||||
let server: String = PushNotificationAPI.server
|
||||
|
||||
guard
|
||||
let url: URL = URL(string: "\(server)/notify"),
|
||||
let detailsData: Data = job.details,
|
||||
let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData)
|
||||
else {
|
||||
|
@ -28,34 +25,16 @@ public enum NotifyPushServerJob: JobExecutor {
|
|||
return
|
||||
}
|
||||
|
||||
let requestBody: RequestBody = RequestBody(
|
||||
data: details.message.data.description,
|
||||
sendTo: details.message.recipient
|
||||
)
|
||||
|
||||
guard let body: Data = try? JSONEncoder().encode(requestBody) else {
|
||||
failure(job, HTTP.Error.invalidJSON, true)
|
||||
return
|
||||
}
|
||||
|
||||
var request: URLRequest = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ]
|
||||
request.httpBody = body
|
||||
|
||||
attempt(maxRetryCount: 4, recoveringOn: queue) {
|
||||
OnionRequestAPI
|
||||
.sendOnionRequest(
|
||||
request,
|
||||
to: server,
|
||||
using: .v2,
|
||||
with: PushNotificationAPI.serverPublicKey
|
||||
)
|
||||
.map { _ in }
|
||||
}
|
||||
.done(on: queue) { _ in success(job, false) }
|
||||
.catch(on: queue) { error in failure(job, error, false) }
|
||||
.retainUntilComplete()
|
||||
PushNotificationAPI
|
||||
.notify(
|
||||
recipient: details.message.recipient,
|
||||
with: details.message.data,
|
||||
maxRetryCount: 4,
|
||||
queue: queue
|
||||
)
|
||||
.done(on: queue) { _ in success(job, false) }
|
||||
.catch(on: queue) { error in failure(job, error, false) }
|
||||
.retainUntilComplete()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -65,14 +44,4 @@ extension NotifyPushServerJob {
|
|||
public struct Details: Codable {
|
||||
public let message: SnodeMessage
|
||||
}
|
||||
|
||||
struct RequestBody: Codable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case data
|
||||
case sendTo = "send_to"
|
||||
}
|
||||
|
||||
let data: String
|
||||
let sendTo: String
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,12 +34,14 @@ public enum UpdateProfilePictureJob: JobExecutor {
|
|||
|
||||
// Note: The user defaults flag is updated in ProfileManager
|
||||
let profile: Profile = Profile.fetchOrCreateCurrentUser()
|
||||
let profilePicture: UIImage? = ProfileManager.profileAvatar(id: profile.id)
|
||||
let profileFilePath: String? = profile.profilePictureFileName
|
||||
.map { ProfileManager.profileAvatarFilepath(filename: $0) }
|
||||
|
||||
ProfileManager.updateLocal(
|
||||
queue: queue,
|
||||
profileName: profile.name,
|
||||
avatarImage: profilePicture,
|
||||
image: nil,
|
||||
imageFilePath: profileFilePath,
|
||||
requiredSync: true,
|
||||
success: { _, _ in success(job, false) },
|
||||
failure: { error in failure(job, error, false) }
|
||||
|
|
|
@ -224,6 +224,15 @@ public enum MessageReceiver {
|
|||
default: fatalError()
|
||||
}
|
||||
|
||||
// Perform any required post-handling logic
|
||||
try MessageReceiver.postHandleMessage(db, message: message, openGroupId: openGroupId)
|
||||
}
|
||||
|
||||
public static func postHandleMessage(
|
||||
_ db: Database,
|
||||
message: Message,
|
||||
openGroupId: String?
|
||||
) throws {
|
||||
// When handling any non-typing indicator message we want to make sure the thread becomes
|
||||
// visible (the only other spot this flag gets set is when sending messages)
|
||||
switch message {
|
||||
|
|
|
@ -7,11 +7,21 @@ import SessionUtilitiesKit
|
|||
|
||||
@objc(LKPushNotificationAPI)
|
||||
public final class PushNotificationAPI : NSObject {
|
||||
struct RequestBody: Codable {
|
||||
struct RegistrationRequestBody: Codable {
|
||||
let token: String
|
||||
let pubKey: String?
|
||||
}
|
||||
|
||||
struct NotifyRequestBody: Codable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case data
|
||||
case sendTo = "send_to"
|
||||
}
|
||||
|
||||
let data: String
|
||||
let sendTo: String
|
||||
}
|
||||
|
||||
struct ClosedGroupRequestBody: Codable {
|
||||
let closedGroupPublicKey: String
|
||||
let pubKey: String
|
||||
|
@ -42,7 +52,7 @@ public final class PushNotificationAPI : NSObject {
|
|||
// MARK: - Registration
|
||||
|
||||
public static func unregister(_ token: Data) -> Promise<Void> {
|
||||
let requestBody: RequestBody = RequestBody(token: token.toHexString(), pubKey: nil)
|
||||
let requestBody: RegistrationRequestBody = RegistrationRequestBody(token: token.toHexString(), pubKey: nil)
|
||||
|
||||
guard let body: Data = try? JSONEncoder().encode(requestBody) else {
|
||||
return Promise(error: HTTP.Error.invalidJSON)
|
||||
|
@ -92,7 +102,7 @@ public final class PushNotificationAPI : NSObject {
|
|||
|
||||
public static func register(with token: Data, publicKey: String, isForcedUpdate: Bool) -> Promise<Void> {
|
||||
let hexEncodedToken: String = token.toHexString()
|
||||
let requestBody: RequestBody = RequestBody(token: hexEncodedToken, pubKey: publicKey)
|
||||
let requestBody: RegistrationRequestBody = RegistrationRequestBody(token: hexEncodedToken, pubKey: publicKey)
|
||||
|
||||
guard let body: Data = try? JSONEncoder().encode(requestBody) else {
|
||||
return Promise(error: HTTP.Error.invalidJSON)
|
||||
|
@ -203,4 +213,33 @@ public final class PushNotificationAPI : NSObject {
|
|||
public static func objc_performOperation(_ operation: ClosedGroupOperation, for closedGroupPublicKey: String, publicKey: String) -> AnyPromise {
|
||||
return AnyPromise.from(performOperation(operation, for: closedGroupPublicKey, publicKey: publicKey))
|
||||
}
|
||||
|
||||
// MARK: - Notify
|
||||
|
||||
public static func notify(
|
||||
recipient: String,
|
||||
with message: String,
|
||||
maxRetryCount: UInt? = nil,
|
||||
queue: DispatchQueue = DispatchQueue.global()
|
||||
) -> Promise<Void> {
|
||||
let requestBody: NotifyRequestBody = NotifyRequestBody(data: message, sendTo: recipient)
|
||||
|
||||
guard let body: Data = try? JSONEncoder().encode(requestBody) else {
|
||||
return Promise(error: HTTP.Error.invalidJSON)
|
||||
}
|
||||
|
||||
let url = URL(string: "\(server)/notify")!
|
||||
var request: URLRequest = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ]
|
||||
request.httpBody = body
|
||||
|
||||
let retryCount: UInt = (maxRetryCount ?? PushNotificationAPI.maxRetryCount)
|
||||
let promise: Promise<Void> = attempt(maxRetryCount: retryCount, recoveringOn: queue) {
|
||||
OnionRequestAPI.sendOnionRequest(request, to: server, using: .v2, with: serverPublicKey)
|
||||
.map { _ in }
|
||||
}
|
||||
|
||||
return promise
|
||||
}
|
||||
}
|
||||
|
|
|
@ -366,9 +366,10 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
|
|||
// Only incoming messages
|
||||
(self.variant == .standardIncoming || self.variant == .standardIncomingDeleted) &&
|
||||
|
||||
// Show if the next message has a different sender or has a "date break"
|
||||
// Show if the next message has a different sender, isn't a standard message or has a "date break"
|
||||
(
|
||||
self.authorId != nextModel?.authorId ||
|
||||
(nextModel?.variant != .standardIncoming && nextModel?.variant != .standardIncomingDeleted) ||
|
||||
shouldShowDateOnNextModel
|
||||
) &&
|
||||
|
||||
|
|
|
@ -574,7 +574,6 @@ public extension SessionThreadViewModel {
|
|||
|
||||
(\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey),
|
||||
(
|
||||
\(thread[.shouldBeVisible]) = true AND
|
||||
\(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND
|
||||
\(SQL("\(thread[.id]) != \(userPublicKey)")) AND
|
||||
IFNULL(\(contact[.isApproved]), false) = false
|
||||
|
|
|
@ -12,7 +12,7 @@ public struct ProfileManager {
|
|||
private static let nameDataLength: UInt = 26
|
||||
public static let maxAvatarDiameter: CGFloat = 640
|
||||
|
||||
private static var profileAvatarCache: Atomic<[String: UIImage]> = Atomic([:])
|
||||
private static var profileAvatarCache: Atomic<[String: Data]> = Atomic([:])
|
||||
private static var currentAvatarDownloads: Atomic<Set<String>> = Atomic([])
|
||||
|
||||
// MARK: - Functions
|
||||
|
@ -21,7 +21,7 @@ public struct ProfileManager {
|
|||
return ((profileName.data(using: .utf8)?.count ?? 0) > nameDataLength)
|
||||
}
|
||||
|
||||
public static func profileAvatar(_ db: Database? = nil, id: String) -> UIImage? {
|
||||
public static func profileAvatar(_ db: Database? = nil, id: String) -> Data? {
|
||||
guard let db: Database = db else {
|
||||
return Storage.shared.read { db in profileAvatar(db, id: id) }
|
||||
}
|
||||
|
@ -30,9 +30,9 @@ public struct ProfileManager {
|
|||
return profileAvatar(profile: profile)
|
||||
}
|
||||
|
||||
public static func profileAvatar(profile: Profile) -> UIImage? {
|
||||
public static func profileAvatar(profile: Profile) -> Data? {
|
||||
if let profileFileName: String = profile.profilePictureFileName, !profileFileName.isEmpty {
|
||||
return loadProfileAvatar(for: profileFileName)
|
||||
return loadProfileAvatar(for: profileFileName, profile: profile)
|
||||
}
|
||||
|
||||
if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty {
|
||||
|
@ -42,22 +42,36 @@ public struct ProfileManager {
|
|||
return nil
|
||||
}
|
||||
|
||||
private static func loadProfileAvatar(for fileName: String) -> UIImage? {
|
||||
if let cachedImage: UIImage = profileAvatarCache.wrappedValue[fileName] {
|
||||
return cachedImage
|
||||
private static func loadProfileAvatar(for fileName: String, profile: Profile) -> Data? {
|
||||
if let cachedImageData: Data = profileAvatarCache.wrappedValue[fileName] {
|
||||
return cachedImageData
|
||||
}
|
||||
|
||||
guard
|
||||
!fileName.isEmpty,
|
||||
let data: Data = loadProfileData(with: fileName),
|
||||
data.isValidImage,
|
||||
let image: UIImage = UIImage(data: data)
|
||||
data.isValidImage
|
||||
else {
|
||||
// If we can't load the avatar or it's an invalid/corrupted image then clear out
|
||||
// the 'profilePictureFileName' and try to re-download
|
||||
Storage.shared.writeAsync(
|
||||
updates: { db in
|
||||
_ = try? Profile
|
||||
.filter(id: profile.id)
|
||||
.updateAll(db, Profile.Columns.profilePictureFileName.set(to: nil))
|
||||
},
|
||||
completion: { _, _ in
|
||||
// Try to re-download the avatar if it has a URL
|
||||
if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty {
|
||||
downloadAvatar(for: profile)
|
||||
}
|
||||
}
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
profileAvatarCache.mutate { $0[fileName] = image }
|
||||
return image
|
||||
profileAvatarCache.mutate { $0[fileName] = data }
|
||||
return data
|
||||
}
|
||||
|
||||
private static func loadProfileData(with fileName: String) -> Data? {
|
||||
|
@ -98,6 +112,20 @@ public struct ProfileManager {
|
|||
return path
|
||||
}()
|
||||
|
||||
public static func profileAvatarFilepath(_ db: Database? = nil, id: String) -> String? {
|
||||
guard let db: Database = db else {
|
||||
return Storage.shared.read { db in profileAvatarFilepath(db, id: id) }
|
||||
}
|
||||
|
||||
let maybeFileName: String? = try? Profile
|
||||
.filter(id: id)
|
||||
.select(.profilePictureFileName)
|
||||
.asRequest(of: String.self)
|
||||
.fetchOne(db)
|
||||
|
||||
return maybeFileName.map { ProfileManager.profileAvatarFilepath(filename: $0) }
|
||||
}
|
||||
|
||||
public static func profileAvatarFilepath(filename: String) -> String {
|
||||
guard !filename.isEmpty else { return "" }
|
||||
|
||||
|
@ -148,45 +176,46 @@ public struct ProfileManager {
|
|||
.done(on: queue) { data in
|
||||
currentAvatarDownloads.mutate { $0.remove(profile.id) }
|
||||
|
||||
guard let latestProfile: Profile = Storage.shared.read({ db in try Profile.fetchOne(db, id: profile.id) }) else {
|
||||
return
|
||||
}
|
||||
|
||||
guard
|
||||
let latestProfileKey: OWSAES256Key = latestProfile.profileEncryptionKey,
|
||||
!latestProfileKey.keyData.isEmpty,
|
||||
latestProfileKey == profileKeyAtStart
|
||||
else {
|
||||
OWSLogger.warn("Ignoring avatar download for obsolete user profile.")
|
||||
return
|
||||
}
|
||||
|
||||
guard profileUrlStringAtStart == latestProfile.profilePictureUrl else {
|
||||
OWSLogger.warn("Avatar url has changed during download.")
|
||||
|
||||
if latestProfile.profilePictureUrl?.isEmpty == false {
|
||||
self.downloadAvatar(for: latestProfile)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let decryptedData: Data = decryptProfileData(data: data, key: profileKeyAtStart) else {
|
||||
OWSLogger.warn("Avatar data for \(profile.id) could not be decrypted.")
|
||||
return
|
||||
}
|
||||
|
||||
try? decryptedData.write(to: URL(fileURLWithPath: filePath), options: [.atomic])
|
||||
|
||||
guard UIImage(contentsOfFile: filePath) != nil else {
|
||||
OWSLogger.warn("Avatar image for \(profile.id) could not be loaded.")
|
||||
return
|
||||
}
|
||||
|
||||
// Store the updated 'profilePictureFileName'
|
||||
Storage.shared.write { db in
|
||||
guard let latestProfile: Profile = try Profile.fetchOne(db, id: profile.id) else {
|
||||
return
|
||||
}
|
||||
|
||||
guard
|
||||
let latestProfileKey: OWSAES256Key = latestProfile.profileEncryptionKey,
|
||||
!latestProfileKey.keyData.isEmpty,
|
||||
latestProfileKey == profileKeyAtStart
|
||||
else {
|
||||
OWSLogger.warn("Ignoring avatar download for obsolete user profile.")
|
||||
return
|
||||
}
|
||||
|
||||
guard profileUrlStringAtStart == latestProfile.profilePictureUrl else {
|
||||
OWSLogger.warn("Avatar url has changed during download.")
|
||||
|
||||
if latestProfile.profilePictureUrl?.isEmpty == false {
|
||||
self.downloadAvatar(for: latestProfile)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let decryptedData: Data = decryptProfileData(data: data, key: profileKeyAtStart) else {
|
||||
OWSLogger.warn("Avatar data for \(profile.id) could not be decrypted.")
|
||||
return
|
||||
}
|
||||
|
||||
try? decryptedData.write(to: URL(fileURLWithPath: filePath), options: [.atomic])
|
||||
|
||||
guard let image: UIImage = UIImage(contentsOfFile: filePath) else {
|
||||
OWSLogger.warn("Avatar image for \(profile.id) could not be loaded.")
|
||||
return
|
||||
}
|
||||
|
||||
_ = try? Profile
|
||||
.filter(id: profile.id)
|
||||
.updateAll(db, Profile.Columns.profilePictureFileName.set(to: fileName))
|
||||
profileAvatarCache.mutate { $0[fileName] = image }
|
||||
profileAvatarCache.mutate { $0[fileName] = decryptedData }
|
||||
}
|
||||
|
||||
// Redundant but without reading 'backgroundTask' it will warn that the variable
|
||||
|
@ -209,7 +238,8 @@ public struct ProfileManager {
|
|||
public static func updateLocal(
|
||||
queue: DispatchQueue,
|
||||
profileName: String,
|
||||
avatarImage: UIImage?,
|
||||
image: UIImage?,
|
||||
imageFilePath: String?,
|
||||
requiredSync: Bool,
|
||||
success: ((Database, Profile) throws -> ())? = nil,
|
||||
failure: ((ProfileManagerError) -> ())? = nil
|
||||
|
@ -218,8 +248,60 @@ public struct ProfileManager {
|
|||
// If the profile avatar was updated or removed then encrypt with a new profile key
|
||||
// to ensure that other users know that our profile picture was updated
|
||||
let newProfileKey: OWSAES256Key = OWSAES256Key.generateRandom()
|
||||
let maxAvatarBytes: UInt = (5 * 1000 * 1000)
|
||||
let avatarImageData: Data?
|
||||
|
||||
guard let avatarImage: UIImage = avatarImage else {
|
||||
do {
|
||||
avatarImageData = try {
|
||||
guard var image: UIImage = image else {
|
||||
guard let imageFilePath: String = imageFilePath else { return nil }
|
||||
|
||||
let data: Data = try Data(contentsOf: URL(fileURLWithPath: imageFilePath))
|
||||
|
||||
guard data.count <= maxAvatarBytes else {
|
||||
// Our avatar dimensions are so small that it's incredibly unlikely we wouldn't
|
||||
// be able to fit our profile photo (eg. generating pure noise at our resolution
|
||||
// compresses to ~200k)
|
||||
SNLog("Animated profile avatar was too large.")
|
||||
SNLog("Updating service with profile failed.")
|
||||
throw ProfileManagerError.avatarUploadMaxFileSizeExceeded
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
if image.size.width != maxAvatarDiameter || image.size.height != maxAvatarDiameter {
|
||||
// To help ensure the user is being shown the same cropping of their avatar as
|
||||
// everyone else will see, we want to be sure that the image was resized before this point.
|
||||
SNLog("Avatar image should have been resized before trying to upload")
|
||||
image = image.resizedImage(toFillPixelSize: CGSize(width: maxAvatarDiameter, height: maxAvatarDiameter))
|
||||
}
|
||||
|
||||
guard let data: Data = image.jpegData(compressionQuality: 0.95) else {
|
||||
SNLog("Updating service with profile failed.")
|
||||
throw ProfileManagerError.avatarWriteFailed
|
||||
}
|
||||
|
||||
guard data.count <= maxAvatarBytes else {
|
||||
// Our avatar dimensions are so small that it's incredibly unlikely we wouldn't
|
||||
// be able to fit our profile photo (eg. generating pure noise at our resolution
|
||||
// compresses to ~200k)
|
||||
SNLog("Suprised to find profile avatar was too large. Was it scaled properly? image: \(image)")
|
||||
SNLog("Updating service with profile failed.")
|
||||
throw ProfileManagerError.avatarUploadMaxFileSizeExceeded
|
||||
}
|
||||
|
||||
return data
|
||||
}()
|
||||
}
|
||||
catch {
|
||||
if let profileManagerError: ProfileManagerError = error as? ProfileManagerError {
|
||||
failure?(profileManagerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let data: Data = avatarImageData else {
|
||||
// If we have no image then we need to make sure to remove it from the profile
|
||||
Storage.shared.writeAsync { db in
|
||||
let existingProfile: Profile = Profile.fetchOrCreateCurrentUser(db)
|
||||
|
@ -255,39 +337,18 @@ public struct ProfileManager {
|
|||
|
||||
// If we have a new avatar image, we must first:
|
||||
//
|
||||
// * Encode it to JPEG.
|
||||
// * Write it to disk.
|
||||
// * Encrypt it
|
||||
// * Upload it to asset service
|
||||
// * Send asset service info to Signal Service
|
||||
OWSLogger.verbose("Updating local profile on service with new avatar.")
|
||||
let maxAvatarBytes: UInt = (5 * 1000 * 1000)
|
||||
var image: UIImage = avatarImage
|
||||
|
||||
if image.size.width != maxAvatarDiameter || image.size.height != maxAvatarDiameter {
|
||||
// To help ensure the user is being shown the same cropping of their avatar as
|
||||
// everyone else will see, we want to be sure that the image was resized before this point.
|
||||
SNLog("Avatar image should have been resized before trying to upload")
|
||||
image = image.resizedImage(toFillPixelSize: CGSize(width: maxAvatarDiameter, height: maxAvatarDiameter))
|
||||
}
|
||||
|
||||
guard let data: Data = image.jpegData(compressionQuality: 0.95) else {
|
||||
SNLog("Updating service with profile failed.")
|
||||
failure?(.avatarWriteFailed)
|
||||
return
|
||||
}
|
||||
|
||||
guard data.count <= maxAvatarBytes else {
|
||||
// Our avatar dimensions are so small that it's incredibly unlikely we wouldn't
|
||||
// be able to fit our profile photo (eg. generating pure noise at our resolution
|
||||
// compresses to ~200k)
|
||||
SNLog("Suprised to find profile avatar was too large. Was it scaled properly? image: \(image)")
|
||||
SNLog("Updating service with profile failed.")
|
||||
failure?(.avatarImageTooLarge)
|
||||
return
|
||||
}
|
||||
|
||||
let fileName: String = UUID().uuidString.appendingFileExtension("jpg")
|
||||
let fileName: String = UUID().uuidString
|
||||
.appendingFileExtension(
|
||||
imageFilePath
|
||||
.map { URL(fileURLWithPath: $0).pathExtension }
|
||||
.defaulting(to: "jpg")
|
||||
)
|
||||
let filePath: String = ProfileManager.profileAvatarFilepath(filename: fileName)
|
||||
|
||||
// Write the avatar to disk
|
||||
|
@ -324,7 +385,7 @@ public struct ProfileManager {
|
|||
.saved(db)
|
||||
|
||||
// Update the cached avatar image value
|
||||
profileAvatarCache.mutate { $0[fileName] = avatarImage }
|
||||
profileAvatarCache.mutate { $0[fileName] = data }
|
||||
|
||||
SNLog("Successfully updated service with profile.")
|
||||
try success?(db, profile)
|
||||
|
|
|
@ -10,43 +10,14 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol {
|
|||
private var notifications: [String: UNNotificationRequest] = [:]
|
||||
|
||||
public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) {
|
||||
guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return }
|
||||
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true)
|
||||
|
||||
// If the thread is a message request and the user hasn't hidden message requests then we need
|
||||
// to check if this is the only message request thread (group threads can't be message requests
|
||||
// so just ignore those and if the user has hidden message requests then we want to show the
|
||||
// notification regardless of how many message requests there are)
|
||||
if thread.variant == .contact {
|
||||
if isMessageRequest && !db[.hasHiddenMessageRequests] {
|
||||
let numMessageRequestThreads: Int = (try? SessionThread
|
||||
.messageRequestsQuery(userPublicKey: userPublicKey, includeNonVisible: true)
|
||||
.fetchCount(db))
|
||||
.defaulting(to: 0)
|
||||
|
||||
// Allow this to show a notification if there are no message requests (ie. this is the first one)
|
||||
guard numMessageRequestThreads == 0 else { return }
|
||||
}
|
||||
else if isMessageRequest && db[.hasHiddenMessageRequests] {
|
||||
// If there are other interactions on this thread already then don't show the notification
|
||||
if ((try? thread.interactions.fetchCount(db)) ?? 0) > 1 { return }
|
||||
|
||||
db[.hasHiddenMessageRequests] = false
|
||||
}
|
||||
}
|
||||
|
||||
let senderPublicKey: String = interaction.authorId
|
||||
|
||||
guard senderPublicKey != userPublicKey else {
|
||||
// Ignore PNs for messages sent by the current user
|
||||
// after handling the message. Otherwise the closed
|
||||
// group self-send messages won't show.
|
||||
// Ensure we should be showing a notification for the thread
|
||||
guard thread.shouldShowNotification(db, for: interaction, isMessageRequest: isMessageRequest) else {
|
||||
return
|
||||
}
|
||||
|
||||
let senderName: String = Profile.displayName(db, id: senderPublicKey, threadVariant: thread.variant)
|
||||
let senderName: String = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant)
|
||||
var notificationTitle: String = senderName
|
||||
|
||||
if thread.variant == .closedGroup || thread.variant == .openGroup {
|
||||
|
|
|
@ -67,16 +67,26 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension
|
|||
return
|
||||
}
|
||||
|
||||
let maybeVariant: SessionThread.Variant? = processedMessage.threadId
|
||||
.map { threadId in
|
||||
try? SessionThread
|
||||
.filter(id: threadId)
|
||||
.select(.variant)
|
||||
.asRequest(of: SessionThread.Variant.self)
|
||||
.fetchOne(db)
|
||||
}
|
||||
let isOpenGroup: Bool = (maybeVariant == .openGroup)
|
||||
|
||||
switch processedMessage.messageInfo.message {
|
||||
case let visibleMessage as VisibleMessage:
|
||||
let interactionId: Int64 = try MessageReceiver.handleVisibleMessage(
|
||||
db,
|
||||
message: visibleMessage,
|
||||
associatedWithProto: processedMessage.proto,
|
||||
openGroupId: nil,
|
||||
openGroupId: (isOpenGroup ? processedMessage.threadId : nil),
|
||||
isBackgroundPoll: false
|
||||
)
|
||||
|
||||
|
||||
// Remove the notifications if there is an outgoing messages from a linked device
|
||||
if
|
||||
let interaction: Interaction = try? Interaction.fetchOne(db, id: interactionId),
|
||||
|
@ -127,6 +137,13 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension
|
|||
|
||||
default: break
|
||||
}
|
||||
|
||||
// Perform any required post-handling logic
|
||||
try MessageReceiver.postHandleMessage(
|
||||
db,
|
||||
message: processedMessage.messageInfo.message,
|
||||
openGroupId: (isOpenGroup ? processedMessage.threadId : nil)
|
||||
)
|
||||
}
|
||||
catch {
|
||||
if let error = error as? MessageReceiverError, error.isRetryable {
|
||||
|
|
|
@ -16,26 +16,21 @@ internal extension OnionRequestAPI {
|
|||
}
|
||||
|
||||
/// Encrypts `payload` for `destination` and returns the result. Use this to build the core of an onion request.
|
||||
static func encrypt(_ payload: Data, for destination: OnionRequestAPIDestination, with version: OnionRequestAPIVersion) -> Promise<AESGCM.EncryptionResult> {
|
||||
static func encrypt(_ payload: Data, for destination: OnionRequestAPIDestination) -> Promise<AESGCM.EncryptionResult> {
|
||||
let (promise, seal) = Promise<AESGCM.EncryptionResult>.pending()
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
let data: Data
|
||||
|
||||
switch version {
|
||||
case .v2, .v3:
|
||||
// Wrapping is only needed for snode requests
|
||||
switch destination {
|
||||
case .snode: data = try encode(ciphertext: payload, json: [ "headers" : "" ])
|
||||
case .server: data = payload
|
||||
}
|
||||
switch destination {
|
||||
case .snode(let snode):
|
||||
// Need to wrap the payload for snode requests
|
||||
let data: Data = try encode(ciphertext: payload, json: [ "headers" : "" ])
|
||||
let result: AESGCM.EncryptionResult = try AESGCM.encrypt(data, for: snode.x25519PublicKey)
|
||||
seal.fulfill(result)
|
||||
|
||||
case .v4:
|
||||
data = payload
|
||||
case .server(_, _, let serverX25519PublicKey, _, _):
|
||||
let result: AESGCM.EncryptionResult = try AESGCM.encrypt(payload, for: serverX25519PublicKey)
|
||||
seal.fulfill(result)
|
||||
}
|
||||
|
||||
let result = try encrypt(data, for: destination)
|
||||
seal.fulfill(result)
|
||||
}
|
||||
catch (let error) {
|
||||
seal.reject(error)
|
||||
|
@ -44,16 +39,6 @@ internal extension OnionRequestAPI {
|
|||
|
||||
return promise
|
||||
}
|
||||
|
||||
private static func encrypt(_ payload: Data, for destination: OnionRequestAPIDestination) throws -> AESGCM.EncryptionResult {
|
||||
switch destination {
|
||||
case .snode(let snode):
|
||||
return try AESGCM.encrypt(payload, for: snode.x25519PublicKey)
|
||||
|
||||
case .server(_, _, let serverX25519PublicKey, _, _):
|
||||
return try AESGCM.encrypt(payload, for: serverX25519PublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypts the previous encryption result (i.e. that of the hop after this one) for this hop. Use this to build the layers of an onion request.
|
||||
static func encryptHop(from lhs: OnionRequestAPIDestination, to rhs: OnionRequestAPIDestination, using previousEncryptionResult: AESGCM.EncryptionResult) -> Promise<AESGCM.EncryptionResult> {
|
||||
|
|
|
@ -7,7 +7,7 @@ import PromiseKit
|
|||
import SessionUtilitiesKit
|
||||
|
||||
public protocol OnionRequestAPIType {
|
||||
static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, using version: OnionRequestAPIVersion, associatedWith publicKey: String?) -> Promise<Data>
|
||||
static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, associatedWith publicKey: String?) -> Promise<Data>
|
||||
static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPIVersion, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)>
|
||||
}
|
||||
|
||||
|
@ -310,7 +310,7 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
}
|
||||
|
||||
/// Builds an onion around `payload` and returns the result.
|
||||
private static func buildOnion(around payload: Data, targetedAt destination: OnionRequestAPIDestination, version: OnionRequestAPIVersion) -> Promise<OnionBuildingResult> {
|
||||
private static func buildOnion(around payload: Data, targetedAt destination: OnionRequestAPIDestination) -> Promise<OnionBuildingResult> {
|
||||
var guardSnode: Snode!
|
||||
var targetSnodeSymmetricKey: Data! // Needed by invoke(_:on:with:) to decrypt the response sent back by the destination
|
||||
var encryptionResult: AESGCM.EncryptionResult!
|
||||
|
@ -323,7 +323,7 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
guardSnode = path.first!
|
||||
|
||||
// Encrypt in reverse order, i.e. the destination first
|
||||
return encrypt(payload, for: destination, with: version)
|
||||
return encrypt(payload, for: destination)
|
||||
.then2 { r -> Promise<AESGCM.EncryptionResult> in
|
||||
targetSnodeSymmetricKey = r.symmetricKey
|
||||
|
||||
|
@ -356,14 +356,15 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
// MARK: - Public API
|
||||
|
||||
/// Sends an onion request to `snode`. Builds new paths as needed.
|
||||
public static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, using version: OnionRequestAPIVersion, associatedWith publicKey: String? = nil) -> Promise<Data> {
|
||||
public static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, associatedWith publicKey: String? = nil) -> Promise<Data> {
|
||||
let payloadJson: JSON = [ "method" : method.rawValue, "params" : parameters ]
|
||||
|
||||
guard let payload: Data = try? JSONSerialization.data(withJSONObject: payloadJson, options: []) else {
|
||||
return Promise(error: HTTP.Error.invalidJSON)
|
||||
}
|
||||
|
||||
return sendOnionRequest(with: payload, to: OnionRequestAPIDestination.snode(snode), version: version)
|
||||
/// **Note:** Currently the service nodes only support V3 Onion Requests
|
||||
return sendOnionRequest(with: payload, to: OnionRequestAPIDestination.snode(snode), version: .v3)
|
||||
.map { _, maybeData in
|
||||
guard let data: Data = maybeData else { throw HTTP.Error.invalidResponse }
|
||||
|
||||
|
@ -410,42 +411,43 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
var guardSnode: Snode?
|
||||
|
||||
Threading.workQueue.async { // Avoid race conditions on `guardSnodes` and `paths`
|
||||
buildOnion(around: payload, targetedAt: destination, version: version).done2 { intermediate in
|
||||
guardSnode = intermediate.guardSnode
|
||||
let url = "\(guardSnode!.address):\(guardSnode!.port)/onion_req/v2"
|
||||
let finalEncryptionResult = intermediate.finalEncryptionResult
|
||||
let onion = finalEncryptionResult.ciphertext
|
||||
if case OnionRequestAPIDestination.server = destination, Double(onion.count) > 0.75 * Double(maxRequestSize) {
|
||||
SNLog("Approaching request size limit: ~\(onion.count) bytes.")
|
||||
}
|
||||
let parameters: JSON = [
|
||||
"ephemeral_key" : finalEncryptionResult.ephemeralPublicKey.toHexString()
|
||||
]
|
||||
let body: Data
|
||||
do {
|
||||
body = try encode(ciphertext: onion, json: parameters)
|
||||
} catch {
|
||||
return seal.reject(error)
|
||||
}
|
||||
let destinationSymmetricKey = intermediate.destinationSymmetricKey
|
||||
|
||||
HTTP.execute(.post, url, body: body)
|
||||
.done2 { responseData in
|
||||
handleResponse(
|
||||
responseData: responseData,
|
||||
destinationSymmetricKey: destinationSymmetricKey,
|
||||
version: version,
|
||||
destination: destination,
|
||||
seal: seal
|
||||
)
|
||||
buildOnion(around: payload, targetedAt: destination)
|
||||
.done2 { intermediate in
|
||||
guardSnode = intermediate.guardSnode
|
||||
let url = "\(guardSnode!.address):\(guardSnode!.port)/onion_req/v2"
|
||||
let finalEncryptionResult = intermediate.finalEncryptionResult
|
||||
let onion = finalEncryptionResult.ciphertext
|
||||
if case OnionRequestAPIDestination.server = destination, Double(onion.count) > 0.75 * Double(maxRequestSize) {
|
||||
SNLog("Approaching request size limit: ~\(onion.count) bytes.")
|
||||
}
|
||||
.catch2 { error in
|
||||
seal.reject(error)
|
||||
let parameters: JSON = [
|
||||
"ephemeral_key" : finalEncryptionResult.ephemeralPublicKey.toHexString()
|
||||
]
|
||||
let body: Data
|
||||
do {
|
||||
body = try encode(ciphertext: onion, json: parameters)
|
||||
} catch {
|
||||
return seal.reject(error)
|
||||
}
|
||||
}
|
||||
.catch2 { error in
|
||||
seal.reject(error)
|
||||
}
|
||||
let destinationSymmetricKey = intermediate.destinationSymmetricKey
|
||||
|
||||
HTTP.execute(.post, url, body: body)
|
||||
.done2 { responseData in
|
||||
handleResponse(
|
||||
responseData: responseData,
|
||||
destinationSymmetricKey: destinationSymmetricKey,
|
||||
version: version,
|
||||
destination: destination,
|
||||
seal: seal
|
||||
)
|
||||
}
|
||||
.catch2 { error in
|
||||
seal.reject(error)
|
||||
}
|
||||
}
|
||||
.catch2 { error in
|
||||
seal.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
promise.catch2 { error in // Must be invoked on Threading.workQueue
|
||||
|
|
|
@ -134,7 +134,6 @@ public final class SnodeAPI {
|
|||
to: snode,
|
||||
invoking: method,
|
||||
with: parameters,
|
||||
using: .v3,
|
||||
associatedWith: publicKey
|
||||
)
|
||||
.map2 { responseData in
|
||||
|
@ -207,7 +206,6 @@ public final class SnodeAPI {
|
|||
attempt(maxRetryCount: 4, recoveringOn: Threading.workQueue) {
|
||||
HTTP.execute(.post, url, parameters: parameters, useSeedNodeURLSession: true)
|
||||
.map2 { responseData -> Set<Snode> in
|
||||
// TODO: Validate this works
|
||||
guard let snodePool: SnodePoolResponse = try? JSONDecoder().decode(SnodePoolResponse.self, from: responseData) else {
|
||||
throw SnodeAPIError.snodePoolUpdatingFailed
|
||||
}
|
||||
|
@ -261,7 +259,6 @@ public final class SnodeAPI {
|
|||
|
||||
return invoke(.oxenDaemonRPCCall, on: snode, parameters: parameters)
|
||||
.map2 { responseData in
|
||||
// TODO: Validate this works
|
||||
guard let snodePool: SnodePoolResponse = try? JSONDecoder().decode(SnodePoolResponse.self, from: responseData) else {
|
||||
throw SnodeAPIError.snodePoolUpdatingFailed
|
||||
}
|
||||
|
|
|
@ -258,7 +258,6 @@ public final class Storage {
|
|||
defer { keySpec.resetBytes(in: 0..<keySpec.count) } // Reset content immediately after use
|
||||
|
||||
try SSKDefaultKeychainStorage.shared.set(data: keySpec, service: keychainService, key: dbCipherKeySpecKey)
|
||||
print("RAWR new keySpec generated and saved")
|
||||
return keySpec
|
||||
}
|
||||
catch {
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import YYImage
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
|
@ -112,8 +113,8 @@ public final class ProfilePictureView: UIView {
|
|||
guard !publicKey.isEmpty || openGroupProfilePicture != nil else { return }
|
||||
|
||||
func getProfilePicture(of size: CGFloat, for publicKey: String, profile: Profile?) -> (image: UIImage, isTappable: Bool) {
|
||||
if let profile: Profile = profile, let profilePicture: UIImage = ProfileManager.profileAvatar(profile: profile) {
|
||||
return (profilePicture, true)
|
||||
if let profile: Profile = profile, let profileData: Data = ProfileManager.profileAvatar(profile: profile), let image: YYImage = YYImage(data: profileData) {
|
||||
return (image, true)
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -179,7 +180,7 @@ public final class ProfilePictureView: UIView {
|
|||
hasTappableProfilePicture = isTappable
|
||||
}
|
||||
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.backgroundColor = Colors.unimportant
|
||||
imageView.layer.cornerRadius = (targetSize / 2)
|
||||
additionalImageView.layer.cornerRadius = (targetSize / 2)
|
||||
|
@ -187,11 +188,11 @@ public final class ProfilePictureView: UIView {
|
|||
|
||||
// MARK: - Convenience
|
||||
|
||||
private func getImageView() -> UIImageView {
|
||||
let result = UIImageView()
|
||||
private func getImageView() -> YYAnimatedImageView {
|
||||
let result = YYAnimatedImageView()
|
||||
result.layer.masksToBounds = true
|
||||
result.backgroundColor = Colors.unimportant
|
||||
result.contentMode = .scaleAspectFit
|
||||
result.contentMode = .scaleAspectFill
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue