From 008d5e2e00687331856a431a85601fd3f0c75cd6 Mon Sep 17 00:00:00 2001 From: fanyu Date: Tue, 24 May 2022 20:34:54 +0800 Subject: [PATCH 1/6] Send multiple images --- Mixin.xcodeproj/project.pbxproj | 24 ++ .../ic_video_bold.imageset/Contents.json | 22 ++ .../ic_video_bold@2x.png | Bin 0 -> 482 bytes .../ic_video_bold@3x.png | Bin 0 -> 714 bytes .../ic_media_close.imageset/Contents.json | 54 ++++ .../ic_media_close@2x.png | Bin 0 -> 1168 bytes .../ic_media_close@3x.png | Bin 0 -> 1673 bytes .../ic_media_close_dark@2x.png | Bin 0 -> 1475 bytes .../ic_media_close_dark@3x.png | Bin 0 -> 2096 bytes .../ic_photo_checkmark.imageset/Contents.json | 22 ++ .../ic_photo_checkmark@2x.png | Bin 0 -> 1387 bytes .../ic_photo_checkmark@3x.png | Bin 0 -> 1949 bytes .../Contents.json | 22 ++ .../ic_photo_unselected@2x.png | Bin 0 -> 1315 bytes .../ic_photo_unselected@3x.png | Bin 0 -> 1874 bytes .../Contents.json | 22 ++ .../ic_photo_unselected_narrow@2x.png | Bin 0 -> 1101 bytes .../ic_photo_unselected_narrow@3x.png | Bin 0 -> 1572 bytes .../Chat/Cells/PhotoInputGridCell.swift | 17 ++ .../Chat/Cells/SelectedMediaCell.swift | 50 ++++ .../ConversationInputViewController.swift | 9 +- .../Chat/PhotoInputGridViewController.swift | 44 +++- .../Chat/PhotoInputViewController.swift | 238 ++++++++++++++++++ ...electedPhotoInputItemsViewController.swift | 134 ++++++++++ .../Chat/Views/MediaTypeOverlayView.swift | 3 + .../Chat/Views/MediaTypeOverlayView.xib | 16 +- .../UserInterface/Storyboard/Chat.storyboard | 191 +++++++++++++- .../Windows/Cells/MediaPreviewCell.swift | 62 +++++ .../Windows/Cells/MediaPreviewCell.xib | 65 +++++ ...SelectedPhotoInputItemsPreviewWindow.swift | 181 +++++++++++++ .../SelectedPhotoInputItemsPreviewWindow.xib | 150 +++++++++++ 31 files changed, 1309 insertions(+), 17 deletions(-) create mode 100644 Mixin/Assets.xcassets/Conversation/ic_video_bold.imageset/Contents.json create mode 100644 Mixin/Assets.xcassets/Conversation/ic_video_bold.imageset/ic_video_bold@2x.png create mode 100644 Mixin/Assets.xcassets/Conversation/ic_video_bold.imageset/ic_video_bold@3x.png create mode 100644 Mixin/Assets.xcassets/ic_media_close.imageset/Contents.json create mode 100644 Mixin/Assets.xcassets/ic_media_close.imageset/ic_media_close@2x.png create mode 100644 Mixin/Assets.xcassets/ic_media_close.imageset/ic_media_close@3x.png create mode 100644 Mixin/Assets.xcassets/ic_media_close.imageset/ic_media_close_dark@2x.png create mode 100644 Mixin/Assets.xcassets/ic_media_close.imageset/ic_media_close_dark@3x.png create mode 100644 Mixin/Assets.xcassets/ic_photo_checkmark.imageset/Contents.json create mode 100644 Mixin/Assets.xcassets/ic_photo_checkmark.imageset/ic_photo_checkmark@2x.png create mode 100644 Mixin/Assets.xcassets/ic_photo_checkmark.imageset/ic_photo_checkmark@3x.png create mode 100644 Mixin/Assets.xcassets/ic_photo_unselected.imageset/Contents.json create mode 100644 Mixin/Assets.xcassets/ic_photo_unselected.imageset/ic_photo_unselected@2x.png create mode 100644 Mixin/Assets.xcassets/ic_photo_unselected.imageset/ic_photo_unselected@3x.png create mode 100644 Mixin/Assets.xcassets/ic_photo_unselected_narrow.imageset/Contents.json create mode 100644 Mixin/Assets.xcassets/ic_photo_unselected_narrow.imageset/ic_photo_unselected_narrow@2x.png create mode 100644 Mixin/Assets.xcassets/ic_photo_unselected_narrow.imageset/ic_photo_unselected_narrow@3x.png create mode 100644 Mixin/UserInterface/Controllers/Chat/Cells/SelectedMediaCell.swift create mode 100644 Mixin/UserInterface/Controllers/Chat/SelectedPhotoInputItemsViewController.swift create mode 100644 Mixin/UserInterface/Windows/Cells/MediaPreviewCell.swift create mode 100644 Mixin/UserInterface/Windows/Cells/MediaPreviewCell.xib create mode 100644 Mixin/UserInterface/Windows/SelectedPhotoInputItemsPreviewWindow.swift create mode 100644 Mixin/UserInterface/Windows/SelectedPhotoInputItemsPreviewWindow.xib diff --git a/Mixin.xcodeproj/project.pbxproj b/Mixin.xcodeproj/project.pbxproj index 2ae9acf1a3..2e53395095 100644 --- a/Mixin.xcodeproj/project.pbxproj +++ b/Mixin.xcodeproj/project.pbxproj @@ -643,6 +643,12 @@ 7CE2DC9A28587DE100AF00AE /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7B8BB58F234F36C000991ACB /* Colors.xcassets */; }; 7CE2DE102858B52000AF00AE /* WallpaperImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE2DE0F2858B52000AF00AE /* WallpaperImageView.swift */; }; 7CE3A25C2771A8AB006BE765 /* DeleteAccountVerifyCodeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE3A25B2771A8AB006BE765 /* DeleteAccountVerifyCodeViewController.swift */; }; + 7CE4BA1F283CD249001C87D5 /* SelectedPhotoInputItemsPreviewWindow.xib in Resources */ = {isa = PBXBuildFile; fileRef = 7CE4BA1D283CD249001C87D5 /* SelectedPhotoInputItemsPreviewWindow.xib */; }; + 7CE4BA20283CD249001C87D5 /* SelectedPhotoInputItemsPreviewWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE4BA1E283CD249001C87D5 /* SelectedPhotoInputItemsPreviewWindow.swift */; }; + 7CE4BA23283CD297001C87D5 /* MediaPreviewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 7CE4BA21283CD297001C87D5 /* MediaPreviewCell.xib */; }; + 7CE4BA24283CD297001C87D5 /* MediaPreviewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE4BA22283CD297001C87D5 /* MediaPreviewCell.swift */; }; + 7CE4BA26283CD2B4001C87D5 /* SelectedPhotoInputItemsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE4BA25283CD2B3001C87D5 /* SelectedPhotoInputItemsViewController.swift */; }; + 7CE4BA28283CD2C9001C87D5 /* SelectedMediaCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE4BA27283CD2C9001C87D5 /* SelectedMediaCell.swift */; }; 7CE5E7A8269BDA29000B7904 /* HomeAppsPinTipsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE5E7A6269BDA29000B7904 /* HomeAppsPinTipsViewController.swift */; }; 7CE5E7A9269BDA29000B7904 /* HomeAppsPinTipsView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 7CE5E7A7269BDA29000B7904 /* HomeAppsPinTipsView.xib */; }; 7CF2FEA626AA89BA00D3A5B3 /* StickersAlbumPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF2FEA526AA89BA00D3A5B3 /* StickersAlbumPreviewViewController.swift */; }; @@ -1669,6 +1675,12 @@ 7CDF316D29891B1200421808 /* PresentationFontSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationFontSize.swift; sourceTree = ""; }; 7CE2DE0F2858B52000AF00AE /* WallpaperImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WallpaperImageView.swift; sourceTree = ""; }; 7CE3A25B2771A8AB006BE765 /* DeleteAccountVerifyCodeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAccountVerifyCodeViewController.swift; sourceTree = ""; }; + 7CE4BA1D283CD249001C87D5 /* SelectedPhotoInputItemsPreviewWindow.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = SelectedPhotoInputItemsPreviewWindow.xib; sourceTree = ""; }; + 7CE4BA1E283CD249001C87D5 /* SelectedPhotoInputItemsPreviewWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectedPhotoInputItemsPreviewWindow.swift; sourceTree = ""; }; + 7CE4BA21283CD297001C87D5 /* MediaPreviewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = MediaPreviewCell.xib; sourceTree = ""; }; + 7CE4BA22283CD297001C87D5 /* MediaPreviewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaPreviewCell.swift; sourceTree = ""; }; + 7CE4BA25283CD2B3001C87D5 /* SelectedPhotoInputItemsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectedPhotoInputItemsViewController.swift; sourceTree = ""; }; + 7CE4BA27283CD2C9001C87D5 /* SelectedMediaCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectedMediaCell.swift; sourceTree = ""; }; 7CE5E7A6269BDA29000B7904 /* HomeAppsPinTipsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeAppsPinTipsViewController.swift; sourceTree = ""; }; 7CE5E7A7269BDA29000B7904 /* HomeAppsPinTipsView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HomeAppsPinTipsView.xib; sourceTree = ""; }; 7CF2FEA526AA89BA00D3A5B3 /* StickersAlbumPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickersAlbumPreviewViewController.swift; sourceTree = ""; }; @@ -2875,6 +2887,7 @@ 7B915F73215FB0C100A562C6 /* GiphySearchViewController.swift */, 7B05CFDC22293B72006DA9E3 /* PhotoInputViewController.swift */, 7B93CAA4222963120053AE90 /* PhotoInputGridViewController.swift */, + 7CE4BA25283CD2B3001C87D5 /* SelectedPhotoInputItemsViewController.swift */, 7B6A4045228400AF0037C7E5 /* MessageReceiverViewController.swift */, 7BFD3457228589ED00524EA0 /* ContactSelectorViewController.swift */, 7BEBCE2C228185130037BF18 /* MediaPreviewViewController.swift */, @@ -2990,6 +3003,8 @@ 7C0FAAC827E07A0A008D4021 /* ExpiredMessageTimePickerWindow.xib */, 7C47352828571CC900ECD293 /* AccessPhoneContactHintWindow.swift */, 7C47352A28571D0300ECD293 /* AccessPhoneContactHintWindow.xib */, + 7CE4BA1E283CD249001C87D5 /* SelectedPhotoInputItemsPreviewWindow.swift */, + 7CE4BA1D283CD249001C87D5 /* SelectedPhotoInputItemsPreviewWindow.xib */, ); path = Windows; sourceTree = ""; @@ -3053,6 +3068,8 @@ DF53BB6E202362E5002BF028 /* AuthorizationScopeCell.xib */, 7C53049828FE753400567CF6 /* AuthorizationScopeGroupCell.swift */, 7C53049928FE753400567CF6 /* AuthorizationScopeGroupCell.xib */, + 7CE4BA22283CD297001C87D5 /* MediaPreviewCell.swift */, + 7CE4BA21283CD297001C87D5 /* MediaPreviewCell.xib */, ); path = Cells; sourceTree = ""; @@ -3624,6 +3641,7 @@ 7B04142A240FA09F00BE8D73 /* LocationCell.swift */, 7BA1768D244ACE2E007D50FD /* PickerCell.swift */, 7B3CDA6324FFDD1D003A3E80 /* FavoriteStickerCell.swift */, + 7CE4BA27283CD2C9001C87D5 /* SelectedMediaCell.swift */, ); path = Cells; sourceTree = ""; @@ -4013,6 +4031,7 @@ 7B600D282453186B001D8146 /* DesktopTableHeaderView.xib in Resources */, 7BB7872B24514ACC0057B4ED /* PhoneContactsSettingTableHeaderView.xib in Resources */, 7B7B5DB5230EBEBA00D0F463 /* TransferTypeCell.xib in Resources */, + 7CE4BA23283CD297001C87D5 /* MediaPreviewCell.xib in Resources */, E0BEB85D236C1C49001FE534 /* ProfileMenuItemView.xib in Resources */, 7BACA8D523602BF8007E3381 /* RecorderLongPressHintView.xib in Resources */, 94C6AA0B280D36940011AB02 /* AttachmentDiagnosticView.xib in Resources */, @@ -4055,6 +4074,7 @@ 7B81BF2922893F8B00266A77 /* GroupParticipantCell.xib in Resources */, 7B36920A233B3650007321A7 /* MediaTypeOverlayView.xib in Resources */, 944ED8D0264640E200C97215 /* WebLoadingFailureView.xib in Resources */, + 7CE4BA1F283CD249001C87D5 /* SelectedPhotoInputItemsPreviewWindow.xib in Resources */, 7BB788B2216C5A4A00EDE7B4 /* LoadingIndicatorFooterView.xib in Resources */, DFDD89E922C4B8E600128991 /* DepositChooseNetworkWindow.xib in Resources */, DFA5B5911FB04C9C00549728 /* Wallet.storyboard in Resources */, @@ -4442,6 +4462,7 @@ files = ( 7BF49DD320C3DBAC00A8510E /* CaptchaManager.swift in Sources */, DF8CECE11FC3054700E40064 /* TransferTypeCell.swift in Sources */, + 7CE4BA26283CD2B4001C87D5 /* SelectedPhotoInputItemsViewController.swift in Sources */, 7BCB8C8422BB56B8002A13CC /* DataAndStorageSettingsViewController.swift in Sources */, 9BB351671FB19ECB00EDDD2C /* ConversationDateHeaderView.swift in Sources */, DF2819752014669E001EE5FA /* RefreshAccountJob.swift in Sources */, @@ -4838,6 +4859,7 @@ 7B51DDB2223A408A008ACDBB /* LoginMobileNumberViewController.swift in Sources */, 7B21782122C4E70B00C08106 /* OggOpusPlayer.swift in Sources */, 7B3CDA6424FFDD1D003A3E80 /* FavoriteStickerCell.swift in Sources */, + 7CE4BA20283CD249001C87D5 /* SelectedPhotoInputItemsPreviewWindow.swift in Sources */, 7BC3559F2265B7C30073C7BF /* DragDownIndicator.swift in Sources */, 7BA398CD242B539900DB5154 /* UIColor+Assets.swift in Sources */, 7B8BB588234F160C00991ACB /* SharedMediaCategorizer.swift in Sources */, @@ -4910,6 +4932,7 @@ 7B9D825A22F1BFEA0099381E /* NormalNetworkOperationIconSet.swift in Sources */, 7B6E5503223F69D90060E6FC /* KeyboardBasedLayoutViewController.swift in Sources */, 9B748DCD1FA71CEF00BC009B /* CameraViewController.swift in Sources */, + 7CE4BA24283CD297001C87D5 /* MediaPreviewCell.swift in Sources */, 7B54F95B22B24A5600908A9D /* CreateEmergencyContactVerificationCodeViewController.swift in Sources */, 7BB0F9512434DDD400BEDA97 /* CircleMemberSearchResult.swift in Sources */, 7B81D91423E93ECA0031E945 /* QuotedMessageView.swift in Sources */, @@ -4988,6 +5011,7 @@ 7BD7534C2182CD7A00BAC172 /* CallMessageViewModel.swift in Sources */, 7B2D174F22B11A8600AE3DD8 /* LoginInfoInputViewController.swift in Sources */, DF1ED8D920BBECFF003E10E8 /* AlbumViewController.swift in Sources */, + 7CE4BA28283CD2C9001C87D5 /* SelectedMediaCell.swift in Sources */, 7BEE5351222D0E5C008D3911 /* ConversationExtensionCell.swift in Sources */, DF8CECF21FC4256D00E40064 /* BlockUserCell.swift in Sources */, 7CC730502745F95D002780F5 /* StickerStore.swift in Sources */, diff --git a/Mixin/Assets.xcassets/Conversation/ic_video_bold.imageset/Contents.json b/Mixin/Assets.xcassets/Conversation/ic_video_bold.imageset/Contents.json new file mode 100644 index 0000000000..89f4a91024 --- /dev/null +++ b/Mixin/Assets.xcassets/Conversation/ic_video_bold.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ic_video_bold@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_video_bold@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mixin/Assets.xcassets/Conversation/ic_video_bold.imageset/ic_video_bold@2x.png b/Mixin/Assets.xcassets/Conversation/ic_video_bold.imageset/ic_video_bold@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..d40d895c1e0def0f17f0af0ece9fa385c876d4a7 GIT binary patch literal 482 zcmV<80UiE{P)JsB7zadbNN3W3`58$qYy}M_NfSPvNzDx_5S{@O{YWUoOgai7zEV2v>>u+fC6q&w zI#`SGN0%$9flQ!w*Q+<@5NUfLb)-ww)dGGgAv35G#xJ~guNY1yr_f-*fpQaAoZ9U( z=@a9Ow!d%(atEeh67T@lZkAo&4xXIa4Nxidjdc98WkoT7CU8{%H;dO-kpYJ#?dt+LTVUorNqDsYiW#dyb>TQurv5kv-`h zbeSc_$IX*~|7EL4ICDmQ+q`e__EN9z%BRp^e#0I!Rwhta9eQuhG168Us!A#y-YKhG zuskg!;$|V`GHM4TlwqdCvQO;u6U3$_`2zQYCl|AsdmB&(&xrXMP`hsSguyTj!~9Ra Y0iLWFu-d2-)Bpeg07*qoM6N<$f-n@q00000 literal 0 HcmV?d00001 diff --git a/Mixin/Assets.xcassets/Conversation/ic_video_bold.imageset/ic_video_bold@3x.png b/Mixin/Assets.xcassets/Conversation/ic_video_bold.imageset/ic_video_bold@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..3179d4d2fb5f3160b4e20edba21350d05b117ae9 GIT binary patch literal 714 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@ZgvM!oCO|{#S9FJ79h;%I?XTvD9BhG z869*1Fk@Na}yv+ME zW*qz03H(>xIjy*+;p@+Pf)#hO9Da5EHhcAEuNke%`zM-R0G(r*e;d zjtg3%$G&CBt*nJ@R=-8LC5t`c=Nx6>@MvhjP0Z!>2>rg3&1=_n(Vxp=cdZUI51V%T z;>FLs?xi#J?Za`OuxzqQ`EsG>xzB!TTJCKf011o=v>1 z9_l)8;T_SZPxlphbx!3bIH; zCpY*^{iVHCf63p)9IC1*d-;scTki?D^YyT)?#e0_#h1lWQ68)<>-JrL+Zi2k@N4Rn j5LV;dpzq9(1o871bDe*UvQhlO0+4{GtDnm{r-UW|eBeOL literal 0 HcmV?d00001 diff --git a/Mixin/Assets.xcassets/ic_media_close.imageset/Contents.json b/Mixin/Assets.xcassets/ic_media_close.imageset/Contents.json new file mode 100644 index 0000000000..2803457d58 --- /dev/null +++ b/Mixin/Assets.xcassets/ic_media_close.imageset/Contents.json @@ -0,0 +1,54 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ic_media_close@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "ic_media_close_dark@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_media_close@3x.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "ic_media_close_dark@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mixin/Assets.xcassets/ic_media_close.imageset/ic_media_close@2x.png b/Mixin/Assets.xcassets/ic_media_close.imageset/ic_media_close@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..f59d49cfa6e8719d876a6a93d718b3b74d3f5e55 GIT binary patch literal 1168 zcmV;B1aJF^P)p%-Xx0Gt3g0dNB733`Om696ZG{_9Xm!bbv$ow)lt z79-0_mlMmfPUyA8P#%{Z zF70r3U2r+#(xki*KN}*fZRD2`!^bIfP{v_A>BXpsN3s517%TV zp-fBlDmjj$t3Knh!Jr+=2zW~4ohjSs_O440-5$-OT6KXzYdVi&r*Ei6o6>OmDjdA_ z>*{81IOw7K7`25q6}95N6S5SzFZ?)?ZN4AS_06rW!MTK~URhqELcvw5w%L;hDZqH! zf9KK^Hb7lncXEf5A>XZd>bk@_Tovn4p5k2@4r}f;;UM7v^d$)!bVvMQ#V}2NT*z;A zZXa@4b6JFq<1Hgs{2H_-&u)tl%o>*^lC@B>S zltqZGiZ=1X4aZqYQ&>l%ka~PVnDq9I7o8GNyM0GpWfv?bb=qkgYaHkuKf=Z zR;2yWE~YsDPk0d{nwIP3CG??zI|f|DCM>ZRk@i2Y#ek>i`ns(DE8uBvH^#dm%Uf0=nhFFN}kzp{5Yy!w}9lnBdsaz^hnNJ8?pO@hShtsa#jKY0Q zeA$?{Zn$hw9>UE7m*X%Wv7@8wWRLQe1bQ(e&?xrU?yO)Iy-?x!3qK0Jwl?D;{~H2F iCo(0ZbLTJpH^g69;kD>+Ni@;`0000;0s zWuXBv2t@n-Jz!|+Ru41{`p>ySbX7rrUAKl?RitQ6GEK9@FEzHW*d98DYgJFyhGXY! zZ`kT=C#h8GoR(dHWZPpqGH2Hj+b&y`mQ8$9H8UPa9ej-ztpq^5ddv1BB`yBaYQmsi z9hgbEx@otornz{bwtuFON(Iem+P&@`+pi(YP^Z(SoBn`qZ*QrO*CDPCM|rZDG-Waw zGK?&nLAh1KdLKeBxJQ2)G;<(e2lj~Up~WvRUA5ZO>)lZArcY6{!tjMcJ`DWdY(Fu0 z!!M&RuxYmyz*c?oS)f;~&O{B)s{C4xikpQX;P2SB{h+nLL18biu44!FYICDtnU4Vd zoo>?N2Mkvc-5Bli^z6cFn@I?@d~d7dSKGMfZ3#Lpm15Wzq=CI|cj>?Xn~A|fc);&J zoKdIS(`EaX?U|IQtYL=zg@r zaA{6Nw_a71{P+Dw-3KpJn5f+QvLYoPv0HDuZ$>rjJc+8R1YBoXVIfRiHBD`zmd9Os zPn8>>%hYZqwXWONY)>J0@w4$aZqNKqmHVuDNo@`Q?t(PEHl9QUlM%OucT~AhyHR1& zsgyMjUR^N)mqFfKZqa z>|upKUZs3~O$n`nb+xv(LScm%TySgX5rAu|l&J0ux02+A*1X%^k|Mc|SMJcwZ`85TMYSV*(fRE;*mhfq-H zxL_e&$2oUkTd4p!OiDr`aFjt`CBLD^l{8MrEl@R737K@@O$kv!VO_-qty$G<09d)4 znayTt7KMd%6$i8?_^^QAC<|pn?e9#eR9x4>qJz?PWV1p`(_7XBfKOB@D@L>$Mu@l0 z`$Cf3-q^4A26ULva$Ev1dc_m54I?g%qKw8fyhD#4bkvS>byW;Hj`4|gRZ5yWttd~p zE9?qLZ(-DlM;nN-y695O)3EGjlH8T92Vr}R0pf; z?8l@C6VVo<>SK}9@4e{4x@2^hj4sD1lPxVassFUG0iuNUm zDZr73=c*%2V$kaxY7tM};WPj?$me-fNhP$|VsS$kjRE#0!#+_Z>VPpBIVAg3j8a(W zJ3VdkYckM?;40jCX7$IGUNh|XQlj5EEkL6^-`C}vce_TduJis>z=LVp(|AFT9-^kT z&;w`Vyxop(FWCi!;eQ{!WqUl+(L+}b^k1ZLZ&v`v4qofJ6M4G^VJU)ufS_ozIDPjx z$nPXVt<*Uk*>2EsEy|_3rs9MjE)}R( zrtRvxGr$i$kb9;*7l=)>@23cIZWQN7YHSba^Uv_Ao~#YBcRx-X`-km#UcK%o9T*;L T5x`w%00000NkvXXu0mjfJ)k3L literal 0 HcmV?d00001 diff --git a/Mixin/Assets.xcassets/ic_media_close.imageset/ic_media_close_dark@2x.png b/Mixin/Assets.xcassets/ic_media_close.imageset/ic_media_close_dark@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..e80af0b68712c7ae175d987a1bcd88c91a8d8d6a GIT binary patch literal 1475 zcmV;!1w8tRP)^mDL`6SEdxxL$OXJ=+-#tgbd(ws{H*fXH9 z6ysoW;06FQPVVv|UT1+p7V5A9P|rNI{>(rZV4Vz3n5HqPMHYbb+h=5gfdi6dJY(0Z z_nui+Ht2JK2okX&Eqs@Ab{l#C&iI3%M0LLb$ zyTNNxnWfiXNRKD&hiY;EOLjhZ!~5SUk=YqCMwR@B^0Sw>p>a5@2l zgM)Bh^!N9}@bCrL-#<`Dvc#pyNn>kc{kN6`KQ(8+MFaj`mPMmcn7VRVkn9z7T^IIS zDwUvCvt^QVV-p|jq2oMiNSuqSrkT)jYf+GJWJD@4b)~1|WRN{(*Zx)QvWWIL?bu_( z%5@fAl*y6Jy-9KeM*)hn88*gT=ZDZBX)nYgFXd4am&Utxh)zL{lgn+oWT?eBX;Dxo z?a4P!Ldn}9M;M96n|7)O5fOh+b<1wZH^(rX1VOo6hKhGw6R^=s577|Ex4>?}->Eg97vV`i8iU;b}94w$=`|U5Do{UbsUK zug{|fwUj(?C_tKU0Td<@3hVJdIg$eb92ij!)C#QXV#-^gCHRU)kTnEBH7Lw=6r9GB)yyFaFGLe z>1dD8$boXXTp3HgW5(cXM@HeKR62s#U`!;ky(ahS)oalrQ5fF&$69{aM36xB==j8B z92+Kcj;vOzi6VOllgVC@bTrpYj-^+v$3Ms-&)!V(+U%0rmmzXvqZiLzOp!xSk46pl zxV29(tXx;ed-i5Ry!s9HN{D2mKSMT|Lt~~8G&VX4h7mlCqQGA)6h(RI**xNW=@4SK zyq;~_eoLCOY25AwuX}3yEsTk-lS>kzDz)G6T&G~(ru~Nb0K5OxTJEgD`P{m7apLl?40s$p+=TN(Xzc(7Zauet&-e}QsEK~noJ;6X zU&Np%2Di>4a0g}oK0C@n2b4uk9!V3hB$t4L# zE@0n)qQX*ygaZj8!M2EvEnBv+WN9beYcH1Mnf=qsC|{LI($36kzv-Xp?in#?5i**V zOh6>df$!srqA|`j29V^~RND`)<*-i<7%PAQ_W+EEWvJ*mT`xcjz#u>n$+!|Z1Dt2^ z)>&u@d;&Q9Qi1iW4|P2U0U;2=Bid*dSoK2lBxNA-eEc{4&IB}xrUJ- z`(3|t6@0=M;RBkM39<0yog&%@&iIw8u|Doc)m89_k)y}YD4_g+WDmeA0t2QorjGTe zhYGX#se9hWx`gR+9?0>&4m{l-5eH zvbt)1cTIr7#rwbB{=#*CH=T%VCyU&`&YDwSBGcQOg3gW(=)jjBtReaN`2{cJ?^byE zv>O$7Buw_IZjjB`^|Offrc!khY!-#ZMVOtPb0kg&-XJ-;@ELb$^9Lu4`z5 zvOZETeelS!i+GDuvi9!oZrHnL*!BT}206TBcM{5_5|qnjS-01?qDKFnnYt#thv?$l z+Of~j^?wZ#&}^sE1Lz?GjZ9rZQ5a5kmnqIwbZQce_N9jxaml*b*szT-*{#AtmnqN} z-HFJO&Aw{TDqusJWN|!|?)z~rpRcp$ZHA|^@Ru^XO~m7ELDm9IKADyTJs1ss-*~Z( z@ImdpQ>e*WL#js__|!?+>_MJc zmg@Z<$P!4kDYO-XWvyVuB&hHjp|EHs0B*6evI@oGGL$`JOSQ0* zas?u0mKQR=F=I>=R(JZ$b{gZ}R8Nl?sMuj@dIlCTQLx}=y|<(+jJX+qpWc)WQH#~N zH`N1PLDFgb!TE&+laLIJWC=8Bq$OS<7t)4WD-{R{b6isAD0)AySi>Y<1R^9NEh1~7 zxKsqMpd4Jbe3l{f0k$YvQKMgV4yp3Bx{vm*Qsg&t{kRL&Ic?WkhX}CL=2%-R)yZqcPTm`Nb}&cCw_xCE;Wcp`=&RL69XViny*pp>xZ# z0k@=sB1=$h9B2EbKP`rou%AU=1gE9Pf^3}*8Vc>tiW-b+6`F-xvKE3OEvsRpzsjN0 z;=&p(RsWlTSF#p@AT1+|oAOAQvwL0!xk3KfGh4FcpzIu3v!q{0gvEg^q75@$)F_LI zoj_GY+82_+^2Scyn@D>}Eo)H{KRZYB)iKPjd$CW{D3mC-YQ)p&hdEo3hLw9uCx zF5wRPN1_{9JMNvG{wj(=!QoU7CV_DF5B=6;V}r2xLuVuU(}PLegBcaI6X~_m8pPvq zOsIGEoCn$L%uL=cK|!+Y(gXeWH=;)ADW1V}|1EuS`n2Dt_+Y!hgkI| z?Ea2ZUm{LhB$WNceC}S*J~64S8=_z)25@(Ucbv@W6OR9r@Z@nuv||_1k)3fn&LSU; zMj^H7bTj*SH(903Hbw%u{-dop)04)`Kr_ zu8cp>Z(Z^-%Cmc&%}?vS={;8&@FcEChaup9#<&2d?56_i$GFa3^E$YxhdrnrJ!LTU z+s)%nttM^~rOc-`8SsjL{>DS+pxAk|?`)9w1=!rV4uGhIK&4`ObEuBt$1$Jex3=a@ zl5BDrIxe|&NN&(FHOy$)WE_^Y%_9|ufNUOE28+X)x(DFqU-1f9bJ7_Y^m7@qO~0000VQFdTXOz+5?I? zwjdGpf&_=8!nY#Xk>C_S=0Yq$iHQT@FD&od%$T>suAN=)I{u{bcxKnx@7wog=FOYL zCxaONPq_s!pn+QBh@yOPR}JEEcQKZULmZoZ_-z1xJa?1eY{*3WE5W6)xt2 z2#NW*8N|7oBByIm{jo(i?mVU9^-Zd-Zt?eLRNvg94ekf}_Qfc5D8Y4{GKYKYGW1c8 z*JJYRS~C=;0QF|(xg3}MrCU#I(ii`K*dV)8K-9>wH|gu)J{la@YhJE!IkA&5(oz7E zALr_K5^o*E3zyfabn^*?0a)Y5Zw^uF?LD%)#wEuh=9~StsQ~3NdEJJq6fQlWbHxpc z0%OAmDEHYx+d2YGUCL8{Nq%K*izffPOI5C;L_b?$_WSotYj}!q6~N>%r(e+RWge6u z@7K?R0wR7Hf5#jf6D;DadGBii)POCK^7&4a*XZkw)9iQeN)Z?nL*~GayZm;66nVJK z&u8y0ow_mZw6b0)A||an3wl71Sr>OT*Pd% zo8&dXVeZnp?3%Q;V=o)h0(t!fW{Ya741#mTy1n)&fc%th*aFnsAJ?6~{=-sb4^l4P9Pz{t^8K8&yzWc6+; z3i57%fSPU?Te+5~KX#58Ekf5(kjFkj!YQD45Bb6AzgK0vZUf}cOuZl3n@=Sjf42ew zOY-4A2`oWCS+{~LEP$7Yu*n04fHDL$ z5=Vony}k#sh8BT@{qb)tbnjIHzK77m{8$C zH{mBv{t6Uyf=f=S4l5Yu+Et8=^qEt9BTw28U|In|S}MT05H`Reck(USH6zoDZn;t9 zMeSW0W|eP$tL{o_5pW3zD@^`Fd5>E(`V}cx(y8vCYePFYz|Ta6T95~)6Xt!d2~g&l z)@>bGcG8_d9?vvpot)c|lk0wfZ!C})D4i66XPlYtH_6X<9W}K&V#;%MO1H5aLU<6_ z5fjfg7_f%Rf_&cVH#<4x!)eVNi9#|tOc9wW|y&rd6F@PwaZRvdh!Wk2@8 z=h+%X&gRv9&Q$R8&p+8u!yMnZoEc+}G8)P;tAy!N63FTyF^1d{Tn!lxEed3@+dDFl zpng8^2I;|pH{517do60_GHnCa;8L){1^#UzFApvvhZ2H+)F@fOvCuB@P*x~wdyHgh tdj&_TGEL6$Qd0%HzuT&wSK&)rcnKrlcIXQn?H>RD002ovPDHLkV1k~(k2U}R literal 0 HcmV?d00001 diff --git a/Mixin/Assets.xcassets/ic_photo_checkmark.imageset/ic_photo_checkmark@3x.png b/Mixin/Assets.xcassets/ic_photo_checkmark.imageset/ic_photo_checkmark@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9f774b1b685f336d127a80b47459f3072f975ae2 GIT binary patch literal 1949 zcmV;O2V(e%P)A?Z)Sh< zn{A;4u`H{T9Sl|m`SCSYdiiBTXl}By!^;XkUv6n>sZcWkQCJyeWuBF7OAqs`lvpt+ zj|if~%{;`SJivvyZt1g>DE!XzY$E1@1 zCKSYa=dBlOHJxN-f(f-l4bYH4yr0jL!fIeGKQ26^SyuMg_c%cp9KLkQwyGlguE1pF zA`{7^h(s2Mcd4I9;cI3W_Go%;H+Eq}T%kfN3KldRwaY~kh=~J7JSn?|u5|PE4y|we zLumq`rapNk%$nerCaF&976?NqJB-V&!D?>)(oh$Vjp0i#*+s#4y`x-R0;wzqzUIrB z2ihI5F8W?SL7!deQdv`7mQ+(9Ce8=s_#7WqHh&rU?oWFjlVxgjxr?=)?Lr0Oqty}# z4mmI4xP*uVzt9&px+w+X^Bjm{cJ$J9qyM{hPGJ0g`(+ z+9*a4B=6=yavVSZ=(K%Q1>e#XM@1{e1hOx>C2LVC5wa)_h)tj{L@}}|4nP+($>WER z*`)(KuHeR0Nd%%mDv}gOa_?rqf(jj4vhwT)UfftUGRv} zVqLX?8%(2H6}y5<(-6q?HR?P@I%!0tw0uraFj;gZAdr(;s(18{B2rr7oGnT-X>A`= z5o-s|wUch@5h=~ls^`_?vg&2mf_$rJNVkoHFp<(ujOc%}BT$Fjp;n&uvFyDNhXuLF zM9Muhe3C+rG8gs^5@$qEqS%GXtUwhfljl4~5KK&;!G}BW zm&L24kok!&urPpf)86D-pwlBt9Sx0wCkBPW*N-%CArlVH!aUQb-X3E z(i!v3Dy2)OWYsh77q38N5=OJ3{z6k*5v)hDgk&=7DZ3}n1mfLn%G(modiZUr!c76J zM-q~m@t%lJXGp9LSW(3NTDd^sdrT4rET2F!D*~UsSJxi7T!Cn(gc&hsS{5*a75N%r zM7!2RA|b>g>00nT<6>n%AZ|s&Vm-QA9Bbl9gL_cKpvGjC(vU0PzYyqZUw!;?*fyig zQ?wTjx@4E8RQt#xZ$&st5G+lKd@DkC#H|PoZDz@vNFn0X+{_ZXs=@#8vRGfouD;9= zA`Td?wU4#tvJdx10SD%alx4?_-}e1jw@RG4wv0#Fk~vO_T`^cG&DvH4OiM8GDwvQB z=`x~j>O~g%4cWD<_$AExF|ozmO+h@HdR_QdDZFVxI&(z&aCvSwaiNgESYpqOAyxy2 zd^zgtbz*t30IUho$Rvb#@kq48Kda-~DpC^rz^HLKfu&Bk=M=WxvH!&0r z7*WrVuH=FV?|7oGrtreM9Q=|I)(&A+#Ga8syS{N~XQ@V^$kyum&$m-g@i+~f@2DqE zWs){42XoJC1kb9UysvbAB$``JVM~kjYv#3YBjL|-vpuf%Z`#} zoB!{8gdHsB9b@xW{LEX<>%j6EkFgx9(ka$E|HP)6nm9!ix`cob z(D>3qERF)CAR(LrBGF(eAVGngd1ludUk-!eY;T^l-kW9Zo4I|vxBCWjH^c!K891KynU^xZfNu^Q-1^(S(G!E}N8n=!B{m@IQ z-U#;8v)OEVX=&+gI2=w37m!RQgCip&v4)0*cGjGwGDf5Exzu+{1|TV8C>vC@f_WAkw{@~!?gqoC<#^mH=+sw>NqPe;GF>4OfNW8cA{UZQ5dA5b*e6^idz{<6} zyu84Iz?}Hc{_=C8P)bPWF_hEO)6r@>58$3dLqijcPVniIy4qKDl`JUG#A+0J5B)@xX4pTKK+-f^Wx&7yRUK=fEOn-Bf0Ax@bw6g5vJ-LuFuZS z_Pt*3bD<%eMnO&Di0VXq(}MR-z-Ez||Kp=h(kGx=<*tWB#X9%G1^pB5^UD;h$5;htI5Wo{(a%=9Awzp_qFPPCRrPn7M-hEM;|ec7}xJ zfU3t#3jq{_Bicqk)_EesH=87B*2$xp2@8VK?Ck8I&=j#)EW}I;3Wa(Pd{wPF(}>RK zqs*MchYA4NXMQ=w5J9UHngKr9gf0^w{7`RDm6z~vE@)|KnGhP`;^N{96MvILpD|qU ztwhl%K*|uBU~_YG)TUe)M2A8Eax%{;8!9n0N>O9Lhwkp~an=;)@}iSA3SBgRN`pSs z-{1eDR>w>@pGYJY*wfFD^N)C+Tg6?-EYK)xA?~iMtSmG&HI*ms$_}rUjhPRXxSDiw za2^9!P;A>`5uR650k)dmyRmSQUC zpia0;k`LJ$$c}|1A0{{SQ#X@*!^6YlwtWsb|5~!)VUTIoLwhb1DN>|L@0(^tiWaF_ zhcv6@TAI}sh#?rIIPms{Fa~W2@(dIXp6JGD4+kckIk4Zt&eukj{LfICD@BUdccDiw^l7K!l&hg9hoOfVDI)Ih=M?}w~Et2EI= Z@EaD6!D)GwKgs|A002ovPDHLkV1f{RV;=wj literal 0 HcmV?d00001 diff --git a/Mixin/Assets.xcassets/ic_photo_unselected.imageset/ic_photo_unselected@3x.png b/Mixin/Assets.xcassets/ic_photo_unselected.imageset/ic_photo_unselected@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..d3fd566e73c9808bf7a5c13dcaa15be2f8fb3546 GIT binary patch literal 1874 zcmV-Y2d(&tP)^TMRDkOw^xr16-RRp*7zrt~IteI5?P} znVA_01OoGvMeuj}tE;PPd_LbVX`1$P>1Lu1=o+{dL86~W$VO|REo*CQQ!OnmQ@XAn z(Jg|1<5gEzSM!S(FRGqCed@DmW^k!xk_^fa851PB)E{JHYc@AGr<$6YMsAxJ875IF zGzuCN%(Tm8L=ck$L)@>bLsuFd9qsGv?EIJR5Fo0ruWvYoHo-3SlS4Qr2riW0LaI7w zO?i2FAKfFYtgJM@fB(MOraI)*%RO?^NN7$-mc!OePELNiZ{z^%?MCq5&d$!Fis}$Q zQ=60TNvlEpMZZ$q<|F_=+|kkTEmL=$YozINwdv+|DG3tZYJnt!oG;{90RA)nJM5-ZrBVD@l~8Jso!x=a z%l3#k*9eEhRgH~}!-^`UJ+dQcizMw?U0of4XCN0~V`HP^&6_v%lGq?cS4-+*V!WeS zW$0RNAqT*)1Or=1tdU|~nLv>6S^>Ec`YyQ)81*Z}t-?87A)$+@M$6mV+kd}){W?T0 zgiTk&%s*sa$XzU8y+GIM>FH4!G*S zBnCMF^TMh@jq(?`S$j-i%}ODzGe2C~AYq)htS#JZdt~hI?+=%jmR3pPGPrzGgZ%PM z3kwT6c?4JzR)_<5F*OLwr+%{rhU>|bC$;1uoS&aZq(Feq8dR&)AXd4&o=#i`J3BkG zlIRn>*(0&WoPg!!~Q0D=sw0GVCnQ9OJ0EGcG|q(MbRMaAS%a8OZx z_RHmtC99;cu+Xy_q z8MF%VqNza%;ntTgUsjOE@ZrOU8d-)|{UZ^JUbI&De7+j;7|P1Z$0_0pP64NK~ViFXVZ>p=SvrB+<9gvW&5bJokBuI2K-CBz{k>Br6m+qhUfX7zk zR_kV3o^>QWBj3zn=X!EEkbJHXPnsXB1cgcRW)&3`^={T8Xb-k9NMeJvCv09^5hS|V zfVCwI*Vx#YYi$v(B0B(hYF_K$LeUuq1b%(yDWz$O;1mE-`D*cFvzf*He1LYZOzPN zOJ;~{cEzl$tc>u2)4kfNfME$duT6Eg%B8E|IB+PwQ0WTcHZkzJ4_ywUeq^ntAgA3a zIly(cg=4D}PFhfI3rG4e@~d-gz%i-DyfFnaFvwprJznQl4iEw7E zM0*_&V!~Y4>c6hm+!|n;K$JM_DA(8*zwPM!qB}<{e#TfNcKe9L;>UsI2Qe~YajnWG zS^K#pC2KawDPr-h56JGcR_NdN!< M07*qoM6N<$g2Z)au>b%7 literal 0 HcmV?d00001 diff --git a/Mixin/Assets.xcassets/ic_photo_unselected_narrow.imageset/Contents.json b/Mixin/Assets.xcassets/ic_photo_unselected_narrow.imageset/Contents.json new file mode 100644 index 0000000000..cfe7a5145c --- /dev/null +++ b/Mixin/Assets.xcassets/ic_photo_unselected_narrow.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ic_photo_unselected_narrow@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_photo_unselected_narrow@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mixin/Assets.xcassets/ic_photo_unselected_narrow.imageset/ic_photo_unselected_narrow@2x.png b/Mixin/Assets.xcassets/ic_photo_unselected_narrow.imageset/ic_photo_unselected_narrow@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..bd6e1a4d2ae45ec5d9cd69803bd4d5134f89a8ab GIT binary patch literal 1101 zcmV-T1hV^yP)@5H>N5HF1%2HsYXD(@5h~ z?VwRB7?aYZ7>z>-OqwV?AKYi2h?J-Nye~QL9v<)A`*F^__uO-k=#j`~vkq?TEH6ac zECfigB!uj-L?uazQnkUPnpwOof3xcGi)ER`q-IR1oQFV+8!yX@eDCV&DtU2n@im!D z#v_r)_QAnHdVPI8gYSlhhV+AjgT~IzPNUgu?rmvldD+<5XjF6=X7RIC6ZBLtxoM@v z&d$yvD=RBw{FPOWEG{lO&(F^z1r~MDQ@|a}$-Sl1>F;j0`;}T^g@Qf`h|ZPbAU!|~ zE{(7_Zfp!_ZnDfZkwg2LoIc+uNGBrIF)Q-sW&w>y(SId^78VFh}{FQpaVA%_(>yB zaIu3Rc5&ix_JcHn)6-KqI5nA&mf;pip-~5cvQhH6)Ed!6m8_8Gj0~kJHIMsxxr97I8QMZt^xWk4t}l z|1i}VycR}<8pTB>n9Bj$+S-OWZ#7b#fU(6{5b)!oJo1C&bS9J8HW&;pssj{<)iAsF z9quV3iH{|PoB#<7)foHx`)>ug1PSW_w**BNmHWbCu}opIQjGxD+HAHd@$pVk=}s&b z`RIH@LxX;NeEefA<|D@N`~7lGJkK1$cQ_S$iRUBoV~1Q_s8#|GFToIETiIiB_QGdM z6^}sDetmtNiN#_Il}_X_=ipXhxhRXXSn;SNu(FkP!Ah0iP|8!3-$L{hpk5=2jkiQm zj8sbuMbCn|Ac~?h{~N#D60nK#+t8SNPc8w+-qqAUwOK-NL|oVthdi5EoqHv|MHrEh2-`!~ct61|q^ T>4f)500000NkvXXu0mjf5{vU& literal 0 HcmV?d00001 diff --git a/Mixin/Assets.xcassets/ic_photo_unselected_narrow.imageset/ic_photo_unselected_narrow@3x.png b/Mixin/Assets.xcassets/ic_photo_unselected_narrow.imageset/ic_photo_unselected_narrow@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..2eed5be70a5fcb14ace7b5dd6cf84cbea1de2c96 GIT binary patch literal 1572 zcmV+<2HW|GP)y(18IN* zQUGg&46uzuS_(r3 zL9XB5-|sCdDta!xHHj|XpX~P}MVOHdKxBH+adUGMT3lRw-PP3nqD3%%9n^su{5BIH#wly)KvQrBJM*v?U@Pm#`7p!^6X=s;a6f z$N}KE;jOH!^a@2PN<+LwOCH1r4gU&PR##UCbA>E{+7b)~UkgPF$};PMhA<_JANm=D zK1*arX{S;tiGX3v*CV*>$&8{W-xGXTA)nI_3jat`z;MnrKP14 zU;&(*oD2v98TI1^c@Q&l>0w}GY3iZT7%T#6E47)a)Q@N6Ix&RNx-_Z(z;&O`*Zo*i z&qF|MrL}=jqyzoI{R+K1?(j?vN4Kfop! z85#LgP<6L%1LNSqWRT+cP^ziVJKoSvSFgr5=N&$xDV zb#)Ck!}|KVNVuK|D_mP!TML2BK(TN^)dww&YdBAR0X76u{FlE@6ymWRJ`uJ%h_K2P z3UY(ZAaPQOa5RQnQBmOpn}JRy1yv`!#kHoUraG`0=onT|uZZv_*GfuCY)6>RoCS3= zrVrC$G}sIhrvMQSaE*2qbWjC00dbMC2{!ZC!Bc>j3=Iu^4K{(qh2mP5d>;Obm)6zQ zJqMdW;-W`#IN|6Sj9uKC=3mL3^>B}2nAQr1XNiEJ<}o2XA~sCR;O8%J0rJwyRwnC8 zxgb$)@=;6F%E!mYzaa5(uplJss%YKM`Frx_A^J;{mzRG(H#gS;7K4FxV={b<&WEE` zrlZHs&d%?wa)L3SqZB5?007I4c*FJT>T2)G%1X{INT_YpMxp2pa+o!$YnrFU zj$o)iN8{F%@Y&khnp2rPMAq$gj|oLvNVeXrexk%QB2E2S;br^#`%^VFH94^;AvcyR zt3rZoytpwfwy|gfVGBtQz*z6A$6T1uvuS|(Sh9pu+w*Z2B)09O?FLJ_nra^0i zj*gC>q~ubH+IW(=Cy`Xtl72Om1`q}^df@o@`0r8NLFrtUd4w zK&qEKXl|z55XIYjJX)AA*!zIv!X Void)? + + private var requestId: PHImageRequestID? + private lazy var imageRequestOptions: PHImageRequestOptions = { + let options = PHImageRequestOptions() + options.version = .current + options.deliveryMode = .opportunistic + options.isNetworkAccessAllowed = true + return options + }() + + override func prepareForReuse() { + super.prepareForReuse() + imageView.image = nil + if let id = requestId { + PHCachingImageManager.default().cancelImageRequest(id) + } + } + + func load(asset: PHAsset, size: CGSize) { + if asset.mediaType == .video { + mediaTypeView.style = .video(duration: asset.duration) + } else { + if let uti = asset.uniformTypeIdentifier, UTTypeConformsTo(uti as CFString, kUTTypeGIF) { + mediaTypeView.style = .gif + } else { + mediaTypeView.style = .hidden + } + } + requestId = PHImageManager.default().requestImage(for: asset, targetSize: size * UIScreen.main.scale, contentMode: .aspectFill, options: imageRequestOptions) { [weak self] (image, info) in + self?.imageView.image = image + } + } + + @IBAction func closeAction(_ sender: Any) { + deselectAsset?() + } + +} diff --git a/Mixin/UserInterface/Controllers/Chat/ConversationInputViewController.swift b/Mixin/UserInterface/Controllers/Chat/ConversationInputViewController.swift index 8e238eac9e..a84325ab0a 100644 --- a/Mixin/UserInterface/Controllers/Chat/ConversationInputViewController.swift +++ b/Mixin/UserInterface/Controllers/Chat/ConversationInputViewController.swift @@ -395,9 +395,11 @@ class ConversationInputViewController: UIViewController { self?.quote = nil } - let recognizer = InteractiveResizeGestureRecognizer(target: self, action: #selector(interactiveResizeAction(_:))) - recognizer.delegate = self - view.addGestureRecognizer(recognizer) + if ScreenHeight.current > .short { + let recognizer = InteractiveResizeGestureRecognizer(target: self, action: #selector(interactiveResizeAction(_:))) + recognizer.delegate = self + view.addGestureRecognizer(recognizer) + } } func update(opponentUser user: UserItem) { @@ -417,6 +419,7 @@ class ConversationInputViewController: UIViewController { if minimize { setPreferredContentHeightAnimated(.minimized) } + photoViewController.dismissSelectedPhotoInputItemsViewControllerIfNeeded() UIView.animate(withDuration: 0.5, delay: 0, options: .overdampedCurve) { self.customInputContainerView.alpha = 0 } completion: { _ in diff --git a/Mixin/UserInterface/Controllers/Chat/PhotoInputGridViewController.swift b/Mixin/UserInterface/Controllers/Chat/PhotoInputGridViewController.swift index 60a92354b8..5da949e333 100644 --- a/Mixin/UserInterface/Controllers/Chat/PhotoInputGridViewController.swift +++ b/Mixin/UserInterface/Controllers/Chat/PhotoInputGridViewController.swift @@ -3,11 +3,20 @@ import Photos import MobileCoreServices import MixinServices +protocol PhotoInputGridViewControllerDelegate: AnyObject { + func photoInputGridViewController(_ controller: PhotoInputGridViewController, didSelect asset: PHAsset) + func photoInputGridViewController(_ controller: PhotoInputGridViewController, didDeselect asset: PHAsset) + func photoInputGridViewControllerDidTapCamera(_ controller: PhotoInputGridViewController) +} + class PhotoInputGridViewController: UIViewController, ConversationAccessible, ConversationInputAccessible { @IBOutlet weak var collectionView: UICollectionView! @IBOutlet weak var collectionViewLayout: UICollectionViewFlowLayout! + weak var delegate: PhotoInputGridViewControllerDelegate? + weak var photoInputViewController: PhotoInputViewController? + var fetchResult: PHFetchResult? { didSet { guard isViewLoaded else { @@ -20,10 +29,14 @@ class PhotoInputGridViewController: UIViewController, ConversationAccessible, Co var firstCellIsCamera = true + private let maxSelectedCount = 99 private let interitemSpacing: CGFloat = 0 private let columnCount: CGFloat = 3 private let imageManager = PHCachingImageManager() + private var selectedAssets: [PHAsset] { + photoInputViewController?.selectedAssets ?? [] + } private lazy var imageRequestOptions: PHImageRequestOptions = { let options = PHImageRequestOptions() options.version = .current @@ -71,6 +84,18 @@ class PhotoInputGridViewController: UIViewController, ConversationAccessible, Co } +extension PhotoInputGridViewController { + + func updateVisibleCellBadge() { + for indexPath in collectionView.indexPathsForVisibleItems { + if let asset = asset(at: indexPath), let cell = collectionView.cellForItem(at: indexPath) as? PhotoInputGridCell { + cell.updateBadge(with: selectedAssets.firstIndex(of: asset)) + } + } + } + +} + extension PhotoInputGridViewController: UICollectionViewDataSource { func numberOfSections(in collectionView: UICollectionView) -> Int { @@ -89,10 +114,15 @@ extension PhotoInputGridViewController: UICollectionViewDataSource { cell.imageView.image = R.image.conversation.ic_camera() cell.imageView.backgroundColor = R.color.camera_background() cell.mediaTypeView.style = .hidden + cell.indexLabel.isHidden = true + cell.statusImageView.isHidden = true } else if let asset = asset(at: indexPath) { cell.identifier = asset.localIdentifier cell.imageView.contentMode = .scaleAspectFill cell.imageView.backgroundColor = .background + cell.indexLabel.isHidden = false + cell.statusImageView.isHidden = false + cell.updateBadge(with: selectedAssets.firstIndex(of: asset)) imageManager.requestImage(for: asset, targetSize: thumbnailSize, contentMode: .aspectFill, options: imageRequestOptions) { [weak cell] (image, _) in guard let cell = cell, cell.identifier == asset.localIdentifier else { return @@ -125,13 +155,15 @@ extension PhotoInputGridViewController: UICollectionViewDelegate { if firstCellIsCamera && indexPath.item == 0 { UIApplication.homeContainerViewController?.pipController?.pauseAction(self) conversationViewController?.imagePickerController.presentCamera() + delegate?.photoInputGridViewControllerDidTapCamera(self) } else if let asset = asset(at: indexPath) { - let vc = R.storyboard.chat.media_preview()! - vc.load(asset: asset) - vc.conversationInputViewController = conversationInputViewController - vc.transitioningDelegate = PopupPresentationManager.shared - vc.modalPresentationStyle = .custom - present(vc, animated: true, completion: nil) + if selectedAssets.contains(asset) { + delegate?.photoInputGridViewController(self, didDeselect: asset) + updateVisibleCellBadge() + } else if selectedAssets.count < maxSelectedCount { + delegate?.photoInputGridViewController(self, didSelect: asset) + updateVisibleCellBadge() + } } } diff --git a/Mixin/UserInterface/Controllers/Chat/PhotoInputViewController.swift b/Mixin/UserInterface/Controllers/Chat/PhotoInputViewController.swift index 6bb9168f9c..6c10f0bee6 100644 --- a/Mixin/UserInterface/Controllers/Chat/PhotoInputViewController.swift +++ b/Mixin/UserInterface/Controllers/Chat/PhotoInputViewController.swift @@ -21,11 +21,19 @@ class PhotoInputViewController: UIViewController, ConversationInputAccessible { } } + private weak var selectedPhotoInputItemsViewControllerIfLoaded: SelectedPhotoInputItemsViewController? + private lazy var selectedPhotoInputItemsViewController: SelectedPhotoInputItemsViewController = { + let controller = R.storyboard.chat.selected_photo_input_items()! + controller.delegate = self + selectedPhotoInputItemsViewControllerIfLoaded = controller + return controller + }() private var allPhotos: PHFetchResult? private var smartAlbums: PHFetchResult? private var sortedSmartAlbums: [PHAssetCollection]? private var userCollections: PHFetchResult? private var gridViewController: PhotoInputGridViewController! + private(set) var selectedAssets: [PHAsset] = [] deinit { PHPhotoLibrary.shared().unregisterChangeObserver(self) @@ -70,6 +78,8 @@ class PhotoInputViewController: UIViewController, ConversationInputAccessible { if let vc = segue.destination as? PhotoInputGridViewController { vc.fetchResult = allPhotos gridViewController = vc + gridViewController.delegate = self + gridViewController.photoInputViewController = self } } @@ -182,6 +192,7 @@ extension PhotoInputViewController: PHPhotoLibraryChangeObserver { func photoLibraryDidChange(_ changeInstance: PHChange) { DispatchQueue.main.sync { + self.dismissSelectedPhotoInputItemsViewControllerIfNeeded() if let allPhotos = self.allPhotos, let changeDetails = changeInstance.changeDetails(for: allPhotos) { self.allPhotos = changeDetails.fetchResultAfterChanges } @@ -220,6 +231,233 @@ extension PhotoInputViewController: PHPickerViewControllerDelegate { } +extension PhotoInputViewController: PhotoInputGridViewControllerDelegate { + + func photoInputGridViewController(_ controller: PhotoInputGridViewController, didSelect asset: PHAsset) { + guard !selectedAssets.contains(asset) else { + return + } + if selectedAssets.isEmpty { + presentSelectedPhotoInputItemsViewControllerAnimated() + } + selectedAssets.append(asset) + selectedPhotoInputItemsViewController.add(asset) + } + + func photoInputGridViewController(_ controller: PhotoInputGridViewController, didDeselect asset: PHAsset) { + guard let index = selectedAssets.firstIndex(of: asset) else { + return + } + selectedAssets.remove(at: index) + if selectedAssets.isEmpty { + dismissSelectedPhotoInputItemsViewControllerAnimated() + } else { + selectedPhotoInputItemsViewController.remove(asset) + } + } + + func photoInputGridViewControllerDidTapCamera(_ controller: PhotoInputGridViewController) { + dismissSelectedPhotoInputItemsViewControllerIfNeeded() + } + +} + +extension PhotoInputViewController: SelectedPhotoInputItemsViewControllerDelegate { + + func selectedPhotoInputItemsViewController(_ controller: SelectedPhotoInputItemsViewController, didSend assets: [PHAsset]) { + sendItems(assets: assets) + } + + func selectedPhotoInputItemsViewController(_ controller: SelectedPhotoInputItemsViewController, didCancelSend assets: [PHAsset]) { + conversationInputViewController?.dismiss() + } + + func selectedPhotoInputItemsViewController(_ controller: SelectedPhotoInputItemsViewController, didDeselect asset: PHAsset) { + guard let index = selectedAssets.firstIndex(of: asset) else { + return + } + selectedAssets.remove(at: index) + gridViewController.updateVisibleCellBadge() + if selectedAssets.isEmpty { + dismissSelectedPhotoInputItemsViewControllerAnimated() + } + } + + func selectedPhotoInputItemsViewController(_ controller: SelectedPhotoInputItemsViewController, didSelectAssetAt index: Int) { + conversationInputViewController?.setPreferredContentHeightAnimated(.regular) + let window = SelectedPhotoInputItemsPreviewWindow.instance() + window.load(assets: selectedAssets, initIndex: index) + window.delegate = self + window.presentPopupControllerAnimated() + } + +} + +extension PhotoInputViewController: SelectedPhotoInputItemsPreviewWindowDelegate { + + func selectedPhotoInputItemsPreviewWindow(_ window: SelectedPhotoInputItemsPreviewWindow, willDismissWindow assets: [PHAsset]) { + if assets.isEmpty { + selectedAssets.removeAll() + dismissSelectedPhotoInputItemsViewControllerAnimated() + } else { + selectedAssets = assets + selectedPhotoInputItemsViewController.updateAssets(assets) + } + gridViewController.updateVisibleCellBadge() + } + + func selectedPhotoInputItemsPreviewWindow(_ window: SelectedPhotoInputItemsPreviewWindow, didTapSendItems assets: [PHAsset]) { + sendItems(assets: assets) + } + + func selectedPhotoInputItemsPreviewWindow(_ window: SelectedPhotoInputItemsPreviewWindow, didTapSendFiles assets: [PHAsset]) { + sendAsFiles(assets: assets) + } + +} + +extension PhotoInputViewController { + + private func sendItems(assets: [PHAsset]) { + guard let controller = conversationInputViewController else { + return + } + assets.forEach(controller.send(asset:)) + selectedAssets.removeAll() + gridViewController.updateVisibleCellBadge() + dismissSelectedPhotoInputItemsViewControllerAnimated() + } + + private func sendAsFiles(assets: [PHAsset]) { + guard let controller = conversationInputViewController else { + return + } + let hud = Hud() + hud.show(style: .busy, text: "", on: AppDelegate.current.mainWindow) + requestURLs(for: assets) { [weak self] urls in + hud.hide() + guard let self = self else { + return + } + urls.forEach(controller.sendFile(url:)) + self.selectedAssets.removeAll() + self.gridViewController.updateVisibleCellBadge() + self.dismissSelectedPhotoInputItemsViewControllerAnimated() + } + } + + func dismissSelectedPhotoInputItemsViewControllerIfNeeded() { + guard let controller = selectedPhotoInputItemsViewControllerIfLoaded, controller.parent != nil else { + return + } + selectedAssets.removeAll() + gridViewController.updateVisibleCellBadge() + gridViewController.view.isUserInteractionEnabled = false + controller.removeAllAssets() + controller.view.removeFromSuperview() + controller.removeFromParent() + controller.view.snp.removeConstraints() + gridViewController.view.isUserInteractionEnabled = true + } + + private func presentSelectedPhotoInputItemsViewControllerAnimated() { + guard + selectedPhotoInputItemsViewController.parent == nil, + let conversationInputViewController = conversationInputViewController, + let inputBarView = conversationInputViewController.inputBarView, + let conversationViewController = conversationInputViewController.parent + else { + return + } + gridViewController.view.isUserInteractionEnabled = false + let controller = selectedPhotoInputItemsViewController + let viewHeight = selectedPhotoInputItemsViewController.viewHeight + addChild(controller) + view.insertSubview(controller.view, at: 0) + controller.view.snp.makeConstraints({ (make) in + make.left.right.equalToSuperview() + make.top.equalTo(inputBarView.snp.bottom).offset(0) + }) + view.layoutIfNeeded() + controller.view.snp.updateConstraints { make in + make.top.equalTo(inputBarView.snp.bottom).offset(-viewHeight) + } + UIView.animate(withDuration: 0.3, delay: 0, options: .overdampedCurve) { + self.view.layoutIfNeeded() + } completion: { _ in + conversationViewController.addChild(controller) + conversationViewController.view.addSubview(controller.view) + controller.view.snp.remakeConstraints({ (make) in + make.left.right.equalToSuperview() + make.top.equalTo(inputBarView.snp.bottom).offset(-viewHeight) + }) + self.gridViewController.view.isUserInteractionEnabled = true + } + } + + private func dismissSelectedPhotoInputItemsViewControllerAnimated() { + guard + selectedPhotoInputItemsViewController.parent != nil, + let conversationInputViewController = conversationInputViewController, + let inputBarView = conversationInputViewController.inputBarView + else { + return + } + gridViewController.view.isUserInteractionEnabled = false + let controller = selectedPhotoInputItemsViewController + let viewHeight = selectedPhotoInputItemsViewController.viewHeight + addChild(controller) + view.insertSubview(controller.view, at: 0) + controller.view.snp.remakeConstraints({ (make) in + make.left.right.equalToSuperview() + make.top.equalTo(inputBarView.snp.bottom).offset(-viewHeight) + }) + view.layoutIfNeeded() + controller.view.snp.updateConstraints { make in + make.top.equalTo(inputBarView.snp.bottom).offset(0) + } + UIView.animate(withDuration: 0.3, delay: 0, options: .overdampedCurve) { + self.view.layoutIfNeeded() + } completion: { _ in + controller.removeAllAssets() + controller.view.removeFromSuperview() + controller.removeFromParent() + controller.view.snp.removeConstraints() + self.gridViewController.view.isUserInteractionEnabled = true + } + } + + private func requestURLs(for assets: [PHAsset], completion: @escaping ((_ urls : [URL]) -> Void)) { + let group = DispatchGroup() + let queue = DispatchQueue(label: "one.mixin.messager.PhotoInputViewController.requestPHAssetsURLs", attributes: .concurrent) + var urls: [URL?] = Array(repeating: nil, count: assets.count) + for (index, asset) in assets.enumerated() { + group.enter() + queue.async(group: group) { + if asset.mediaType == .image { + let options = PHContentEditingInputRequestOptions() + options.canHandleAdjustmentData = { (adjustmeta: PHAdjustmentData) -> Bool in true } + asset.requestContentEditingInput(with: options, completionHandler: { (contentEditingInput, info) in + urls.insert(contentEditingInput?.fullSizeImageURL, at: index) + group.leave() + }) + } else if asset.mediaType == .video { + let options: PHVideoRequestOptions = PHVideoRequestOptions() + options.version = .original + PHImageManager.default().requestAVAsset(forVideo: asset, options: options, resultHandler: { (asset, audioMix, info) in + urls.insert((asset as? AVURLAsset)?.url, at: index) + group.leave() + }) + } + } + } + group.notify(queue: .main) { + completion(urls.compactMap { $0 }) + } + } + +} + fileprivate let collectionSubtypeOrder: [PHAssetCollectionSubtype: Int] = { var idx = -1 var autoIncrement: Int { diff --git a/Mixin/UserInterface/Controllers/Chat/SelectedPhotoInputItemsViewController.swift b/Mixin/UserInterface/Controllers/Chat/SelectedPhotoInputItemsViewController.swift new file mode 100644 index 0000000000..e88f62572a --- /dev/null +++ b/Mixin/UserInterface/Controllers/Chat/SelectedPhotoInputItemsViewController.swift @@ -0,0 +1,134 @@ +import UIKit +import Photos + +protocol SelectedPhotoInputItemsViewControllerDelegate: AnyObject { + func selectedPhotoInputItemsViewController(_ controller: SelectedPhotoInputItemsViewController, didSend assets: [PHAsset]) + func selectedPhotoInputItemsViewController(_ controller: SelectedPhotoInputItemsViewController, didDeselect asset: PHAsset) + func selectedPhotoInputItemsViewController(_ controller: SelectedPhotoInputItemsViewController, didSelectAssetAt index: Int) + func selectedPhotoInputItemsViewController(_ controller: SelectedPhotoInputItemsViewController, didCancelSend assets: [PHAsset]) +} + +final class SelectedPhotoInputItemsViewController: UIViewController { + + let viewHeight: CGFloat = 224 + + @IBOutlet weak var collectionView: UICollectionView! + @IBOutlet weak var sendButton: UIButton! + + weak var delegate: SelectedPhotoInputItemsViewControllerDelegate? + + private var cellSizeCache = [String: CGSize]() + private var assets = [PHAsset]() { + didSet { + sendButton.setTitle(R.string.localizable.send_count(assets.count), for: .normal) + } + } + + @IBAction func cancelAction(_ sender: Any) { + delegate?.selectedPhotoInputItemsViewController(self, didCancelSend: assets) + } + + @IBAction func sendAction(_ sender: Any) { + delegate?.selectedPhotoInputItemsViewController(self, didSend: assets) + } + +} + +extension SelectedPhotoInputItemsViewController { + + func add(_ asset: PHAsset) { + guard !assets.contains(asset) else { + return + } + assets.append(asset) + let index = IndexPath(item: assets.count - 1, section: 0) + collectionView.insertItems(at: [index]) + collectionView.scrollToItem(at: index, at: .centeredHorizontally, animated: true) + } + + func remove(_ asset: PHAsset) { + guard let index = assets.firstIndex(of: asset) else { + return + } + assets.remove(at: index) + cellSizeCache.removeValue(forKey: asset.localIdentifier) + collectionView.deleteItems(at: [IndexPath(item: index, section: 0)]) + } + + func removeAllAssets() { + cellSizeCache.removeAll() + assets.removeAll() + collectionView.reloadData() + } + + func updateAssets(_ selectedAssets: [PHAsset]) { + cellSizeCache.removeAll() + assets = selectedAssets + collectionView.reloadData() + } + +} + +extension SelectedPhotoInputItemsViewController: UICollectionViewDataSource { + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + assets.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: R.reuseIdentifier.selected_media, for: indexPath)! + if indexPath.item < assets.count { + let asset = assets[indexPath.item] + cell.load(asset: asset, size: cellSizeForItemAt(indexPath.item)) + cell.deselectAsset = { [weak self] in + guard let self = self else { + return + } + self.remove(asset) + self.delegate?.selectedPhotoInputItemsViewController(self, didDeselect: asset) + } + } + return cell + } + +} + +extension SelectedPhotoInputItemsViewController: UICollectionViewDelegateFlowLayout, UICollectionViewDelegate { + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + cellSizeForItemAt(indexPath.item) + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + delegate?.selectedPhotoInputItemsViewController(self, didSelectAssetAt: indexPath.item) + } + +} + +extension SelectedPhotoInputItemsViewController { + + private func cellSizeForItemAt(_ index: Int) -> CGSize { + guard index < assets.count else { + return .zero + } + let asset = assets[index] + if let size = cellSizeCache[asset.localIdentifier] { + return size + } else { + let height: CGFloat = 160 + let width: CGFloat + let ratio = CGFloat(asset.pixelWidth) / CGFloat(asset.pixelHeight) + if ratio > 1 { + width = ceil(height / 3 * 4) + } else if ratio < 1 { + width = ceil(height / 4 * 3) + } else { + width = height + } + let size = CGSize(width: width, height: height) + cellSizeCache[asset.localIdentifier] = size + return size + } + } + +} diff --git a/Mixin/UserInterface/Controllers/Chat/Views/MediaTypeOverlayView.swift b/Mixin/UserInterface/Controllers/Chat/Views/MediaTypeOverlayView.swift index 4a27ecc559..8c27666ea5 100644 --- a/Mixin/UserInterface/Controllers/Chat/Views/MediaTypeOverlayView.swift +++ b/Mixin/UserInterface/Controllers/Chat/Views/MediaTypeOverlayView.swift @@ -13,6 +13,9 @@ class MediaTypeOverlayView: UIView, XibDesignable { @IBOutlet weak var gifFileTypeView: UILabel! @IBOutlet weak var videoTypeView: UIStackView! @IBOutlet weak var videoDurationLabel: UILabel! + @IBOutlet weak var videoImageView: UIImageView! + + @IBOutlet weak var typeViewBottomConstraint: NSLayoutConstraint! class var backgroundImage: UIImage? { return R.image.conversation.bg_photo_bottom_shadow() diff --git a/Mixin/UserInterface/Controllers/Chat/Views/MediaTypeOverlayView.xib b/Mixin/UserInterface/Controllers/Chat/Views/MediaTypeOverlayView.xib index 368dd0924c..285f20ca37 100644 --- a/Mixin/UserInterface/Controllers/Chat/Views/MediaTypeOverlayView.xib +++ b/Mixin/UserInterface/Controllers/Chat/Views/MediaTypeOverlayView.xib @@ -1,9 +1,9 @@ - + - + @@ -11,7 +11,9 @@ + + @@ -24,7 +26,7 @@ - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2357,20 +2509,52 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -2390,14 +2574,17 @@ + + + - + @@ -2809,6 +2996,8 @@ + + diff --git a/Mixin/UserInterface/Windows/Cells/MediaPreviewCell.swift b/Mixin/UserInterface/Windows/Cells/MediaPreviewCell.swift new file mode 100644 index 0000000000..e984451e34 --- /dev/null +++ b/Mixin/UserInterface/Windows/Cells/MediaPreviewCell.swift @@ -0,0 +1,62 @@ +import UIKit +import Photos +import SDWebImage +import CoreServices +import MixinServices + +class MediaPreviewCell: UICollectionViewCell { + + static let cellSize = CGSize(width: 312, height: 312) + + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var selectedStatusImageView: UIImageView! + @IBOutlet weak var mediaTypeView: MediaTypeOverlayView! + + private var requestId: PHImageRequestID? + private lazy var imageRequestOptions: PHImageRequestOptions = { + let options = PHImageRequestOptions() + options.version = .current + options.deliveryMode = .opportunistic + options.isNetworkAccessAllowed = true + return options + }() + + override func awakeFromNib() { + super.awakeFromNib() + selectedStatusImageView.isHidden = false + mediaTypeView.videoTypeView.spacing = 8 + mediaTypeView.typeViewBottomConstraint.constant = 8 + mediaTypeView.gifFileTypeView.font = .systemFont(ofSize: 16) + mediaTypeView.videoDurationLabel.font = .systemFont(ofSize: 16) + mediaTypeView.videoImageView.image = R.image.conversation.ic_video_bold() + } + + override func prepareForReuse() { + super.prepareForReuse() + imageView.image = nil + if let id = requestId { + PHCachingImageManager.default().cancelImageRequest(id) + } + } + + func load(asset: PHAsset) { + if asset.mediaType == .video { + mediaTypeView.style = .video(duration: asset.duration) + } else { + if let uti = asset.uniformTypeIdentifier, UTTypeConformsTo(uti as CFString, kUTTypeGIF) { + mediaTypeView.style = .gif + } else { + mediaTypeView.style = .hidden + } + } + let targetSize = Self.cellSize * UIScreen.main.scale + requestId = PHImageManager.default().requestImage(for: asset, targetSize: targetSize, contentMode: .aspectFill, options: imageRequestOptions) { [weak self] (image, info) in + self?.imageView.image = image + } + } + + func updateSelectedStatus(isSelected: Bool) { + selectedStatusImageView.image = isSelected ? R.image.ic_photo_checkmark() : R.image.ic_photo_unselected() + } + +} diff --git a/Mixin/UserInterface/Windows/Cells/MediaPreviewCell.xib b/Mixin/UserInterface/Windows/Cells/MediaPreviewCell.xib new file mode 100644 index 0000000000..31ea635d0e --- /dev/null +++ b/Mixin/UserInterface/Windows/Cells/MediaPreviewCell.xib @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Mixin/UserInterface/Windows/SelectedPhotoInputItemsPreviewWindow.swift b/Mixin/UserInterface/Windows/SelectedPhotoInputItemsPreviewWindow.swift new file mode 100644 index 0000000000..6151540c38 --- /dev/null +++ b/Mixin/UserInterface/Windows/SelectedPhotoInputItemsPreviewWindow.swift @@ -0,0 +1,181 @@ +import UIKit +import Photos + +protocol SelectedPhotoInputItemsPreviewWindowDelegate: AnyObject { + func selectedPhotoInputItemsPreviewWindow(_ window: SelectedPhotoInputItemsPreviewWindow, didTapSendItems assets: [PHAsset]) + func selectedPhotoInputItemsPreviewWindow(_ window: SelectedPhotoInputItemsPreviewWindow, didTapSendFiles assets: [PHAsset]) + func selectedPhotoInputItemsPreviewWindow(_ window: SelectedPhotoInputItemsPreviewWindow, willDismissWindow assets: [PHAsset]) +} + +final class SelectedPhotoInputItemsPreviewWindow: BottomSheetView { + + @IBOutlet weak var label: UILabel! + @IBOutlet weak var collectionView: UICollectionView! + @IBOutlet weak var sendPhotoButton: RoundedButton! + @IBOutlet weak var sendFileButton: UIButton! + @IBOutlet weak var flowLayout: SnapCenterFlowLayout! + + @IBOutlet weak var collectionViewHeightConstraint: NSLayoutConstraint! + + weak var delegate: SelectedPhotoInputItemsPreviewWindowDelegate? + + private var assets = [PHAsset]() + private var selectedAssets = [PHAsset]() + private var lastWidth: CGFloat = 0 + private var isSending = false + + override func awakeFromNib() { + super.awakeFromNib() + sendFileButton.setTitleColor(.theme, for: .normal) + sendFileButton.setTitleColor(R.color.button_background_disabled(), for: .disabled) + collectionView.decelerationRate = .fast + collectionView.isPagingEnabled = false + collectionView.delegate = self + collectionView.dataSource = self + collectionView.allowsMultipleSelection = true + collectionView.register(R.nib.mediaPreviewCell) + } + + override func layoutSubviews() { + super.layoutSubviews() + let width = bounds.width + if lastWidth != width { + lastWidth = width + let inset = (width - collectionViewHeightConstraint.constant) / 2 + flowLayout.sectionInset = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset) + } + } + + override func dismissPopupControllerAnimated() { + if !isSending { + delegate?.selectedPhotoInputItemsPreviewWindow(self, willDismissWindow: selectedAssets) + } + super.dismissPopupControllerAnimated() + } + + @IBAction func closeAction(_ sender: Any) { + isSending = false + dismissPopupControllerAnimated() + } + + @IBAction func sendPhotosAction(_ sender: Any) { + isSending = true + delegate?.selectedPhotoInputItemsPreviewWindow(self, didTapSendItems: selectedAssets) + dismissPopupControllerAnimated() + } + + @IBAction func sendAsFilesAction(_ sender: Any) { + isSending = true + delegate?.selectedPhotoInputItemsPreviewWindow(self, didTapSendFiles: selectedAssets) + dismissPopupControllerAnimated() + } + + func load(assets: [PHAsset], initIndex: Int) { + self.assets = assets + selectedAssets = assets + updateUI() + collectionView.reloadData() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.collectionView.scrollToItem(at: IndexPath(item: initIndex, section: 0), at: .centeredHorizontally, animated: false) + } + } + + class func instance() -> SelectedPhotoInputItemsPreviewWindow { + R.nib.selectedPhotoInputItemsPreviewWindow(owner: nil)! + } + +} + +extension SelectedPhotoInputItemsPreviewWindow: UICollectionViewDataSource { + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + assets.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: R.reuseIdentifier.media_preview, for: indexPath)! + if indexPath.item < assets.count { + let asset = assets[indexPath.item] + cell.load(asset: asset) + cell.updateSelectedStatus(isSelected: selectedAssets.contains(asset)) + } + return cell + } + +} + +extension SelectedPhotoInputItemsPreviewWindow: UICollectionViewDelegate, UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + MediaPreviewCell.cellSize + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + collectionView.deselectItem(at: indexPath, animated: true) + guard let cell = collectionView.cellForItem(at: indexPath) as? MediaPreviewCell else { + return + } + let asset = assets[indexPath.item] + if let index = selectedAssets.firstIndex(of: asset) { + selectedAssets.remove(at: index) + cell.updateSelectedStatus(isSelected: false) + } else { + selectedAssets.append(asset) + cell.updateSelectedStatus(isSelected: true) + } + updateUI() + } + +} + +extension SelectedPhotoInputItemsPreviewWindow { + + private func updateUI() { + let title: String + let sendPhotoButtonTitle: String + let sendFileButtonTitle: String + let isEnabled: Bool + if selectedAssets.count == 0 { + title = R.string.localizable.no_items_selected() + sendPhotoButtonTitle = R.string.localizable.send_item_count(0) + sendFileButtonTitle = R.string.localizable.send_as_files() + isEnabled = false + } else if selectedAssets.count == 1 { + switch selectedAssets[0].mediaType { + case .image: + title = R.string.localizable.selected_photo() + sendPhotoButtonTitle = R.string.localizable.send_photo() + case .video: + title = R.string.localizable.selected_video() + sendPhotoButtonTitle = R.string.localizable.send_video() + default: + title = R.string.localizable.selected_item() + sendPhotoButtonTitle = R.string.localizable.send_item() + } + sendFileButtonTitle = R.string.localizable.send_as_file() + isEnabled = true + } else { + let count = selectedAssets.count + let isAllImages = selectedAssets.allSatisfy { $0.mediaType == .image } + let isAllVideos = selectedAssets.allSatisfy { $0.mediaType == .video } + if isAllImages { + title = R.string.localizable.selected_photo_count(count) + sendPhotoButtonTitle = R.string.localizable.send_photo_count(count) + } else if isAllVideos { + title = R.string.localizable.selected_video_count(count) + sendPhotoButtonTitle = R.string.localizable.send_video_count(count) + } else { + title = R.string.localizable.selected_item_count(count) + sendPhotoButtonTitle = R.string.localizable.send_item_count(count) + } + sendFileButtonTitle = R.string.localizable.send_as_files() + isEnabled = true + } + label.text = title + sendPhotoButton.setTitle(sendPhotoButtonTitle, for: .normal) + sendFileButton.setTitle(sendFileButtonTitle, for: .normal) + sendPhotoButton.isEnabled = isEnabled + sendFileButton.isEnabled = isEnabled + } + +} diff --git a/Mixin/UserInterface/Windows/SelectedPhotoInputItemsPreviewWindow.xib b/Mixin/UserInterface/Windows/SelectedPhotoInputItemsPreviewWindow.xib new file mode 100644 index 0000000000..4c3c1819bf --- /dev/null +++ b/Mixin/UserInterface/Windows/SelectedPhotoInputItemsPreviewWindow.xib @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 2da4f6fa528c632fa24581a9ff2fed07b729031d Mon Sep 17 00:00:00 2001 From: fanyu Date: Wed, 1 Jun 2022 11:16:50 +0800 Subject: [PATCH 2/6] Update cell size --- ...electedPhotoInputItemsViewController.swift | 12 ++--------- .../Windows/Cells/MediaPreviewCell.swift | 7 ++----- ...SelectedPhotoInputItemsPreviewWindow.swift | 21 +++++++++++++++++-- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/Mixin/UserInterface/Controllers/Chat/SelectedPhotoInputItemsViewController.swift b/Mixin/UserInterface/Controllers/Chat/SelectedPhotoInputItemsViewController.swift index e88f62572a..b6820b7826 100644 --- a/Mixin/UserInterface/Controllers/Chat/SelectedPhotoInputItemsViewController.swift +++ b/Mixin/UserInterface/Controllers/Chat/SelectedPhotoInputItemsViewController.swift @@ -116,16 +116,8 @@ extension SelectedPhotoInputItemsViewController { return size } else { let height: CGFloat = 160 - let width: CGFloat - let ratio = CGFloat(asset.pixelWidth) / CGFloat(asset.pixelHeight) - if ratio > 1 { - width = ceil(height / 3 * 4) - } else if ratio < 1 { - width = ceil(height / 4 * 3) - } else { - width = height - } - let size = CGSize(width: width, height: height) + let width: CGFloat = ceil(height / CGFloat(asset.pixelHeight) * CGFloat(asset.pixelWidth)) + let size = CGSize(width: min(160, max(width, 62)), height: height) cellSizeCache[asset.localIdentifier] = size return size } diff --git a/Mixin/UserInterface/Windows/Cells/MediaPreviewCell.swift b/Mixin/UserInterface/Windows/Cells/MediaPreviewCell.swift index e984451e34..bb7bd27ec8 100644 --- a/Mixin/UserInterface/Windows/Cells/MediaPreviewCell.swift +++ b/Mixin/UserInterface/Windows/Cells/MediaPreviewCell.swift @@ -6,8 +6,6 @@ import MixinServices class MediaPreviewCell: UICollectionViewCell { - static let cellSize = CGSize(width: 312, height: 312) - @IBOutlet weak var imageView: UIImageView! @IBOutlet weak var selectedStatusImageView: UIImageView! @IBOutlet weak var mediaTypeView: MediaTypeOverlayView! @@ -39,7 +37,7 @@ class MediaPreviewCell: UICollectionViewCell { } } - func load(asset: PHAsset) { + func load(asset: PHAsset, size: CGSize) { if asset.mediaType == .video { mediaTypeView.style = .video(duration: asset.duration) } else { @@ -49,8 +47,7 @@ class MediaPreviewCell: UICollectionViewCell { mediaTypeView.style = .hidden } } - let targetSize = Self.cellSize * UIScreen.main.scale - requestId = PHImageManager.default().requestImage(for: asset, targetSize: targetSize, contentMode: .aspectFill, options: imageRequestOptions) { [weak self] (image, info) in + requestId = PHImageManager.default().requestImage(for: asset, targetSize: size * UIScreen.main.scale, contentMode: .aspectFill, options: imageRequestOptions) { [weak self] (image, info) in self?.imageView.image = image } } diff --git a/Mixin/UserInterface/Windows/SelectedPhotoInputItemsPreviewWindow.swift b/Mixin/UserInterface/Windows/SelectedPhotoInputItemsPreviewWindow.swift index 6151540c38..7059c2503f 100644 --- a/Mixin/UserInterface/Windows/SelectedPhotoInputItemsPreviewWindow.swift +++ b/Mixin/UserInterface/Windows/SelectedPhotoInputItemsPreviewWindow.swift @@ -23,6 +23,7 @@ final class SelectedPhotoInputItemsPreviewWindow: BottomSheetView { private var selectedAssets = [PHAsset]() private var lastWidth: CGFloat = 0 private var isSending = false + private var cellSizeCache = [String: CGSize]() override func awakeFromNib() { super.awakeFromNib() @@ -96,7 +97,7 @@ extension SelectedPhotoInputItemsPreviewWindow: UICollectionViewDataSource { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: R.reuseIdentifier.media_preview, for: indexPath)! if indexPath.item < assets.count { let asset = assets[indexPath.item] - cell.load(asset: asset) + cell.load(asset: asset, size: cellSizeForItemAt(indexPath.item)) cell.updateSelectedStatus(isSelected: selectedAssets.contains(asset)) } return cell @@ -107,7 +108,7 @@ extension SelectedPhotoInputItemsPreviewWindow: UICollectionViewDataSource { extension SelectedPhotoInputItemsPreviewWindow: UICollectionViewDelegate, UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - MediaPreviewCell.cellSize + cellSizeForItemAt(indexPath.item) } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { @@ -178,4 +179,20 @@ extension SelectedPhotoInputItemsPreviewWindow { sendFileButton.isEnabled = isEnabled } + private func cellSizeForItemAt(_ index: Int) -> CGSize { + guard index < assets.count else { + return .zero + } + let asset = assets[index] + if let size = cellSizeCache[asset.localIdentifier] { + return size + } else { + let height: CGFloat = 312 + let width: CGFloat = ceil(height / CGFloat(asset.pixelHeight) * CGFloat(asset.pixelWidth)) + let size = CGSize(width: min(312, max(width, 120)), height: height) + cellSizeCache[asset.localIdentifier] = size + return size + } + } + } From cae52ab0eba93a0cf6deb91a15a39b3f8c8ee0c6 Mon Sep 17 00:00:00 2001 From: fanyu Date: Wed, 1 Jun 2022 15:21:33 +0800 Subject: [PATCH 3/6] Update local title --- Mixin/UserInterface/Storyboard/Chat.storyboard | 4 ++-- .../Windows/SelectedPhotoInputItemsPreviewWindow.xib | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Mixin/UserInterface/Storyboard/Chat.storyboard b/Mixin/UserInterface/Storyboard/Chat.storyboard index 8b9bf23ba6..035012b297 100644 --- a/Mixin/UserInterface/Storyboard/Chat.storyboard +++ b/Mixin/UserInterface/Storyboard/Chat.storyboard @@ -1556,7 +1556,7 @@ - + @@ -3028,7 +3028,7 @@ - + diff --git a/Mixin/UserInterface/Windows/SelectedPhotoInputItemsPreviewWindow.xib b/Mixin/UserInterface/Windows/SelectedPhotoInputItemsPreviewWindow.xib index 4c3c1819bf..1d85a1ebb1 100644 --- a/Mixin/UserInterface/Windows/SelectedPhotoInputItemsPreviewWindow.xib +++ b/Mixin/UserInterface/Windows/SelectedPhotoInputItemsPreviewWindow.xib @@ -91,7 +91,6 @@ - From 23ab69dac12cae706fc93a8c33bb49bc34453d7f Mon Sep 17 00:00:00 2001 From: fanyu Date: Tue, 16 Aug 2022 10:37:24 +0800 Subject: [PATCH 4/6] Apply code style --- .../Windows/SelectedPhotoInputItemsPreviewWindow.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Mixin/UserInterface/Windows/SelectedPhotoInputItemsPreviewWindow.swift b/Mixin/UserInterface/Windows/SelectedPhotoInputItemsPreviewWindow.swift index 7059c2503f..c84429516b 100644 --- a/Mixin/UserInterface/Windows/SelectedPhotoInputItemsPreviewWindow.swift +++ b/Mixin/UserInterface/Windows/SelectedPhotoInputItemsPreviewWindow.swift @@ -47,28 +47,28 @@ final class SelectedPhotoInputItemsPreviewWindow: BottomSheetView { } } - override func dismissPopupControllerAnimated() { + override func dismissPopupController(animated: Bool) { if !isSending { delegate?.selectedPhotoInputItemsPreviewWindow(self, willDismissWindow: selectedAssets) } - super.dismissPopupControllerAnimated() + super.dismissPopupController(animated: animated) } @IBAction func closeAction(_ sender: Any) { isSending = false - dismissPopupControllerAnimated() + dismissPopupController(animated: true) } @IBAction func sendPhotosAction(_ sender: Any) { isSending = true delegate?.selectedPhotoInputItemsPreviewWindow(self, didTapSendItems: selectedAssets) - dismissPopupControllerAnimated() + dismissPopupController(animated: true) } @IBAction func sendAsFilesAction(_ sender: Any) { isSending = true delegate?.selectedPhotoInputItemsPreviewWindow(self, didTapSendFiles: selectedAssets) - dismissPopupControllerAnimated() + dismissPopupController(animated: true) } func load(assets: [PHAsset], initIndex: Int) { From ff6bff2efe4fad0ff2fe2dbc961ad1bc40b7d5ca Mon Sep 17 00:00:00 2001 From: fanyu Date: Thu, 2 Feb 2023 15:52:03 +0800 Subject: [PATCH 5/6] Display photos stacked --- Mixin.xcodeproj/project.pbxproj | 24 ++ .../Chat/Cells/StackedPhotoMessageCell.swift | 35 ++ .../Chat/ConversationViewController.swift | 167 ++++++--- .../Chat/Model/ConversationDataSource.swift | 227 +++++++++++- .../Model/DetailInfoMessageViewModel.swift | 6 +- .../Chat/Model/MessageViewModel.swift | 2 + .../Chat/Model/MessageViewModelFactory.swift | 10 +- .../Chat/Model/StackedPhotoLayout.swift | 59 +++ .../Model/StackedPhotoMessageViewModel.swift | 35 ++ .../Chat/PhotoInputViewController.swift | 4 +- .../PinMessagesPreviewViewController.swift | 6 - .../StackedPhotoPreviewViewController.swift | 347 ++++++++++++++++++ .../Chat/StaticMessagesViewController.swift | 39 +- .../TranscriptPreviewViewController.swift | 3 +- .../Chat/Views/ConversationTableView.swift | 4 + .../Views/MultipleSelectionActionView.swift | 45 ++- .../Views/MultipleSelectionActionView.xib | 41 ++- .../Chat/Views/QuotePreviewView.swift | 12 + .../Chat/Views/StackedPhotoCell.swift | 53 +++ .../Chat/Views/StackedPhotoView.swift | 47 +++ .../Database/User/DAO/MessageDAO.swift | 6 + .../Database/User/Model/Message.swift | 1 + .../Database/User/Model/MessageItem.swift | 7 +- 23 files changed, 1081 insertions(+), 99 deletions(-) create mode 100644 Mixin/UserInterface/Controllers/Chat/Cells/StackedPhotoMessageCell.swift create mode 100644 Mixin/UserInterface/Controllers/Chat/Model/StackedPhotoLayout.swift create mode 100644 Mixin/UserInterface/Controllers/Chat/Model/StackedPhotoMessageViewModel.swift create mode 100644 Mixin/UserInterface/Controllers/Chat/StackedPhotoPreviewViewController.swift create mode 100644 Mixin/UserInterface/Controllers/Chat/Views/StackedPhotoCell.swift create mode 100644 Mixin/UserInterface/Controllers/Chat/Views/StackedPhotoView.swift diff --git a/Mixin.xcodeproj/project.pbxproj b/Mixin.xcodeproj/project.pbxproj index 2e53395095..a5a2f4928a 100644 --- a/Mixin.xcodeproj/project.pbxproj +++ b/Mixin.xcodeproj/project.pbxproj @@ -630,6 +630,12 @@ 7CB227862907CE060034F9FD /* DepositNetworkCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB227842907CE060034F9FD /* DepositNetworkCell.swift */; }; 7CB227872907CE060034F9FD /* DepositNetworkCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 7CB227852907CE060034F9FD /* DepositNetworkCell.xib */; }; 7CB2A57F27C386F0007D9DEE /* GroupsInCommonViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB2A57E27C386F0007D9DEE /* GroupsInCommonViewController.swift */; }; + 7CBB77D8298B865E00E01F19 /* StackedPhotoMessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CBB77D7298B865E00E01F19 /* StackedPhotoMessageCell.swift */; }; + 7CBB77DA298B94D900E01F19 /* StackedPhotoLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CBB77D9298B94D900E01F19 /* StackedPhotoLayout.swift */; }; + 7CBB77DC298B979900E01F19 /* StackedPhotoMessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CBB77DB298B979900E01F19 /* StackedPhotoMessageViewModel.swift */; }; + 7CBB77DE298B97EF00E01F19 /* StackedPhotoPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CBB77DD298B97EF00E01F19 /* StackedPhotoPreviewViewController.swift */; }; + 7CBB77E0298B9AB700E01F19 /* StackedPhotoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CBB77DF298B9AB700E01F19 /* StackedPhotoCell.swift */; }; + 7CBB77E2298B9ADF00E01F19 /* StackedPhotoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CBB77E1298B9ADF00E01F19 /* StackedPhotoView.swift */; }; 7CBD2EFA268FF6BB00AA0847 /* HomeAppsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CBD2EF9268FF6BB00AA0847 /* HomeAppsStorage.swift */; }; 7CC3B1A026C21F7E0018B0D1 /* ScriptMessageProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC3B19F26C21F7E0018B0D1 /* ScriptMessageProxy.swift */; }; 7CC730502745F95D002780F5 /* StickerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC7304F2745F95D002780F5 /* StickerStore.swift */; }; @@ -1663,6 +1669,12 @@ 7CB227842907CE060034F9FD /* DepositNetworkCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DepositNetworkCell.swift; sourceTree = ""; }; 7CB227852907CE060034F9FD /* DepositNetworkCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = DepositNetworkCell.xib; sourceTree = ""; }; 7CB2A57E27C386F0007D9DEE /* GroupsInCommonViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupsInCommonViewController.swift; sourceTree = ""; }; + 7CBB77D7298B865E00E01F19 /* StackedPhotoMessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackedPhotoMessageCell.swift; sourceTree = ""; }; + 7CBB77D9298B94D900E01F19 /* StackedPhotoLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackedPhotoLayout.swift; sourceTree = ""; }; + 7CBB77DB298B979900E01F19 /* StackedPhotoMessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackedPhotoMessageViewModel.swift; sourceTree = ""; }; + 7CBB77DD298B97EF00E01F19 /* StackedPhotoPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackedPhotoPreviewViewController.swift; sourceTree = ""; }; + 7CBB77DF298B9AB700E01F19 /* StackedPhotoCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackedPhotoCell.swift; sourceTree = ""; }; + 7CBB77E1298B9ADF00E01F19 /* StackedPhotoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackedPhotoView.swift; sourceTree = ""; }; 7CBD2EF9268FF6BB00AA0847 /* HomeAppsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeAppsStorage.swift; sourceTree = ""; }; 7CC3B19F26C21F7E0018B0D1 /* ScriptMessageProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptMessageProxy.swift; sourceTree = ""; }; 7CC7304F2745F95D002780F5 /* StickerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickerStore.swift; sourceTree = ""; }; @@ -2261,6 +2273,8 @@ 94D63DD52646C29100FD7EE8 /* MessageViewModelFactory.swift */, 7CC7304F2745F95D002780F5 /* StickerStore.swift */, 7CF7416D27DAD93000DA0004 /* SnapCenterFlowLayout.swift */, + 7CBB77D9298B94D900E01F19 /* StackedPhotoLayout.swift */, + 7CBB77DB298B979900E01F19 /* StackedPhotoMessageViewModel.swift */, ); path = Model; sourceTree = ""; @@ -2920,6 +2934,7 @@ 7C14CFBF26CA35CF0094AF4A /* StaticMessagesViewController.swift */, 94FCB5FE264673C400CCC8FD /* TranscriptPreviewViewController.swift */, 7CB0955226CD18F70049F4C7 /* PinMessagesPreviewViewController.swift */, + 7CBB77DD298B97EF00E01F19 /* StackedPhotoPreviewViewController.swift */, ); path = Chat; sourceTree = ""; @@ -3455,6 +3470,8 @@ 7C14CFC026CA35CF0094AF4A /* StaticMessagesView.xib */, 7C359DCF26A6C173001D3AE4 /* StickerStoreBannerView.swift */, 7C2DE55828DD94D200C00818 /* LiveTextImageView.swift */, + 7CBB77DF298B9AB700E01F19 /* StackedPhotoCell.swift */, + 7CBB77E1298B9ADF00E01F19 /* StackedPhotoView.swift */, ); path = Views; sourceTree = ""; @@ -3642,6 +3659,7 @@ 7BA1768D244ACE2E007D50FD /* PickerCell.swift */, 7B3CDA6324FFDD1D003A3E80 /* FavoriteStickerCell.swift */, 7CE4BA27283CD2C9001C87D5 /* SelectedMediaCell.swift */, + 7CBB77D7298B865E00E01F19 /* StackedPhotoMessageCell.swift */, ); path = Cells; sourceTree = ""; @@ -4696,6 +4714,7 @@ 7B915F74215FB0C100A562C6 /* GiphySearchViewController.swift in Sources */, 7B7DACA623505793006AA2AC /* AudioCell.swift in Sources */, 5E5CA86D2674B09100C1E113 /* ScreenLockSettingViewController.swift in Sources */, + 7CBB77DE298B97EF00E01F19 /* StackedPhotoPreviewViewController.swift in Sources */, DF5D9F251F9C79E10036D5FD /* LocalizedExtension.swift in Sources */, 7B4FCCE02440A66600360F65 /* SolidBackgroundColorImageView.swift in Sources */, DF1A2C60219BCACC004EFD68 /* ClockSkewViewController.swift in Sources */, @@ -4750,6 +4769,7 @@ 7BEEC0121FDEA19300514A48 /* EncryptionHintViewModel.swift in Sources */, E0D4F5F523A9D803008F0189 /* NotificationManager.swift in Sources */, 7BADC842204C20820016B0FC /* ChangeNumberVerificationCodeViewController.swift in Sources */, + 7CBB77E2298B9ADF00E01F19 /* StackedPhotoView.swift in Sources */, 7CB2A57F27C386F0007D9DEE /* GroupsInCommonViewController.swift in Sources */, 7C66F029268A0384006D8462 /* AppPageCell.swift in Sources */, 7B2A116122C20F5F00AD029C /* PlayerView.swift in Sources */, @@ -4891,12 +4911,14 @@ 7B894AF5205BFC9B0065A1B8 /* NewAddressViewController.swift in Sources */, 7BB5250324160F3D0060DAE1 /* LocationPreviewViewController.swift in Sources */, 7BDEE5C71FA72EB9004CB189 /* SearchViewController.swift in Sources */, + 7CBB77D8298B865E00E01F19 /* StackedPhotoMessageCell.swift in Sources */, 7BF4047B2048F008004C54E4 /* VerifyPinNavigationController.swift in Sources */, DF2A245E1FCC5D15003A8C1E /* GroupAddMemberCell.swift in Sources */, DFB6CE1E23C4805B00FB6615 /* KeychainExtension.swift in Sources */, 7B2E3E5A1FA0816D00DDDDEB /* LoginContinueButton.swift in Sources */, 5E3725BA268D4E0400269FE7 /* HomeAppsSnapshotView.swift in Sources */, 277FF7031F909A1200DBB2EB /* AppDelegate.swift in Sources */, + 7CBB77DC298B979900E01F19 /* StackedPhotoMessageViewModel.swift in Sources */, 7B2E3E4D1FA07F5D00DDDDEB /* HairlineLayoutConstraint.swift in Sources */, 94DF7E0E28DC1697006E415B /* AcknowledgementViewController.swift in Sources */, 7B4ADAA7243C7C8400F04CA6 /* HomeAppsViewController.swift in Sources */, @@ -4951,6 +4973,7 @@ 7B333A8623406AD000FDA848 /* GalleryTransitionFromMessageCellView.swift in Sources */, 7B54F95922B243EA00908A9D /* EmergencyContactVerifyPinViewController.swift in Sources */, 7B68F78B2191741300B79978 /* BiometryType.swift in Sources */, + 7CBB77DA298B94D900E01F19 /* StackedPhotoLayout.swift in Sources */, 7BD0532820A41F6A00C36F69 /* RangeExtension.swift in Sources */, 7BC022E5214A13DC00B7A398 /* AuthorizationsViewController.swift in Sources */, 7C1EDAB42772FF8B00193917 /* VerifyNumberContext.swift in Sources */, @@ -5138,6 +5161,7 @@ 7C9A734027392FAF00E0127A /* PinSettingTableHeaderView.swift in Sources */, 7C4E2B0426A9BDAD008190F5 /* StickersEditingViewController.swift in Sources */, 7B5E9B4C2437434D000AE24E /* CircleEditingButton.swift in Sources */, + 7CBB77E0298B9AB700E01F19 /* StackedPhotoCell.swift in Sources */, 7B4CBDC22528533600BA66D0 /* ClipSwitcher.swift in Sources */, 7CB17B642771602A00CF4C94 /* DeleteAccountConfirmWindow.swift in Sources */, 7BFE47E52284530200FC4379 /* PeerHeaderView.swift in Sources */, diff --git a/Mixin/UserInterface/Controllers/Chat/Cells/StackedPhotoMessageCell.swift b/Mixin/UserInterface/Controllers/Chat/Cells/StackedPhotoMessageCell.swift new file mode 100644 index 0000000000..4890117787 --- /dev/null +++ b/Mixin/UserInterface/Controllers/Chat/Cells/StackedPhotoMessageCell.swift @@ -0,0 +1,35 @@ +import UIKit + +class StackedPhotoMessageCell: ImageMessageCell { + + static let contentCornerRadius: CGFloat = 13 + + private let stackedPhotoView = StackedPhotoView() + + override func prepareForReuse() { + super.prepareForReuse() + stackedPhotoView.viewModels = [] + } + + override func render(viewModel: MessageViewModel) { + super.render(viewModel: viewModel) + if let viewModel = viewModel as? StackedPhotoMessageViewModel { + stackedPhotoView.viewModels = viewModel.photoMessageViewModels + stackedPhotoView.frame = viewModel.stackedPhotoViewFrame + selectedOverlapView.frame = viewModel.photoFrame + trailingInfoBackgroundView.frame = viewModel.trailingInfoBackgroundFrame + } + } + + override func prepare() { + messageContentView.addSubview(stackedPhotoView) + updateAppearance(highlight: false, animated: false) + messageContentView.addSubview(trailingInfoBackgroundView) + super.prepare() + selectedOverlapView.layer.cornerRadius = Self.contentCornerRadius + backgroundImageView.removeFromSuperview() + stackedPhotoView.backgroundColor = .clear + statusImageView.alpha = 0.9 + } + +} diff --git a/Mixin/UserInterface/Controllers/Chat/ConversationViewController.swift b/Mixin/UserInterface/Controllers/Chat/ConversationViewController.swift index 0747b78d54..90614fb3eb 100644 --- a/Mixin/UserInterface/Controllers/Chat/ConversationViewController.swift +++ b/Mixin/UserInterface/Controllers/Chat/ConversationViewController.swift @@ -105,7 +105,12 @@ class ConversationViewController: UIViewController { private(set) lazy var imagePickerController = ImagePickerController(initialCameraPosition: .rear, cropImageAfterPicked: false, parent: self, delegate: self) private lazy var userHandleViewController = R.storyboard.chat.user_handle()! - private lazy var multipleSelectionActionView = R.nib.multipleSelectionActionView(owner: self)! + private lazy var multipleSelectionActionView: MultipleSelectionActionView = { + let view = R.nib.multipleSelectionActionView(owner: self)! + view.delegate = self + view.showCancelButton = false + return view + }() private lazy var announcementBadgeContentView = R.nib.announcementBadgeContentView(owner: self)! private lazy var strangerHintView: StrangerHintView = { @@ -508,52 +513,7 @@ class ConversationViewController: UIViewController { reloadWithMessageId(id, scrollUpwards: true) } } - - @IBAction func multipleSelectionAction(_ sender: Any) { - switch multipleSelectionActionView.intent { - case .forward: - let messages = dataSource.selectedViewModels.values - .map({ $0.message }) - .sorted(by: { $0.createdAt < $1.createdAt }) - let containsTranscriptMessage = messages.contains { - $0.category.hasSuffix("_TRANSCRIPT") - } - if messages.count == 1 || containsTranscriptMessage { - let vc = MessageReceiverViewController.instance(content: .messages(messages)) - navigationController?.pushViewController(vc, animated: true) - } else { - let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) - alert.addAction(UIAlertAction(title: R.string.localizable.one_by_one_forward(), style: .default, handler: { (_) in - let vc = MessageReceiverViewController.instance(content: .messages(messages)) - self.navigationController?.pushViewController(vc, animated: true) - })) - alert.addAction(UIAlertAction(title: R.string.localizable.combine_and_forward(), style: .default, handler: { (_) in - let vc = MessageReceiverViewController.instance(content: .transcript(messages)) - self.navigationController?.pushViewController(vc, animated: true) - })) - alert.addAction(UIAlertAction(title: R.string.localizable.cancel(), style: .cancel, handler: nil)) - present(alert, animated: true, completion: nil) - } - case .delete: - let viewModels = dataSource.selectedViewModels.values.map({ $0 }) - let controller = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) - if !viewModels.contains(where: { $0.message.userId != myUserId || !$0.message.canRecall }) { - controller.addAction(UIAlertAction(title: R.string.localizable.delete_for_everyone(), style: .destructive, handler: { (_) in - if AppGroupUserDefaults.User.hasShownRecallTips { - self.deleteForEveryone(viewModels: viewModels) - } else { - self.showRecallTips(viewModels: viewModels) - } - })) - } - controller.addAction(UIAlertAction(title: R.string.localizable.delete_for_me(), style: .destructive, handler: { (_) in - self.deleteForMe(viewModels: viewModels) - })) - controller.addAction(UIAlertAction(title: R.string.localizable.cancel(), style: .cancel, handler: nil)) - self.present(controller, animated: true, completion: nil) - } - } - + @IBAction func dismissAnnouncementBadgeAction(_ sender: Any) { if dataSource.category == .group { AppGroupUserDefaults.User.hasUnreadAnnouncement.removeValue(forKey: conversationId) @@ -783,9 +743,10 @@ class ConversationViewController: UIViewController { let isImageOrVideo = message.category.hasSuffix("_IMAGE") || message.category.hasSuffix("_VIDEO") let mediaStatusIsReady = message.mediaStatus == MediaStatus.DONE.rawValue || message.mediaStatus == MediaStatus.READ.rawValue if let quoteMessageId = viewModel.message.quoteMessageId, !quoteMessageId.isEmpty, let quote = cell.quotedMessageViewIfLoaded, quote.bounds.contains(recognizer.location(in: quote)) { + let messageId = displayedMessageId(for: quoteMessageId) if let indexPath = dataSource?.indexPath(where: { $0.messageId == quoteMessageId }) { quotingMessageId = message.messageId - scheduleCellBackgroundFlash(messageId: quoteMessageId) + scheduleCellBackgroundFlash(messageId: messageId) tableView.scrollToRow(at: indexPath, at: .middle, animated: true) } else if MessageDAO.shared.hasMessage(id: quoteMessageId) { quotingMessageId = message.messageId @@ -869,6 +830,9 @@ class ConversationViewController: UIViewController { conversationInputViewController.dismiss() let vc = StickerPreviewViewController.instance(message: message) vc.presentAsChild(of: self) + } else if message.category == MessageCategory.STACKED_PHOTO.rawValue { + let vc = StackedPhotoPreviewViewController(conversationId: conversationId, stackedPhotoMessage: message) + vc.presentAsChild(of: self) } else { conversationInputViewController.dismiss() } @@ -909,7 +873,7 @@ class ConversationViewController: UIViewController { cell.messageContentView.frame.origin.x = 0 } if shouldQuote, let viewModel = cell.viewModel { - conversationInputViewController.quote = (viewModel.message, viewModel.thumbnail) + conversationInputViewController.quote = (displayedMessage(for: viewModel.message), viewModel.thumbnail) } case .cancelled, .failed: tableView.isScrollEnabled = true @@ -1375,8 +1339,9 @@ class ConversationViewController: UIViewController { } func scrollToMessage(messageId: String) { - if let indexPath = dataSource.indexPath(where: { $0.messageId == messageId }) { - scheduleCellBackgroundFlash(messageId: messageId) + let msgId = displayedMessageId(for: messageId) + if let indexPath = dataSource.indexPath(where: { $0.messageId == msgId }) { + scheduleCellBackgroundFlash(messageId: msgId) tableView.scrollToRow(at: indexPath, at: .middle, animated: true) } else if MessageDAO.shared.hasMessage(id: messageId) { messageIdToFlashAfterAnimationFinished = messageId @@ -1386,6 +1351,55 @@ class ConversationViewController: UIViewController { } +extension ConversationViewController: MultipleSelectionActionViewDelegate { + + func multipleSelectionActionViewDidTapIntent(_ view: MultipleSelectionActionView) { + switch view.intent { + case .forward: + let messages = dataSource.selectedMessageViewModels + .map({ $0.message }) + .sorted(by: { $0.createdAt < $1.createdAt }) + let containsTranscriptMessage = messages.contains { + $0.category.hasSuffix("_TRANSCRIPT") + } + if messages.count == 1 || containsTranscriptMessage { + let vc = MessageReceiverViewController.instance(content: .messages(messages)) + navigationController?.pushViewController(vc, animated: true) + } else { + let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + alert.addAction(UIAlertAction(title: R.string.localizable.one_by_one_forward(), style: .default, handler: { (_) in + let vc = MessageReceiverViewController.instance(content: .messages(messages)) + self.navigationController?.pushViewController(vc, animated: true) + })) + alert.addAction(UIAlertAction(title: R.string.localizable.combine_and_forward(), style: .default, handler: { (_) in + let vc = MessageReceiverViewController.instance(content: .transcript(messages)) + self.navigationController?.pushViewController(vc, animated: true) + })) + alert.addAction(UIAlertAction(title: R.string.localizable.cancel(), style: .cancel, handler: nil)) + present(alert, animated: true, completion: nil) + } + case .delete: + let viewModels = dataSource.selectedMessageViewModels + let controller = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + if !viewModels.contains(where: { $0.message.userId != myUserId || !$0.message.canRecall }) { + controller.addAction(UIAlertAction(title: R.string.localizable.delete_for_everyone(), style: .destructive, handler: { (_) in + if AppGroupUserDefaults.User.hasShownRecallTips { + self.deleteForEveryone(viewModels: viewModels) + } else { + self.showRecallTips(viewModels: viewModels) + } + })) + } + controller.addAction(UIAlertAction(title: R.string.localizable.delete_for_me(), style: .destructive, handler: { (_) in + self.deleteForMe(viewModels: self.dataSource.selectedViewModels.values.map({ $0 })) + })) + controller.addAction(UIAlertAction(title: R.string.localizable.cancel(), style: .cancel, handler: nil)) + self.present(controller, animated: true, completion: nil) + } + } + + } + // MARK: - UIGestureRecognizerDelegate extension ConversationViewController: UIGestureRecognizerDelegate { @@ -1933,6 +1947,8 @@ extension ConversationViewController { actions = [.delete] } else if category == MessageCategory.MESSAGE_RECALL.rawValue { actions = [.delete] + } else if category == MessageCategory.STACKED_PHOTO.rawValue { + actions = [.reply, .forward, .delete] } else { actions = [] } @@ -1949,6 +1965,7 @@ extension ConversationViewController { index = nil } if let index = index { + let message = displayedMessage(for: message) let action: MessageAction = pinnedMessageIds.contains(message.messageId) ? .unpin : .pin actions.insert(action, at: index) } @@ -1978,7 +1995,7 @@ extension ConversationViewController { case .forward: beginMultipleSelection(on: indexPath, intent: .forward) case .reply: - conversationInputViewController.quote = (message, viewModel.thumbnail) + conversationInputViewController.quote = (displayedMessage(for: message), viewModel.thumbnail) case .addToStickers: if message.category.hasSuffix("_STICKER"), let stickerId = message.stickerId { StickerAPI.addSticker(stickerId: stickerId, completion: { (result) in @@ -2000,10 +2017,10 @@ extension ConversationViewController { report(conversationId: conversationId, message: message) case .pin: dataSource.postponeMessagePinningUpdate(with: message.messageId) - SendMessageService.shared.sendPinMessages(items: [message], conversationId: conversationId, action: .pin) + SendMessageService.shared.sendPinMessages(items: [displayedMessage(for: message)], conversationId: conversationId, action: .pin) case .unpin: dataSource.postponeMessagePinningUpdate(with: message.messageId) - SendMessageService.shared.sendPinMessages(items: [message], conversationId: conversationId, action: .unpin) + SendMessageService.shared.sendPinMessages(items: [displayedMessage(for: message)], conversationId: conversationId, action: .unpin) } } @@ -2699,9 +2716,13 @@ extension ConversationViewController { guard let weakSelf = self, let indexPath = weakSelf.dataSource.indexPath(where: { $0.messageId == message.messageId }) else { return } - let (deleted, childMessageIds) = MessageDAO.shared.deleteMessage(id: message.messageId) - if deleted { - ReceiveMessageService.shared.stopRecallMessage(item: message, childMessageIds: childMessageIds) + if message.category == MessageCategory.STACKED_PHOTO.rawValue { + message.stackedMessageItems?.forEach({ MessageDAO.shared.deleteMessage(id: $0.messageId) }) + } else { + let (deleted, childMessageIds) = MessageDAO.shared.deleteMessage(id: message.messageId) + if deleted { + ReceiveMessageService.shared.stopRecallMessage(item: message, childMessageIds: childMessageIds) + } } DispatchQueue.main.sync { _ = weakSelf.dataSource?.removeViewModel(at: indexPath) @@ -2746,7 +2767,8 @@ extension ConversationViewController { let flashingId = self.messageIdToFlashAfterAnimationFinished self.messageIdToFlashAfterAnimationFinished = nil scroll(messageId, { - guard let indexPath = self.dataSource?.indexPath(where: { $0.messageId == messageId }) else { + let msgId = self.displayedMessageId(for: messageId) + guard let indexPath = self.dataSource?.indexPath(where: { $0.messageId == msgId }) else { return } if scrollUpwards { @@ -2754,7 +2776,7 @@ extension ConversationViewController { } else { self.tableView.scrollToRow(at: indexPath, at: .top, animated: false) } - self.messageIdToFlashAfterAnimationFinished = flashingId + self.messageIdToFlashAfterAnimationFinished = flashingId != nil ? msgId : flashingId self.tableView.scrollToRow(at: indexPath, at: .middle, animated: true) }) } @@ -2878,6 +2900,9 @@ extension ConversationViewController { } else if let viewModel = viewModel as? AppButtonGroupViewModel { param.visiblePath = UIBezierPath(roundedRect: viewModel.buttonGroupFrame, cornerRadius: AppButtonView.cornerRadius) + } else if let viewModel = viewModel as? StackedPhotoMessageViewModel { + param.visiblePath = UIBezierPath(roundedRect: viewModel.stackedPhotoViewFrame, + cornerRadius: StackedPhotoMessageCell.contentCornerRadius) } else { if viewModel.style.contains(.received) { if viewModel.style.contains(.tail) { @@ -2897,6 +2922,30 @@ extension ConversationViewController { return UITargetedPreview(view: cell.messageContentView, parameters: param) } + private func displayedMessageId(for messageId: String) -> String { + guard !dataSource.stackedPhotoMessages.isEmpty else { + return messageId + } + let msgId: String + let stackedPhotoMessage = dataSource.stackedPhotoMessages.first { message in + message.stackedMessageItems?.contains(where: { $0.messageId == messageId }) ?? false + } + if let stackedPhotoMessageId = stackedPhotoMessage?.messageId { + msgId = stackedPhotoMessageId + } else { + msgId = messageId + } + return msgId + } + + private func displayedMessage(for message: MessageItem) -> MessageItem { + if message.category == MessageCategory.STACKED_PHOTO.rawValue, let photoMessage = message.stackedMessageItems?.first { + return photoMessage + } else { + return message + } + } + } // MARK: - Embedded classes diff --git a/Mixin/UserInterface/Controllers/Chat/Model/ConversationDataSource.swift b/Mixin/UserInterface/Controllers/Chat/Model/ConversationDataSource.swift index 56b7428626..c53e95a31b 100644 --- a/Mixin/UserInterface/Controllers/Chat/Model/ConversationDataSource.swift +++ b/Mixin/UserInterface/Controllers/Chat/Model/ConversationDataSource.swift @@ -20,9 +20,25 @@ class ConversationDataSource { var firstUnreadMessageId: String? var focusIndexPath: IndexPath? var selectedViewModels = [String: MessageViewModel]() // Key is message id + var selectedMessageViewModels: [MessageViewModel] { + selectedViewModels.values.reduce(into: []) { result, viewModel in + if viewModel.message.category == MessageCategory.STACKED_PHOTO.rawValue { + if let messages = viewModel.message.stackedMessageItems, !messages.isEmpty { + let models: [MessageViewModel] = factory + .viewModels(with: messages, fits: layoutSize.width) + .viewModels + .reduce([]) { $1.value } + result.append(contentsOf: models) + } + } else { + result.append(viewModel) + } + } + } weak var tableView: ConversationTableView? + private let numberOfConsecutiveImagesToStack = 4 private let numberOfMessagesOnPaging = 100 private let numberOfMessagesOnReloading = 35 private let me = LoginManager.shared.account! @@ -37,7 +53,8 @@ class ConversationDataSource { private(set) var loadedMessageIds = SafeSet() private(set) var didLoadLatestMessage = false private(set) var category: Category - + private(set) var stackedPhotoMessages = [MessageItem]() + private var highlight: Highlight? private var viewModels = [String: [MessageViewModel]]() private var didLoadEarliestMessage = false @@ -94,6 +111,7 @@ class ConversationDataSource { NotificationCenter.default.addObserver(self, selector: #selector(updateMessageMediaStatus(_:)), name: MessageDAO.messageMediaStatusDidUpdateNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(updateMessagePinning(_:)), name: PinMessageDAO.didSaveNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(updateMessagePinning(_:)), name: PinMessageDAO.didDeleteNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(messageDidDelete(_:)), name: MessageDAO.didDeleteMessageNotification, object: nil) reload(completion: completion) } @@ -141,6 +159,7 @@ class ConversationDataSource { self.firstUnreadMessageId = nil canInsertUnreadHint = false } + messages = stackConsecutiveImageMessagesIfneeded(messages) var (dates, viewModels) = factory.viewModels(with: messages, fits: layoutSize.width) if canInsertEncryptionHint && didLoadEarliestMessage { let date: String @@ -300,6 +319,7 @@ class ConversationDataSource { let shouldInsertEncryptionHint = self.canInsertEncryptionHint && didLoadEarliestMessage messages = messages.filter{ !self.loadedMessageIds.contains($0.messageId) } self.loadedMessageIds.formUnion(messages.map({ $0.messageId })) + messages = self.stackConsecutiveImageMessagesIfneeded(messages) var (dates, viewModels) = self.factory.viewModels(with: messages, fits: layoutWidth) if shouldInsertEncryptionHint { let hint = MessageItem.encryptionHintMessage(conversationId: conversationId) @@ -383,6 +403,7 @@ class ConversationDataSource { messages.insert(hint, at: index) self.canInsertUnreadHint = false } + messages = self.stackConsecutiveImageMessagesIfneeded(messages) let (dates, viewModels) = self.factory.viewModels(with: messages, fits: layoutWidth) if let firstDate = dates.first, let messagesBeforeAppend = self.viewModels[firstDate]?.suffix(2).map({ $0.message }) { let messagesForTheDate = messagesBeforeAppend + messages.prefix(2) @@ -519,7 +540,7 @@ extension ConversationDataSource { case .updateMediaContent(let messageId, let message): updateMediaContent(messageId: messageId, message: message) case .recallMessage(let messageId): - updateMessage(messageId: messageId) + recallMessage(messageId: messageId) case .updateExpireIn(let expireIn, let messageId): conversation.expireIn = expireIn if let messageId = messageId { @@ -648,6 +669,100 @@ extension ConversationDataSource { } } + @objc private func messageDidDelete(_ notification: Notification) { + guard let messageId = notification.userInfo?[MessageDAO.UserInfoKey.messageId] as? String else { + return + } + queue.async { + var deletePhotoMessage = false + for (index, message) in self.stackedPhotoMessages.enumerated() { + guard + var imageMessages = message.stackedMessageItems, + let deletedMessageIndex = imageMessages.firstIndex(where: { $0.messageId == messageId }), + let viewModelIndexPath = self.indexPath(where: { $0.messageId == message.messageId }) + else { + continue + } + imageMessages.remove(at: deletedMessageIndex) + message.stackedMessageItems = imageMessages + if deletedMessageIndex == 0 { + message.messageId = imageMessages[0].messageId + } + if imageMessages.count < self.numberOfConsecutiveImagesToStack { + DispatchQueue.main.sync { + guard let tableView = self.tableView, !self.messageProcessingIsCancelled else { + return + } + _ = self.removeViewModel(at: viewModelIndexPath) + self.stackedPhotoMessages.remove(at: index) + tableView.reloadData() + } + imageMessages.forEach(self.addMessageAndDisplay(message:)) + } else { + DispatchQueue.main.sync { + guard let tableView = self.tableView, !self.messageProcessingIsCancelled else { + return + } + let date = DateFormatter.yyyymmdd.string(from: message.createdAt.toUTCDate()) + if let style = self.viewModels[date]?[viewModelIndexPath.row].style { + let viewModel = self.factory.viewModel(withMessage: message, style: style, fits: self.layoutSize.width) + self.viewModels[date]?[viewModelIndexPath.row] = viewModel + tableView.reloadData() + } + } + } + deletePhotoMessage = true + break + } + if !deletePhotoMessage { + guard let indexPath = self.indexPath(where: { $0.messageId == messageId }) else { + return + } + DispatchQueue.main.sync { + guard let tableView = self.tableView else { + return + } + _ = self.removeViewModel(at: indexPath) + tableView.reloadData() + } + } + } + } + + private func recallMessage(messageId: String) { + queue.async { + guard !self.messageProcessingIsCancelled else { + return + } + var recallStackedPhotoMessage = false + for (index, message) in self.stackedPhotoMessages.enumerated() { + guard + var imageMessages = message.stackedMessageItems, + let recallMessageIndex = imageMessages.firstIndex(where: { $0.messageId == messageId }), + let viewModelIndexPath = self.indexPath(where: { $0.messageId == message.messageId }), + let recalledMessage = MessageDAO.shared.getFullMessage(messageId: messageId) + else { + continue + } + imageMessages[recallMessageIndex] = recalledMessage + DispatchQueue.main.sync { + guard let tableView = self.tableView, !self.messageProcessingIsCancelled else { + return + } + _ = self.removeViewModel(at: viewModelIndexPath) + self.stackedPhotoMessages.remove(at: index) + tableView.reloadData() + } + imageMessages.forEach(self.addMessageAndDisplay(message:)) + recallStackedPhotoMessage = true + break + } + if !recallStackedPhotoMessage { + self.updateMessage(messageId: messageId) + } + } + } + private func updateMessageStatus(messageId: String, status: MessageStatus) { guard let indexPath = indexPath(where: { $0.messageId == messageId }), let viewModel = viewModel(for: indexPath) as? DetailInfoMessageViewModel else { return @@ -725,12 +840,16 @@ extension ConversationDataSource { return } let date = DateFormatter.yyyymmdd.string(from: message.createdAt.toUTCDate()) - if let style = self.viewModels[date]?[indexPath.row].style { - let viewModel = self.factory.viewModel(withMessage: message, style: style, fits: self.layoutSize.width) - self.viewModels[date]?[indexPath.row] = viewModel - tableView.reloadData() - self.selectTableViewRowsWithPreviousSelection() + guard + let viewModel = self.viewModels[date]?[indexPath.row], + viewModel.message.category != MessageCategory.STACKED_PHOTO.rawValue + else { + return } + let model = self.factory.viewModel(withMessage: message, style: viewModel.style, fits: self.layoutSize.width) + self.viewModels[date]?[indexPath.row] = model + tableView.reloadData() + self.selectTableViewRowsWithPreviousSelection() } } } @@ -969,6 +1088,100 @@ extension ConversationDataSource { } } + private func stackConsecutiveImageMessagesIfneeded(_ messages: [MessageItem]) -> [MessageItem] { + guard messages.count >= numberOfConsecutiveImagesToStack else { + return messages + } + func createStackedPhotoMessage(_ messages: [MessageItem]) -> MessageItem { + let message = messages[0] + let item = MessageItem(messageId: message.messageId, + conversationId: message.conversationId, + userId: message.userId, + category: MessageCategory.STACKED_PHOTO.rawValue, + thumbImage: message.thumbImage, + status: message.status, + createdAt: message.createdAt, + userFullName: message.userFullName, + userIdentityNumber: message.userIdentityNumber, + userAvatarUrl: message.userAvatarUrl, + messageItems: messages) + stackedPhotoMessages.append(item) + return item + } + var result = [MessageItem]() + var messagesToStack = [MessageItem]() + var left = 0 + var right = 1 + var unableToStack: Bool { + messagesToStack.count < numberOfConsecutiveImagesToStack || + messagesToStack.contains(where: { $0.mediaStatus != MediaStatus.DONE.rawValue }) + } + while right < messages.count { + if !messages[left].category.hasSuffix("_IMAGE") { + result.append(messages[left]) + left += 1 + right += 1 + } else if !messages[left].quoteMessageId.isNilOrEmpty { + result.append(messages[left]) + left += 1 + right += 1 + } else if !messages[right].category.hasSuffix("_IMAGE") { + if messagesToStack.isEmpty { + result.append(contentsOf: messages[left...right]) + } else if unableToStack { + result.append(contentsOf: messagesToStack) + result.append(messages[right]) + messagesToStack.removeAll() + } else { + result.append(createStackedPhotoMessage(messagesToStack)) + result.append(messages[right]) + messagesToStack.removeAll() + } + left = right + 1 + right = left + 1 + } else if messages[left].userId != messages[right].userId { + if messagesToStack.isEmpty { + result.append(messages[left]) + } else if unableToStack { + result.append(contentsOf: messagesToStack) + messagesToStack.removeAll() + } else { + result.append(createStackedPhotoMessage(messagesToStack)) + messagesToStack.removeAll() + } + left = right + right += 1 + } else if !messages[right].quoteMessageId.isNilOrEmpty { + if messagesToStack.isEmpty { + result.append(messages[left]) + } else if unableToStack { + result.append(contentsOf: messagesToStack) + messagesToStack.removeAll() + } else { + result.append(createStackedPhotoMessage(messagesToStack)) + messagesToStack.removeAll() + } + result.append(messages[right]) + left = right + 1 + right += 1 + } else { + messagesToStack = Array(messages[left...right]) + right += 1 + } + } + if left == messages.count - 1 { + result.append(messages[left]) + } + if !messagesToStack.isEmpty { + if unableToStack { + result.append(contentsOf: messagesToStack) + } else { + result.append(createStackedPhotoMessage(messagesToStack)) + } + messagesToStack.removeAll() + } + return result + } } // MARK: - Embedded class diff --git a/Mixin/UserInterface/Controllers/Chat/Model/DetailInfoMessageViewModel.swift b/Mixin/UserInterface/Controllers/Chat/Model/DetailInfoMessageViewModel.swift index 7fb48f9411..018ece161f 100644 --- a/Mixin/UserInterface/Controllers/Chat/Model/DetailInfoMessageViewModel.swift +++ b/Mixin/UserInterface/Controllers/Chat/Model/DetailInfoMessageViewModel.swift @@ -64,7 +64,8 @@ class DetailInfoMessageViewModel: MessageViewModel { private let minFullnameWidth: CGFloat = 16 private let minDetailInfoLeftMargin: CGFloat = 8 private let statusHighlightTintColor = UIColor.theme - + private let stackedPhotoMargin: CGFloat = 40 + override func layout(width: CGFloat, style: MessageViewModel.Style) { super.layout(width: width, style: style) let fullnameSize = ((message.userFullName ?? "") as NSString) @@ -105,6 +106,9 @@ class DetailInfoMessageViewModel: MessageViewModel { + statusFrame.width timeFrame.origin.x -= offset } + if isStackedPhoto { + timeFrame.origin.x -= stackedPhotoMargin + } if style.contains(.fullname) { let index = message.userId.positiveHashCode() % UIColor.usernameColors.count fullnameColor = UIColor.usernameColors[index] diff --git a/Mixin/UserInterface/Controllers/Chat/Model/MessageViewModel.swift b/Mixin/UserInterface/Controllers/Chat/Model/MessageViewModel.swift index 6f11ecf791..b871fa2614 100644 --- a/Mixin/UserInterface/Controllers/Chat/Model/MessageViewModel.swift +++ b/Mixin/UserInterface/Controllers/Chat/Model/MessageViewModel.swift @@ -18,6 +18,7 @@ class MessageViewModel: CustomDebugStringConvertible { let isEncrypted: Bool let isPinned: Bool let time: String + let isStackedPhoto: Bool let quotedMessageViewModel: QuotedMessageViewModel? private(set) var layoutWidth: CGFloat = 414 @@ -64,6 +65,7 @@ class MessageViewModel: CustomDebugStringConvertible { self.isEncrypted = message.category.hasPrefix("SIGNAL_") || message.category.hasPrefix("ENCRYPTED_") self.isPinned = message.isPinned ?? false self.time = message.createdAt.toUTCDate().timeHoursAndMinutes() + self.isStackedPhoto = message.category == MessageCategory.STACKED_PHOTO.rawValue thumbnail = UIImage(thumbnailString: message.thumbImage) var quoteIfExist: Quote? = nil diff --git a/Mixin/UserInterface/Controllers/Chat/Model/MessageViewModelFactory.swift b/Mixin/UserInterface/Controllers/Chat/Model/MessageViewModelFactory.swift index be86e47444..ca5845fb4a 100644 --- a/Mixin/UserInterface/Controllers/Chat/Model/MessageViewModelFactory.swift +++ b/Mixin/UserInterface/Controllers/Chat/Model/MessageViewModelFactory.swift @@ -68,10 +68,10 @@ class MessageViewModelFactory { if message.userId != myUserId { style = .received } - if isLastMessage - || messageAtIndex(index + 1).userId != message.userId - || messageAtIndex(index + 1).isExtensionMessage - || messageAtIndex(index + 1).isSystemMessage { + if message.category != MessageCategory.STACKED_PHOTO.rawValue && (isLastMessage + || messageAtIndex(index + 1).userId != message.userId + || messageAtIndex(index + 1).isExtensionMessage + || messageAtIndex(index + 1).isSystemMessage) { style.insert(.tail) } if message.category == MessageCategory.EXT_ENCRYPTION.rawValue { @@ -154,6 +154,8 @@ class MessageViewModelFactory { viewModel.cellHeight = 38 } else if message.category == MessageCategory.EXT_ENCRYPTION.rawValue { viewModel = EncryptionHintViewModel(message: message) + } else if message.category == MessageCategory.STACKED_PHOTO.rawValue { + viewModel = StackedPhotoMessageViewModel(message: message) } else if MessageCategory.krakenCategories.contains(message.category) { viewModel = SystemMessageViewModel(message: message) } else { diff --git a/Mixin/UserInterface/Controllers/Chat/Model/StackedPhotoLayout.swift b/Mixin/UserInterface/Controllers/Chat/Model/StackedPhotoLayout.swift new file mode 100644 index 0000000000..d4b30b0124 --- /dev/null +++ b/Mixin/UserInterface/Controllers/Chat/Model/StackedPhotoLayout.swift @@ -0,0 +1,59 @@ +import UIKit + +class StackedPhotoLayout: UICollectionViewLayout { + + let visibleItemCount: Int = 4 + + private let itemSize = CGSize(width: 210, height: 280) + private let itemScale: CGFloat = 0.95 + private let itemRotationDegree: CGFloat = 4 + + private var contentWidth: CGFloat = 0 + private var layoutAttributes = [UICollectionViewLayoutAttributes]() + + override func prepare() { + super.prepare() + guard let collectionView = collectionView else { + layoutAttributes = [] + return + } + let numberOfItems = collectionView.numberOfItems(inSection: 0) + layoutAttributes = (0 ..< numberOfItems).map { index in + let itemAttributes = UICollectionViewLayoutAttributes(forCellWith: IndexPath(item: index, section: 0)) + if index < visibleItemCount { + let angle = itemRotationDegree * .pi / 180 * CGFloat(index) + let scale = pow(itemScale, CGFloat(index)) + itemAttributes.transform = CGAffineTransform(scaleX: scale, y: scale).rotated(by: angle) + itemAttributes.zIndex = -index + itemAttributes.frame = CGRect(origin: .zero, size: itemSize) + if index == min(numberOfItems, visibleItemCount) - 1 { + contentWidth = ceil(itemSize.width + itemSize.height * scale * sin(angle)) + } + } else { + itemAttributes.isHidden = true + itemAttributes.frame = .zero + } + return itemAttributes + } + } + + override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { + true + } + + override var collectionViewContentSize: CGSize { + CGSize(width: contentWidth, height: itemSize.height) + } + + override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { + layoutAttributes.filter { $0.frame.intersects(rect) } + } + + override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { + guard indexPath.item < layoutAttributes.count else { + return nil + } + return layoutAttributes[indexPath.row] + } + +} diff --git a/Mixin/UserInterface/Controllers/Chat/Model/StackedPhotoMessageViewModel.swift b/Mixin/UserInterface/Controllers/Chat/Model/StackedPhotoMessageViewModel.swift new file mode 100644 index 0000000000..57a2e5e8b6 --- /dev/null +++ b/Mixin/UserInterface/Controllers/Chat/Model/StackedPhotoMessageViewModel.swift @@ -0,0 +1,35 @@ +import UIKit +import MixinServices + +class StackedPhotoMessageViewModel: ImageMessageViewModel { + + private let stackedPhotoViewHeight: CGFloat = 280 + + override class var bubbleWidth: CGFloat { + 258 + } + + private(set) var photoMessageViewModels: [PhotoMessageViewModel] = [] + private(set) var stackedPhotoViewFrame = CGRect.zero + + override init(message: MessageItem) { + super.init(message: message) + if let items = message.stackedMessageItems, !items.isEmpty { + photoMessageViewModels = items.map({ PhotoMessageViewModel(message: $0) }) + } + } + + override func layout(width: CGFloat, style: MessageViewModel.Style) { + photoFrame.size = CGSize(width: Self.bubbleWidth, height: stackedPhotoViewHeight + 3) + super.layout(width: width, style: style) + if style.contains(.received) { + photoFrame.origin.x += 8 + } + stackedPhotoViewFrame = CGRect(x: photoFrame.origin.x, + y: photoFrame.origin.y + 1, + width: photoFrame.width, + height: stackedPhotoViewHeight - 1) + layoutTrailingInfoBackgroundFrame() + } + +} diff --git a/Mixin/UserInterface/Controllers/Chat/PhotoInputViewController.swift b/Mixin/UserInterface/Controllers/Chat/PhotoInputViewController.swift index 6c10f0bee6..98f4ce3b6b 100644 --- a/Mixin/UserInterface/Controllers/Chat/PhotoInputViewController.swift +++ b/Mixin/UserInterface/Controllers/Chat/PhotoInputViewController.swift @@ -322,10 +322,10 @@ extension PhotoInputViewController { guard let controller = conversationInputViewController else { return } - assets.forEach(controller.send(asset:)) selectedAssets.removeAll() gridViewController.updateVisibleCellBadge() dismissSelectedPhotoInputItemsViewControllerAnimated() + assets.forEach(controller.send(asset:)) } private func sendAsFiles(assets: [PHAsset]) { @@ -339,10 +339,10 @@ extension PhotoInputViewController { guard let self = self else { return } - urls.forEach(controller.sendFile(url:)) self.selectedAssets.removeAll() self.gridViewController.updateVisibleCellBadge() self.dismissSelectedPhotoInputItemsViewControllerAnimated() + urls.forEach(controller.sendFile(url:)) } } diff --git a/Mixin/UserInterface/Controllers/Chat/PinMessagesPreviewViewController.swift b/Mixin/UserInterface/Controllers/Chat/PinMessagesPreviewViewController.swift index 44b70466e3..a6b4081c97 100644 --- a/Mixin/UserInterface/Controllers/Chat/PinMessagesPreviewViewController.swift +++ b/Mixin/UserInterface/Controllers/Chat/PinMessagesPreviewViewController.swift @@ -18,12 +18,6 @@ final class PinMessagesPreviewViewController: StaticMessagesViewController { private var ignoresPinMessageChangeNotification = false private var canUnpinMessages = false - private var layoutWidth: CGFloat { - Queue.main.autoSync { - AppDelegate.current.mainWindow.bounds.width - } - } - private weak var bottomBarViewIfAdded: UIView? init(conversationId: String, isGroup: Bool) { diff --git a/Mixin/UserInterface/Controllers/Chat/StackedPhotoPreviewViewController.swift b/Mixin/UserInterface/Controllers/Chat/StackedPhotoPreviewViewController.swift new file mode 100644 index 0000000000..a41a9e7dee --- /dev/null +++ b/Mixin/UserInterface/Controllers/Chat/StackedPhotoPreviewViewController.swift @@ -0,0 +1,347 @@ +import UIKit +import MixinServices + +class StackedPhotoPreviewViewController: StaticMessagesViewController { + + private let stackedPhotoMessage: MessageItem + + private var photoMessageItems: [MessageItem] = [] + private var selectedViewModels = [String: MessageViewModel]() + private var pinnedMessageIds = Set() + + private lazy var multipleSelectionView: MultipleSelectionActionView = { + let view = R.nib.multipleSelectionActionView(owner: self)! + view.delegate = self + view.showCancelButton = true + return view + }() + + init(conversationId: String, stackedPhotoMessage: MessageItem) { + self.stackedPhotoMessage = stackedPhotoMessage + super.init(conversationId: conversationId, audioManager: StaticAudioMessagePlayingManager()) + } + + required init?(coder: NSCoder) { + fatalError("Storyboard/Xib not supported") + } + + override func viewDidLoad() { + super.viewDidLoad() + if let messages = stackedPhotoMessage.stackedMessageItems { + photoMessageItems = messages + titleLabel.text = R.string.localizable.photo_count(messages.count) + } + factory.delegate = self + reloadData() + let center = NotificationCenter.default + center.addObserver(self, selector: #selector(pinMessageDidSave(_:)), name: PinMessageDAO.didSaveNotification, object: nil) + center.addObserver(self, selector: #selector(pinMessageDidDelete(_:)), name: PinMessageDAO.didDeleteNotification, object: nil) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + guard traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory else { + return + } + reloadData() + } + + override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + if let cell = cell as? MessageCell { + CATransaction.performWithoutAnimation { + cell.setMultipleSelecting(tableView.allowsMultipleSelection, animated: false) + cell.layoutIfNeeded() + } + } + super.tableView(tableView, willDisplay: cell, forRowAt: indexPath) + } + + override func contextMenuActions(for viewModel: MessageViewModel) -> [UIAction]? { + let pinningAction: MessageAction = pinnedMessageIds.contains(viewModel.message.messageId) ? .unpin : .pin + let messageActions: [MessageAction] = [.addToStickers, pinningAction, .forward, .delete] + let menuActions = messageActions.map { action -> UIAction in + UIAction(title: action.title, image: action.image) { _ in + if action == .delete || action == .forward { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.perforAction(action, for: viewModel) + } + } else { + self.perforAction(action, for: viewModel) + } + } + } + return menuActions + } + + override func tapAction(_ recognizer: UIGestureRecognizer) { + let tappedIndexPath = tableView.indexPathForRow(at: recognizer.location(in: tableView)) + let tappedViewModel: MessageViewModel? = { + if let indexPath = tappedIndexPath { + return viewModel(at: indexPath) + } else { + return nil + } + }() + if tableView.allowsMultipleSelection { + if let indexPath = tappedIndexPath, let viewModel = tappedViewModel { + if let indexPaths = tableView.indexPathsForSelectedRows, indexPaths.contains(indexPath) { + tableView.deselectRow(at: indexPath, animated: true) + selectedViewModels[viewModel.message.messageId] = nil + } else { + tableView.selectRow(at: indexPath, animated: true, scrollPosition: .none) + selectedViewModels[viewModel.message.messageId] = viewModel + } + } + multipleSelectionView.numberOfSelection = selectedViewModels.count + } else { + super.tapAction(recognizer) + } + } + + override func dismissAction(_ sender: Any) { + endMultipleSelection() + super.dismissAction(sender) + } + +} + +extension StackedPhotoPreviewViewController: MultipleSelectionActionViewDelegate { + + func multipleSelectionActionViewDidTapIntent(_ view: MultipleSelectionActionView) { + switch multipleSelectionView.intent { + case .forward: + let messages = selectedViewModels.values + .map({ $0.message }) + .sorted(by: { $0.createdAt < $1.createdAt }) + if messages.count == 1 { + let vc = MessageReceiverViewController.instance(content: .messages(messages)) + navigationController?.pushViewController(vc, animated: true) + } else { + let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + alert.addAction(UIAlertAction(title: R.string.localizable.one_by_one_forward(), style: .default, handler: { (_) in + let vc = MessageReceiverViewController.instance(content: .messages(messages)) + self.navigationController?.pushViewController(vc, animated: true) + })) + alert.addAction(UIAlertAction(title: R.string.localizable.combine_and_forward(), style: .default, handler: { (_) in + let vc = MessageReceiverViewController.instance(content: .transcript(messages)) + self.navigationController?.pushViewController(vc, animated: true) + })) + alert.addAction(UIAlertAction(title: R.string.localizable.cancel(), style: .cancel, handler: nil)) + present(alert, animated: true, completion: nil) + } + case .delete: + let viewModels = selectedViewModels.values.map({ $0 }) + let controller = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + if !viewModels.contains(where: { $0.message.userId != myUserId || !$0.message.canRecall }) { + controller.addAction(UIAlertAction(title: R.string.localizable.delete_for_everyone(), style: .destructive, handler: { (_) in + if AppGroupUserDefaults.User.hasShownRecallTips { + self.deleteForEveryone(viewModels: viewModels) + } else { + self.showRecallTips(viewModels: viewModels) + } + })) + } + controller.addAction(UIAlertAction(title: R.string.localizable.delete_for_me(), style: .destructive, handler: { (_) in + self.deleteForMe(viewModels: viewModels) + })) + controller.addAction(UIAlertAction(title: R.string.localizable.cancel(), style: .cancel, handler: nil)) + present(controller, animated: true, completion: nil) + } + } + + func multipleSelectionActionViewDidTapCancel(_ view: MultipleSelectionActionView) { + endMultipleSelection() + } + +} + +// MARK: - MessageViewModelFactoryDelegate +extension StackedPhotoPreviewViewController: MessageViewModelFactoryDelegate { + + func messageViewModelFactory(_ factory: MessageViewModelFactory, showUsernameForMessageIfNeeded message: MessageItem) -> Bool { + message.userId != myUserId + } + + func messageViewModelFactory(_ factory: MessageViewModelFactory, isMessageForwardedByBot message: MessageItem) -> Bool { + false + } + + func messageViewModelFactory(_ factory: MessageViewModelFactory, updateViewModelForPresentation viewModel: MessageViewModel) { + + } + +} + +extension StackedPhotoPreviewViewController { + + private func deleteForMe(viewModels: [MessageViewModel]) { + for viewModel in viewModels { + queue.async { [weak self] in + let message = viewModel.message + guard let self = self, let indexPath = self.indexPath(where: { $0.messageId == message.messageId }) else { + return + } + let (deleted, childMessageIds) = MessageDAO.shared.deleteMessage(id: message.messageId) + if deleted { + ReceiveMessageService.shared.stopRecallMessage(item: message, childMessageIds: childMessageIds) + } + DispatchQueue.main.sync { + _ = self.removeViewModel(at: indexPath) + self.tableView.reloadData() + self.tableView.setFloatingHeaderViewsHidden(true, animated: true) + } + } + } + endMultipleSelection() + } + + private func deleteForEveryone(viewModels: [MessageViewModel]) { + DispatchQueue.global().async { + for viewModel in viewModels { + SendMessageService.shared.recallMessage(item: viewModel.message) + } + } + endMultipleSelection() + } + + private func showRecallTips(viewModels: [MessageViewModel]) { + let alc = UIAlertController(title: R.string.localizable.chat_recall_delete_alert(), message: "", preferredStyle: .alert) + alc.addAction(UIAlertAction(title: R.string.localizable.learn_more(), style: .default, handler: { (_) in + AppGroupUserDefaults.User.hasShownRecallTips = true + UIApplication.shared.openURL(url: "https://mixinmessenger.zendesk.com/hc/articles/360028209571") + })) + alc.addAction(UIAlertAction(title: R.string.localizable.ok(), style: .default, handler: { (_) in + AppGroupUserDefaults.User.hasShownRecallTips = true + self.deleteForEveryone(viewModels: viewModels) + })) + present(alc, animated: true, completion: nil) + } + + private func reloadData() { + let messages = self.photoMessageItems + queue.async { + let pinnedMessageIds = Set(PinMessageDAO.shared.messageItems(conversationId: self.conversationId).map(\.messageId)) + let (dates, viewModels) = self.categorizedViewModels(with: messages, fits: self.layoutWidth) + DispatchQueue.main.async { + self.dates = dates + self.viewModels = viewModels + self.pinnedMessageIds = pinnedMessageIds + self.tableView.reloadData() + } + } + } + + private func perforAction(_ action: MessageAction, for viewModel: MessageViewModel) { + let message = viewModel.message + switch action { + case .forward: + beginMultipleSelection(for: viewModel, intent: .forward) + case .delete: + beginMultipleSelection(for: viewModel, intent: .delete) + case .addToStickers: + let vc = StickerAddViewController.instance(source: .message(message)) + navigationController?.pushViewController(vc, animated: true) + case .pin: + SendMessageService.shared.sendPinMessages(items: [message], conversationId: conversationId, action: .pin) + case .unpin: + SendMessageService.shared.sendPinMessages(items: [message], conversationId: conversationId, action: .unpin) + default: + break + } + } + + private func beginMultipleSelection(for viewModel: MessageViewModel, intent: MultipleSelectionIntent) { + guard let indexPath = indexPath(where: { $0.messageId == viewModel.message.messageId }) else { + return + } + tableView.allowsMultipleSelection = true + for cell in tableView.visibleCells { + guard let cell = cell as? MessageCell else { + continue + } + cell.setMultipleSelecting(true, animated: true) + } + multipleSelectionView.intent = intent + multipleSelectionView.frame = CGRect(x: 0, y: view.bounds.height, width: view.bounds.width, height: multipleSelectionView.preferredHeight) + multipleSelectionView.autoresizingMask = [.flexibleWidth] + view.addSubview(multipleSelectionView) + UIView.animateKeyframes(withDuration: 0.4, delay: 0, options: [], animations: { + UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) { + self.view.layoutIfNeeded() + } + UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) { + let y = self.view.bounds.height - self.multipleSelectionView.preferredHeight + self.multipleSelectionView.frame.origin.y = y + } + UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 1) { + let height = self.multipleSelectionView.preferredHeight + self.updateTableViewBottomInsetWithBottomBarHeight(height, animated: false) + } + }, completion: nil) + DispatchQueue.main.async { + self.multipleSelectionView.numberOfSelection = 1 + self.tableView.selectRow(at: indexPath, animated: true, scrollPosition: .none) + self.selectedViewModels[viewModel.message.messageId] = viewModel + } + } + + @objc private func endMultipleSelection() { + selectedViewModels.removeAll() + for cell in tableView.visibleCells.compactMap({ $0 as? MessageCell }) { + cell.setMultipleSelecting(false, animated: true) + } + tableView.indexPathsForSelectedRows?.forEach({ (indexPath) in + tableView.deselectRow(at: indexPath, animated: true) + }) + tableView.allowsMultipleSelection = false + UIView.animateKeyframes(withDuration: 0.4, delay: 0, options: [], animations: { + UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 1) { + self.updateTableViewBottomInsetWithBottomBarHeight(0, animated: false) + } + UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) { + self.multipleSelectionView.frame.origin.y = self.view.bounds.height + } + UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) { + self.view.layoutIfNeeded() + } + }, completion: { _ in + self.multipleSelectionView.removeFromSuperview() + }) + } + + private func updateTableViewBottomInsetWithBottomBarHeight(_ height: CGFloat, animated: Bool) { + func layout() { + tableView.contentInset.bottom = height + MessageViewModel.bottomSeparatorHeight + if view.window != nil { + view.layoutIfNeeded() + } + } + tableView.verticalScrollIndicatorInsets.bottom = height - view.safeAreaInsets.bottom + if animated { + UIView.animate(withDuration: 0.5, delay: 0, options: .overdampedCurve, animations: layout) + } else { + UIView.performWithoutAnimation(layout) + } + } + + @objc private func pinMessageDidSave(_ notification: Notification) { + guard let conversationId = notification.userInfo?[PinMessageDAO.UserInfoKey.conversationId] as? String, conversationId == self.conversationId else { + return + } + guard let referencedMessageId = notification.userInfo?[PinMessageDAO.UserInfoKey.referencedMessageId] as? String else { + return + } + pinnedMessageIds.insert(referencedMessageId) + } + + @objc private func pinMessageDidDelete(_ notification: Notification) { + guard let conversationId = notification.userInfo?[PinMessageDAO.UserInfoKey.conversationId] as? String, conversationId == self.conversationId else { + return + } + guard let referencedMessageIds = notification.userInfo?[PinMessageDAO.UserInfoKey.referencedMessageIds] as? [String] else { + return + } + pinnedMessageIds.subtract(referencedMessageIds) + } + +} diff --git a/Mixin/UserInterface/Controllers/Chat/StaticMessagesViewController.swift b/Mixin/UserInterface/Controllers/Chat/StaticMessagesViewController.swift index 9a45868265..d708ae7470 100644 --- a/Mixin/UserInterface/Controllers/Chat/StaticMessagesViewController.swift +++ b/Mixin/UserInterface/Controllers/Chat/StaticMessagesViewController.swift @@ -18,6 +18,11 @@ class StaticMessagesViewController: UIViewController { var dates: [String] = [] var viewModels: [String: [MessageViewModel]] = [:] + var layoutWidth: CGFloat { + Queue.main.autoSync { + AppDelegate.current.mainWindow.bounds.width + } + } private let audioManager: StaticAudioMessagePlayingManager @@ -168,12 +173,41 @@ class StaticMessagesViewController: UIViewController { } } + func removeViewModel(at indexPath: IndexPath) -> (didRemoveRow: Bool, didRemoveSection: Bool) { + var didRemoveRow = false + var didRemoveSection = false + let date = dates[indexPath.section] + if let _ = viewModels[date]?.remove(at: indexPath.row) { + didRemoveRow = true + } + if let viewModels = viewModels[date], viewModels.isEmpty { + if let index = dates.firstIndex(of: date) { + didRemoveSection = true + dates.remove(at: index) + } + self.viewModels[date] = nil + } + if let viewModels = self.viewModels[date] { + let indexBeforeDeletedMessage = indexPath.row - 1 + let indexAfterDeletedMessage = indexPath.row + if indexBeforeDeletedMessage >= 0 { + let style = factory.style(forIndex: indexBeforeDeletedMessage, viewModels: viewModels) + self.viewModels[date]?[indexBeforeDeletedMessage].style = style + } + if indexAfterDeletedMessage < viewModels.count { + let style = factory.style(forIndex: indexAfterDeletedMessage, viewModels: viewModels) + self.viewModels[date]?[indexAfterDeletedMessage].style = style + } + } + return (didRemoveRow, didRemoveSection) + } + } // MARK: - Actions extension StaticMessagesViewController { - @objc private func tapAction(_ recognizer: UIGestureRecognizer) { + @objc func tapAction(_ recognizer: UIGestureRecognizer) { let tappedIndexPath = tableView.indexPathForRow(at: recognizer.location(in: tableView)) let tappedViewModel: MessageViewModel? = { if let indexPath = tappedIndexPath { @@ -543,10 +577,9 @@ extension StaticMessagesViewController { return } DispatchQueue.main.sync { - let layoutWidth = AppDelegate.current.mainWindow.bounds.width let date = DateFormatter.yyyymmdd.string(from: message.createdAt.toUTCDate()) if let style = self.viewModels[date]?[indexPath.row].style { - let viewModel = self.factory.viewModel(withMessage: message, style: style, fits: layoutWidth) + let viewModel = self.factory.viewModel(withMessage: message, style: style, fits: self.layoutWidth) self.viewModels[date]?[indexPath.row] = viewModel self.tableView.reloadData() } diff --git a/Mixin/UserInterface/Controllers/Chat/TranscriptPreviewViewController.swift b/Mixin/UserInterface/Controllers/Chat/TranscriptPreviewViewController.swift index baca947eae..442573bd24 100644 --- a/Mixin/UserInterface/Controllers/Chat/TranscriptPreviewViewController.swift +++ b/Mixin/UserInterface/Controllers/Chat/TranscriptPreviewViewController.swift @@ -154,7 +154,6 @@ extension TranscriptPreviewViewController { } private func reloadData() { - let layoutWidth = AppDelegate.current.mainWindow.bounds.width queue.async { [weak self] in guard let self = self else { return @@ -164,7 +163,7 @@ extension TranscriptPreviewViewController { let children = items.compactMap { item in TranscriptMessage(transcriptId: transcriptId, mediaUrl: item.mediaUrl, thumbImage: item.thumbImage, messageItem: item) } - let (dates, viewModels) = self.categorizedViewModels(with: items, fits: layoutWidth) + let (dates, viewModels) = self.categorizedViewModels(with: items, fits: self.layoutWidth) DispatchQueue.main.async { self.childMessages = children self.dates = dates diff --git a/Mixin/UserInterface/Controllers/Chat/Views/ConversationTableView.swift b/Mixin/UserInterface/Controllers/Chat/Views/ConversationTableView.swift index 3a172f94ae..481be2d58a 100644 --- a/Mixin/UserInterface/Controllers/Chat/Views/ConversationTableView.swift +++ b/Mixin/UserInterface/Controllers/Chat/Views/ConversationTableView.swift @@ -164,6 +164,7 @@ class ConversationTableView: UITableView { register(LocationMessageCell.self, forCellReuseIdentifier: ReuseId.location.rawValue) register(TranscriptMessageCell.self, forCellReuseIdentifier: ReuseId.transcript.rawValue) register(PinMessageCell.self, forCellReuseIdentifier: ReuseId.pin.rawValue) + register(StackedPhotoMessageCell.self, forCellReuseIdentifier: ReuseId.stackedPhoto.rawValue) } } @@ -205,6 +206,7 @@ extension ConversationTableView { case transcript = "TranscriptMessageCell" case pin = "PinMessageCell" case header = "DateHeader" + case stackedPhoto = "StackedPhotoMessageCell" init(category: String) { if category.hasSuffix("_TEXT") { @@ -243,6 +245,8 @@ extension ConversationTableView { self = .appCard } else if category == MessageCategory.MESSAGE_PIN.rawValue { self = .pin + } else if category == MessageCategory.STACKED_PHOTO.rawValue { + self = .stackedPhoto } else { self = .unknown } diff --git a/Mixin/UserInterface/Controllers/Chat/Views/MultipleSelectionActionView.swift b/Mixin/UserInterface/Controllers/Chat/Views/MultipleSelectionActionView.swift index 3612eefdd5..a2e860ca15 100644 --- a/Mixin/UserInterface/Controllers/Chat/Views/MultipleSelectionActionView.swift +++ b/Mixin/UserInterface/Controllers/Chat/Views/MultipleSelectionActionView.swift @@ -1,14 +1,41 @@ import UIKit +@objc protocol MultipleSelectionActionViewDelegate { + @objc optional func multipleSelectionActionViewDidTapCancel(_ view: MultipleSelectionActionView) + @objc optional func multipleSelectionActionViewDidTapIntent(_ view: MultipleSelectionActionView) +} + class MultipleSelectionActionView: UIView { @IBOutlet weak var label: UILabel! - @IBOutlet weak var button: UIButton! + @IBOutlet weak var intentButton: UIButton! + @IBOutlet weak var cancelButton: UIButton! + + @IBOutlet weak var hideCancelButtonConstraint: NSLayoutConstraint! + @IBOutlet weak var showCancelButtonConstraint: NSLayoutConstraint! + + weak var delegate: MultipleSelectionActionViewDelegate? var preferredHeight: CGFloat { 50 + safeAreaInsets.bottom } + var showCancelButton: Bool = true { + didSet { + if showCancelButton { + label.textAlignment = .center + cancelButton.isHidden = false + showCancelButtonConstraint.priority = .defaultHigh + hideCancelButtonConstraint.priority = .defaultLow + } else { + label.textAlignment = .left + cancelButton.isHidden = true + showCancelButtonConstraint.priority = .defaultLow + hideCancelButtonConstraint.priority = .defaultHigh + } + } + } + var intent: MultipleSelectionIntent = .forward { didSet { let image: UIImage? @@ -18,7 +45,7 @@ class MultipleSelectionActionView: UIView { case .delete: image = R.image.conversation.ic_selection_action_delete() } - button.setImage(image, for: .normal) + intentButton.setImage(image, for: .normal) updateButtonAvailability() } } @@ -35,14 +62,22 @@ class MultipleSelectionActionView: UIView { frame.size.height = preferredHeight } + @IBAction func intentAction(_ sender: Any) { + delegate?.multipleSelectionActionViewDidTapIntent?(self) + } + + @IBAction func cancelAction(_ sender: Any) { + delegate?.multipleSelectionActionViewDidTapCancel?(self) + } + private func updateButtonAvailability() { switch intent { case .forward: - button.isEnabled = numberOfSelection > 0 - && numberOfSelection <= maxNumberOfTranscriptChildren + intentButton.isEnabled = numberOfSelection > 0 && numberOfSelection <= maxNumberOfTranscriptChildren case .delete: - button.isEnabled = numberOfSelection > 0 + intentButton.isEnabled = numberOfSelection > 0 } } + } diff --git a/Mixin/UserInterface/Controllers/Chat/Views/MultipleSelectionActionView.xib b/Mixin/UserInterface/Controllers/Chat/Views/MultipleSelectionActionView.xib index ac7c5e04b3..c8a67e0a7d 100644 --- a/Mixin/UserInterface/Controllers/Chat/Views/MultipleSelectionActionView.xib +++ b/Mixin/UserInterface/Controllers/Chat/Views/MultipleSelectionActionView.xib @@ -1,9 +1,9 @@ - + - + @@ -15,8 +15,8 @@ - + + - - + + + + + - - + + + + @@ -64,5 +84,8 @@ + + + diff --git a/Mixin/UserInterface/Controllers/Chat/Views/QuotePreviewView.swift b/Mixin/UserInterface/Controllers/Chat/Views/QuotePreviewView.swift index f19fff3a79..a9f8e65aa9 100644 --- a/Mixin/UserInterface/Controllers/Chat/Views/QuotePreviewView.swift +++ b/Mixin/UserInterface/Controllers/Chat/Views/QuotePreviewView.swift @@ -48,6 +48,11 @@ class QuotePreviewView: UIView, XibDesignable { } else { imageView.contentMode = .scaleAspectFill } + } else if message.category == MessageCategory.STACKED_PHOTO.rawValue { + contentImageWrapperView.isHidden = false + avatarImageView.isHidden = true + imageView.isHidden = false + imageView.contentMode = .scaleAspectFill } else { contentImageWrapperView.isHidden = true } @@ -75,6 +80,13 @@ class QuotePreviewView: UIView, XibDesignable { avatarImageView.setImage(with: message.sharedUserAvatarUrl ?? "", userId: message.sharedUserId ?? "", name: message.sharedUserFullName ?? "") + } else if message.category == MessageCategory.STACKED_PHOTO.rawValue { + if let mediaUrl = message.stackedMessageItems?.first?.mediaUrl, !mediaUrl.isEmpty { + let url = AttachmentContainer.url(for: .photos, filename: mediaUrl) + imageView.sd_setImage(with: url, placeholderImage: contentImageThumbnail, context: localImageContext) + } else { + imageView.image = contentImageThumbnail + } } UIView.performWithoutAnimation { iconImageView.image = MessageCategory.iconImage(forMessageCategoryString: message.category) diff --git a/Mixin/UserInterface/Controllers/Chat/Views/StackedPhotoCell.swift b/Mixin/UserInterface/Controllers/Chat/Views/StackedPhotoCell.swift new file mode 100644 index 0000000000..21f15064d7 --- /dev/null +++ b/Mixin/UserInterface/Controllers/Chat/Views/StackedPhotoCell.swift @@ -0,0 +1,53 @@ +import UIKit +import SDWebImage +import MixinServices + +class StackedPhotoCell: UICollectionViewCell { + + static let reuseIdentifier = "cell_identifier_stacked_photo_cell" + + var viewModel: PhotoMessageViewModel! { + didSet { + if let url = viewModel.attachmentURL { + imageView.sd_setImage(with: url, + placeholderImage: viewModel.thumbnail, + context: localImageContext) + } else { + imageView.image = viewModel.thumbnail + } + layer.anchorPoint = CGPoint(x: 1, y: 1) + let centerX = 0.5 * bounds.width + center.x + let centerY = 0.5 * bounds.height + center.y + center = CGPoint(x: centerX, y: centerY) + } + } + + private var imageView = SDAnimatedImageView() + + override init(frame: CGRect) { + super.init(frame: frame) + loadSubview() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + loadSubview() + } + + override func prepareForReuse() { + super.prepareForReuse() + imageView.sd_cancelCurrentImageLoad() + } + + private func loadSubview() { + layer.cornerRadius = 13 + layer.masksToBounds = true + layer.shouldRasterize = true + layer.rasterizationScale = UIScreen.main.scale + layer.allowsEdgeAntialiasing = true + contentView.addSubview(imageView) + imageView.snp.makeEdgesEqualToSuperview() + imageView.contentMode = .scaleAspectFill + } + +} diff --git a/Mixin/UserInterface/Controllers/Chat/Views/StackedPhotoView.swift b/Mixin/UserInterface/Controllers/Chat/Views/StackedPhotoView.swift new file mode 100644 index 0000000000..f989d06284 --- /dev/null +++ b/Mixin/UserInterface/Controllers/Chat/Views/StackedPhotoView.swift @@ -0,0 +1,47 @@ +import UIKit + +class StackedPhotoView: UIView { + + var viewModels = [PhotoMessageViewModel]() { + didSet { + collectionView.reloadData() + } + } + + private let layout = StackedPhotoLayout() + + private var collectionView: UICollectionView! + + override init(frame: CGRect) { + super.init(frame: frame) + clipsToBounds = true + collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = .clear + collectionView.isScrollEnabled = false + collectionView.showsVerticalScrollIndicator = false + collectionView.showsHorizontalScrollIndicator = false + collectionView.dataSource = self + collectionView.register(StackedPhotoCell.self, forCellWithReuseIdentifier: StackedPhotoCell.reuseIdentifier) + addSubview(collectionView) + collectionView.snp.makeEdgesEqualToSuperview() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +extension StackedPhotoView: UICollectionViewDataSource { + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + layout.visibleItemCount + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: StackedPhotoCell.reuseIdentifier, for: indexPath) as! StackedPhotoCell + cell.viewModel = viewModels[indexPath.row] + return cell + } + +} diff --git a/MixinServices/MixinServices/Database/User/DAO/MessageDAO.swift b/MixinServices/MixinServices/Database/User/DAO/MessageDAO.swift index fabe62a089..7db2580e83 100644 --- a/MixinServices/MixinServices/Database/User/DAO/MessageDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/MessageDAO.swift @@ -15,6 +15,7 @@ public final class MessageDAO: UserDatabaseDAO { public static let shared = MessageDAO() public static let willDeleteMessageNotification = Notification.Name("one.mixin.services.MessageDAO.willDeleteMessage") + public static let didDeleteMessageNotification = Notification.Name("one.mixin.services.MessageDAO.didDeleteMessage") public static let didInsertMessageNotification = Notification.Name("one.mixin.services.did.insert.msg") public static let didRedecryptMessageNotification = Notification.Name("one.mixin.services.did.redecrypt.msg") public static let messageMediaStatusDidUpdateNotification = Notification.Name("one.mixin.services.MessageDAO.MessageMediaStatusDidUpdate") @@ -815,6 +816,11 @@ public final class MessageDAO: UserDatabaseDAO { try PinMessageDAO.shared.delete(messageIds: [id], conversationId: conversationId, from: database) try clearPinMessageContent(quoteMessageIds: [id], conversationId: conversationId, from: database) } + if deleteCount > 0 { + NotificationCenter.default.post(onMainThread: Self.didDeleteMessageNotification, + object: self, + userInfo: [UserInfoKey.messageId: id]) + } return (deleteCount > 0, childMessageIds) } diff --git a/MixinServices/MixinServices/Database/User/Model/Message.swift b/MixinServices/MixinServices/Database/User/Model/Message.swift index 0eebb00bfb..d3ab508c8d 100644 --- a/MixinServices/MixinServices/Database/User/Model/Message.swift +++ b/MixinServices/MixinServices/Database/User/Model/Message.swift @@ -313,6 +313,7 @@ public enum MessageCategory: String, Decodable { case ENCRYPTED_TRANSCRIPT case EXT_UNREAD case EXT_ENCRYPTION + case STACKED_PHOTO case UNKNOWN public static func isLegal(category: String) -> Bool { diff --git a/MixinServices/MixinServices/Database/User/Model/MessageItem.swift b/MixinServices/MixinServices/Database/User/Model/MessageItem.swift index 63058d5d58..4e09950406 100644 --- a/MixinServices/MixinServices/Database/User/Model/MessageItem.swift +++ b/MixinServices/MixinServices/Database/User/Model/MessageItem.swift @@ -73,6 +73,8 @@ public final class MessageItem { public var expireIn: Int64? + public var stackedMessageItems: [MessageItem]? + public lazy var appButtons: [AppButtonData]? = { guard category == MessageCategory.APP_BUTTON_GROUP.rawValue, let content = content, let data = Data(base64Encoded: content) else { return nil @@ -125,7 +127,7 @@ public final class MessageItem { expireIn != nil } - public init(messageId: String, conversationId: String, userId: String, category: String, content: String? = nil, mediaUrl: String? = nil, mediaMimeType: String? = nil, mediaSize: Int64? = nil, mediaDuration: Int64? = nil, mediaWidth: Int? = nil, mediaHeight: Int? = nil, mediaHash: String? = nil, mediaKey: Data? = nil, mediaDigest: Data? = nil, mediaStatus: String? = nil, mediaWaveform: Data? = nil, mediaLocalIdentifier: String? = nil, thumbImage: String? = nil, thumbUrl: String? = nil, status: String, participantId: String? = nil, snapshotId: String? = nil, name: String? = nil, stickerId: String? = nil, createdAt: String, actionName: String? = nil, userFullName: String? = nil, userIdentityNumber: String? = nil, userAvatarUrl: String? = nil, appId: String? = nil, snapshotAmount: String? = nil, snapshotAssetId: String? = nil, snapshotType: String? = nil, participantFullName: String? = nil, participantUserId: String? = nil, assetUrl: String? = nil, assetType: String? = nil, assetSymbol: String? = nil, assetIcon: String? = nil, assetWidth: Int? = nil, assetHeight: Int? = nil, assetCategory: String? = nil, sharedUserId: String? = nil, sharedUserFullName: String? = nil, sharedUserIdentityNumber: String? = nil, sharedUserAvatarUrl: String? = nil, sharedUserAppId: String? = nil, sharedUserIsVerified: Bool? = nil, quoteMessageId: String? = nil, quoteContent: Data? = nil, mentionsJson: Data? = nil, hasMentionRead: Bool? = nil, expireIn: Int64? = nil) { + public init(messageId: String, conversationId: String, userId: String, category: String, content: String? = nil, mediaUrl: String? = nil, mediaMimeType: String? = nil, mediaSize: Int64? = nil, mediaDuration: Int64? = nil, mediaWidth: Int? = nil, mediaHeight: Int? = nil, mediaHash: String? = nil, mediaKey: Data? = nil, mediaDigest: Data? = nil, mediaStatus: String? = nil, mediaWaveform: Data? = nil, mediaLocalIdentifier: String? = nil, thumbImage: String? = nil, thumbUrl: String? = nil, status: String, participantId: String? = nil, snapshotId: String? = nil, name: String? = nil, stickerId: String? = nil, createdAt: String, actionName: String? = nil, userFullName: String? = nil, userIdentityNumber: String? = nil, userAvatarUrl: String? = nil, appId: String? = nil, snapshotAmount: String? = nil, snapshotAssetId: String? = nil, snapshotType: String? = nil, participantFullName: String? = nil, participantUserId: String? = nil, assetUrl: String? = nil, assetType: String? = nil, assetSymbol: String? = nil, assetIcon: String? = nil, assetWidth: Int? = nil, assetHeight: Int? = nil, assetCategory: String? = nil, sharedUserId: String? = nil, sharedUserFullName: String? = nil, sharedUserIdentityNumber: String? = nil, sharedUserAvatarUrl: String? = nil, sharedUserAppId: String? = nil, sharedUserIsVerified: Bool? = nil, quoteMessageId: String? = nil, quoteContent: Data? = nil, mentionsJson: Data? = nil, hasMentionRead: Bool? = nil, expireIn: Int64? = nil, messageItems: [MessageItem]? = nil) { self.messageId = messageId self.conversationId = conversationId self.userId = userId @@ -179,6 +181,7 @@ public final class MessageItem { self.mentionsJson = mentionsJson self.hasMentionRead = hasMentionRead self.expireIn = expireIn + self.stackedMessageItems = messageItems } public convenience init(category: String, conversationId: String, createdAt: String) { @@ -265,6 +268,8 @@ extension MessageItem: Codable, MixinFetchableRecord { case albumId = "album_id" case expireIn = "expire_in" + + case stackedMessageItems = "stacked_message_items" } } From 4f52cf751d0082a1cf45031b894dc5febfcf44d0 Mon Sep 17 00:00:00 2001 From: fanyu Date: Tue, 28 Feb 2023 11:13:30 +0800 Subject: [PATCH 6/6] Refactor stack implementation --- .../Chat/Model/ConversationDataSource.swift | 104 ++++++++---------- 1 file changed, 46 insertions(+), 58 deletions(-) diff --git a/Mixin/UserInterface/Controllers/Chat/Model/ConversationDataSource.swift b/Mixin/UserInterface/Controllers/Chat/Model/ConversationDataSource.swift index c53e95a31b..0ab316c57a 100644 --- a/Mixin/UserInterface/Controllers/Chat/Model/ConversationDataSource.swift +++ b/Mixin/UserInterface/Controllers/Chat/Model/ConversationDataSource.swift @@ -1108,80 +1108,68 @@ extension ConversationDataSource { stackedPhotoMessages.append(item) return item } + func canStack(_ message: MessageItem) -> Bool { + message.category.hasSuffix("_IMAGE") && message.quoteMessageId.isNilOrEmpty && message.mediaStatus == MediaStatus.DONE.rawValue + } var result = [MessageItem]() var messagesToStack = [MessageItem]() - var left = 0 - var right = 1 - var unableToStack: Bool { - messagesToStack.count < numberOfConsecutiveImagesToStack || - messagesToStack.contains(where: { $0.mediaStatus != MediaStatus.DONE.rawValue }) - } - while right < messages.count { - if !messages[left].category.hasSuffix("_IMAGE") { - result.append(messages[left]) - left += 1 - right += 1 - } else if !messages[left].quoteMessageId.isNilOrEmpty { - result.append(messages[left]) - left += 1 - right += 1 - } else if !messages[right].category.hasSuffix("_IMAGE") { - if messagesToStack.isEmpty { - result.append(contentsOf: messages[left...right]) - } else if unableToStack { - result.append(contentsOf: messagesToStack) - result.append(messages[right]) - messagesToStack.removeAll() - } else { - result.append(createStackedPhotoMessage(messagesToStack)) - result.append(messages[right]) - messagesToStack.removeAll() + var startIndex = 0 + var endIndex = 1 + while endIndex < messages.count { + let startMessage = messages[startIndex] + let endMessage = messages[endIndex] + if canStack(startMessage), canStack(endMessage), startMessage.userId == endMessage.userId { + if startIndex == 0, messagesToStack.isEmpty { + messagesToStack.append(startMessage) } - left = right + 1 - right = left + 1 - } else if messages[left].userId != messages[right].userId { + messagesToStack.append(endMessage) + endIndex += 1 + if endIndex >= messages.count { + if messagesToStack.count < numberOfConsecutiveImagesToStack { + result.append(contentsOf: messagesToStack) + } else { + result.append(createStackedPhotoMessage(messagesToStack)) + } + } + } else { if messagesToStack.isEmpty { - result.append(messages[left]) - } else if unableToStack { + result.append(contentsOf: messages[startIndex..= messages.count { + if messagesToStack.count < numberOfConsecutiveImagesToStack { + result.append(contentsOf: messagesToStack) + } else { + result.append(createStackedPhotoMessage(messagesToStack)) + } + } } else { - result.append(createStackedPhotoMessage(messagesToStack)) - messagesToStack.removeAll() + result.append(endMessage) + startIndex = endIndex + 1 + endIndex = startIndex + 1 + if startIndex < messages.count { + let message = messages[startIndex] + if startIndex == messages.count - 1 { + result.append(message) + } else if canStack(message) { + messagesToStack.append(message) + } + } } - result.append(messages[right]) - left = right + 1 - right += 1 - } else { - messagesToStack = Array(messages[left...right]) - right += 1 - } - } - if left == messages.count - 1 { - result.append(messages[left]) - } - if !messagesToStack.isEmpty { - if unableToStack { - result.append(contentsOf: messagesToStack) - } else { - result.append(createStackedPhotoMessage(messagesToStack)) } - messagesToStack.removeAll() } return result } + } // MARK: - Embedded class