mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
Add the gallery timeline item rendered as a placeholder
Introduces the GalleryRoomTimelineItem and its typed GalleryItem model (reusing the existing per-message content types), builds it in the RoomTimelineItemFactory and routes it through RoomTimelineItemView. For now it renders as a placeholder; the grid/list media views and the full-screen preview land in follow-up PRs. Co-authored-by: svetoslav.mitov <svetoslav.mitov@sportinno.com>
This commit is contained in:
@@ -197,6 +197,7 @@
|
|||||||
1C8BC70A18060677E295A846 /* ShareToMapsAppActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4481799F455B3DA243BDA2AC /* ShareToMapsAppActivity.swift */; };
|
1C8BC70A18060677E295A846 /* ShareToMapsAppActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4481799F455B3DA243BDA2AC /* ShareToMapsAppActivity.swift */; };
|
||||||
1C9BB74711E5F24C77B7FED0 /* RoomMembersListScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA0B743847CFA5B3C38EE4 /* RoomMembersListScreenCoordinator.swift */; };
|
1C9BB74711E5F24C77B7FED0 /* RoomMembersListScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA0B743847CFA5B3C38EE4 /* RoomMembersListScreenCoordinator.swift */; };
|
||||||
1CA094038D4D036A6F0A1314 /* VoiceMessageTrashButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF84AA68B2B7584D9275769 /* VoiceMessageTrashButton.swift */; };
|
1CA094038D4D036A6F0A1314 /* VoiceMessageTrashButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF84AA68B2B7584D9275769 /* VoiceMessageTrashButton.swift */; };
|
||||||
|
1CA6E1843E1B3680544EB584 /* GalleryRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A5AB4E2E5CA98F99BFC552 /* GalleryRoomTimelineItemContent.swift */; };
|
||||||
1D2D713A5A269072D103AE37 /* CLLocationManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA46D6DD4B4AB17E1D45092E /* CLLocationManagerProtocol.swift */; };
|
1D2D713A5A269072D103AE37 /* CLLocationManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA46D6DD4B4AB17E1D45092E /* CLLocationManagerProtocol.swift */; };
|
||||||
1D5DC685CED904386C89B7DA /* NSRegularExpresion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95BAC0F6C9644336E9567EE6 /* NSRegularExpresion.swift */; };
|
1D5DC685CED904386C89B7DA /* NSRegularExpresion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95BAC0F6C9644336E9567EE6 /* NSRegularExpresion.swift */; };
|
||||||
1D623953F970D11F6F38499C /* AppLockService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 851B95BB98649B8E773D6790 /* AppLockService.swift */; };
|
1D623953F970D11F6F38499C /* AppLockService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 851B95BB98649B8E773D6790 /* AppLockService.swift */; };
|
||||||
@@ -307,6 +308,7 @@
|
|||||||
3042527CB344A9EF1157FC26 /* AudioRecorderStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C55CC239AE12339C565F6C9A /* AudioRecorderStateTests.swift */; };
|
3042527CB344A9EF1157FC26 /* AudioRecorderStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C55CC239AE12339C565F6C9A /* AudioRecorderStateTests.swift */; };
|
||||||
308BD9343B95657FAA583FB7 /* GZIP in Frameworks */ = {isa = PBXBuildFile; productRef = 2B788C81F6369D164ADEB917 /* GZIP */; };
|
308BD9343B95657FAA583FB7 /* GZIP in Frameworks */ = {isa = PBXBuildFile; productRef = 2B788C81F6369D164ADEB917 /* GZIP */; };
|
||||||
30CC1DB7CE357659C82AA115 /* MediaProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EB16E7FE59A947CA441531 /* MediaProviderProtocol.swift */; };
|
30CC1DB7CE357659C82AA115 /* MediaProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EB16E7FE59A947CA441531 /* MediaProviderProtocol.swift */; };
|
||||||
|
30DA1A668482E5B95E8F1F2B /* GalleryRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A04EFA0E3139C01E1CFDC93B /* GalleryRoomTimelineView.swift */; };
|
||||||
30E5628F74AD3C27A061BF25 /* QRCodeLoginScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC1B7CB061C9865B2B91B56 /* QRCodeLoginScreenViewModel.swift */; };
|
30E5628F74AD3C27A061BF25 /* QRCodeLoginScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC1B7CB061C9865B2B91B56 /* QRCodeLoginScreenViewModel.swift */; };
|
||||||
311868F5E65BA61AFA6CCC2C /* CompoundHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B89D6C760E8CAE29CA28FB1 /* CompoundHook.swift */; };
|
311868F5E65BA61AFA6CCC2C /* CompoundHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B89D6C760E8CAE29CA28FB1 /* CompoundHook.swift */; };
|
||||||
3118D9ABFD4BE5A3492FF88A /* ElementCallConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC437C491EA6996513B1CEAB /* ElementCallConfiguration.swift */; };
|
3118D9ABFD4BE5A3492FF88A /* ElementCallConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC437C491EA6996513B1CEAB /* ElementCallConfiguration.swift */; };
|
||||||
@@ -1362,6 +1364,7 @@
|
|||||||
DFF7D6A6C26DDD40D00AE579 /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = F012CB5EE3F2B67359F6CC52 /* target.yml */; };
|
DFF7D6A6C26DDD40D00AE579 /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = F012CB5EE3F2B67359F6CC52 /* target.yml */; };
|
||||||
E010DDE938032D3B8E84CC35 /* TracingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A95C9B8299A36A6495DECA6 /* TracingHook.swift */; };
|
E010DDE938032D3B8E84CC35 /* TracingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A95C9B8299A36A6495DECA6 /* TracingHook.swift */; };
|
||||||
E02DAD9FD8D62587049FFFEC /* LinkNewDeviceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80F04B12FA231E797B7151A8 /* LinkNewDeviceTests.swift */; };
|
E02DAD9FD8D62587049FFFEC /* LinkNewDeviceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80F04B12FA231E797B7151A8 /* LinkNewDeviceTests.swift */; };
|
||||||
|
E08DFC18E97623B5C91C5025 /* GalleryRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E994322F10D20ECE68683683 /* GalleryRoomTimelineItem.swift */; };
|
||||||
E0B6A569AC3E81D233B43D60 /* SettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E625B0EB2F86B37C14EF7E6 /* SettingsScreenViewModel.swift */; };
|
E0B6A569AC3E81D233B43D60 /* SettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E625B0EB2F86B37C14EF7E6 /* SettingsScreenViewModel.swift */; };
|
||||||
E0C167D41A48EDB30B447DE3 /* VoiceMessageRecordingComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73A5C3F7C9C1DA10CAEC6A98 /* VoiceMessageRecordingComposer.swift */; };
|
E0C167D41A48EDB30B447DE3 /* VoiceMessageRecordingComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73A5C3F7C9C1DA10CAEC6A98 /* VoiceMessageRecordingComposer.swift */; };
|
||||||
E1428612B08ED3030EC1FEC3 /* SpacesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 104B7747499487538483FEAF /* SpacesScreen.swift */; };
|
E1428612B08ED3030EC1FEC3 /* SpacesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 104B7747499487538483FEAF /* SpacesScreen.swift */; };
|
||||||
@@ -1980,6 +1983,7 @@
|
|||||||
3558A15CFB934F9229301527 /* RestorationToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorationToken.swift; sourceTree = "<group>"; };
|
3558A15CFB934F9229301527 /* RestorationToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorationToken.swift; sourceTree = "<group>"; };
|
||||||
358528B29FA72ACFD0D9644B /* SpacesScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpacesScreenCoordinator.swift; sourceTree = "<group>"; };
|
358528B29FA72ACFD0D9644B /* SpacesScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpacesScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||||
35A057BA9BE0F079784CD061 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
35A057BA9BE0F079784CD061 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
35A5AB4E2E5CA98F99BFC552 /* GalleryRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryRoomTimelineItemContent.swift; sourceTree = "<group>"; };
|
||||||
35AFCF4C05DEED04E3DB1A16 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
35AFCF4C05DEED04E3DB1A16 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
36DA824791172B9821EACBED /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
36DA824791172B9821EACBED /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
36FD673E24FBFCFDF398716A /* RoomMemberProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberProxyMock.swift; sourceTree = "<group>"; };
|
36FD673E24FBFCFDF398716A /* RoomMemberProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberProxyMock.swift; sourceTree = "<group>"; };
|
||||||
@@ -2579,6 +2583,7 @@
|
|||||||
A010B8EAD1A9F6B4686DF2F4 /* BlockedUsersScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedUsersScreenViewModel.swift; sourceTree = "<group>"; };
|
A010B8EAD1A9F6B4686DF2F4 /* BlockedUsersScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedUsersScreenViewModel.swift; sourceTree = "<group>"; };
|
||||||
A019A12C866D64CF072024B9 /* AppLockSetupPINScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupPINScreenViewModel.swift; sourceTree = "<group>"; };
|
A019A12C866D64CF072024B9 /* AppLockSetupPINScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupPINScreenViewModel.swift; sourceTree = "<group>"; };
|
||||||
A02D1A490944BF01A37586E1 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/SAS.strings; sourceTree = "<group>"; };
|
A02D1A490944BF01A37586E1 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/SAS.strings; sourceTree = "<group>"; };
|
||||||
|
A04EFA0E3139C01E1CFDC93B /* GalleryRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryRoomTimelineView.swift; sourceTree = "<group>"; };
|
||||||
A057F2FDC14866C3026A89A4 /* NotificationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManagerProtocol.swift; sourceTree = "<group>"; };
|
A057F2FDC14866C3026A89A4 /* NotificationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManagerProtocol.swift; sourceTree = "<group>"; };
|
||||||
A07B011547B201A836C03052 /* EncryptionResetFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetFlowCoordinator.swift; sourceTree = "<group>"; };
|
A07B011547B201A836C03052 /* EncryptionResetFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetFlowCoordinator.swift; sourceTree = "<group>"; };
|
||||||
A0E7B059E84E7E374D3322A2 /* SpaceRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceRoomCell.swift; sourceTree = "<group>"; };
|
A0E7B059E84E7E374D3322A2 /* SpaceRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceRoomCell.swift; sourceTree = "<group>"; };
|
||||||
@@ -3000,6 +3005,7 @@
|
|||||||
E8AE4B3273BA189FDCD4055C /* UserIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicator.swift; sourceTree = "<group>"; };
|
E8AE4B3273BA189FDCD4055C /* UserIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicator.swift; sourceTree = "<group>"; };
|
||||||
E944F717FC10A428D027074D /* RoomPowerLevelsProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPowerLevelsProxyMock.swift; sourceTree = "<group>"; };
|
E944F717FC10A428D027074D /* RoomPowerLevelsProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPowerLevelsProxyMock.swift; sourceTree = "<group>"; };
|
||||||
E992D7B8BE54B2AB454613AF /* XCUIElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCUIElement.swift; sourceTree = "<group>"; };
|
E992D7B8BE54B2AB454613AF /* XCUIElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCUIElement.swift; sourceTree = "<group>"; };
|
||||||
|
E994322F10D20ECE68683683 /* GalleryRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryRoomTimelineItem.swift; sourceTree = "<group>"; };
|
||||||
E9A3D3CFA199FA7897364547 /* CallInviteRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallInviteRoomTimelineItem.swift; sourceTree = "<group>"; };
|
E9A3D3CFA199FA7897364547 /* CallInviteRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallInviteRoomTimelineItem.swift; sourceTree = "<group>"; };
|
||||||
E9B796D347E53631576F631C /* PreviewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewTests.swift; sourceTree = "<group>"; };
|
E9B796D347E53631576F631C /* PreviewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewTests.swift; sourceTree = "<group>"; };
|
||||||
E9D059BFE329BE09B6D96A9F /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ro; path = ro.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
E9D059BFE329BE09B6D96A9F /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ro; path = ro.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||||
@@ -5500,6 +5506,8 @@
|
|||||||
EE378083653EF0C9B5E9D580 /* EmoteRoomTimelineItemContent.swift */,
|
EE378083653EF0C9B5E9D580 /* EmoteRoomTimelineItemContent.swift */,
|
||||||
5098DA7799946A61E34A2373 /* FileRoomTimelineItem.swift */,
|
5098DA7799946A61E34A2373 /* FileRoomTimelineItem.swift */,
|
||||||
216F0DDC98F2A2C162D09C28 /* FileRoomTimelineItemContent.swift */,
|
216F0DDC98F2A2C162D09C28 /* FileRoomTimelineItemContent.swift */,
|
||||||
|
E994322F10D20ECE68683683 /* GalleryRoomTimelineItem.swift */,
|
||||||
|
35A5AB4E2E5CA98F99BFC552 /* GalleryRoomTimelineItemContent.swift */,
|
||||||
3DFE4453AB0B34C203447162 /* ImageRoomTimelineItem.swift */,
|
3DFE4453AB0B34C203447162 /* ImageRoomTimelineItem.swift */,
|
||||||
B2B5EDCD05D50BA9B815C66C /* ImageRoomTimelineItemContent.swift */,
|
B2B5EDCD05D50BA9B815C66C /* ImageRoomTimelineItemContent.swift */,
|
||||||
8A8DCBD0ABAADFDE5AF17E1F /* LiveLocationRoomTimelineItem.swift */,
|
8A8DCBD0ABAADFDE5AF17E1F /* LiveLocationRoomTimelineItem.swift */,
|
||||||
@@ -6572,6 +6580,7 @@
|
|||||||
2F06F70B9C433BAD4BC6B9F5 /* EncryptedRoomTimelineView.swift */,
|
2F06F70B9C433BAD4BC6B9F5 /* EncryptedRoomTimelineView.swift */,
|
||||||
4F75EF13F49DD2204E760910 /* FileRoomTimelineView.swift */,
|
4F75EF13F49DD2204E760910 /* FileRoomTimelineView.swift */,
|
||||||
C258C9C815272911A5B132C3 /* FormattedBodyText.swift */,
|
C258C9C815272911A5B132C3 /* FormattedBodyText.swift */,
|
||||||
|
A04EFA0E3139C01E1CFDC93B /* GalleryRoomTimelineView.swift */,
|
||||||
59B7CC77B82C6C67DE3AD869 /* HighlightedTimelineItemModifier.swift */,
|
59B7CC77B82C6C67DE3AD869 /* HighlightedTimelineItemModifier.swift */,
|
||||||
C5599255A6C98EBDA77B76E6 /* ImageRoomTimelineView.swift */,
|
C5599255A6C98EBDA77B76E6 /* ImageRoomTimelineView.swift */,
|
||||||
52947F8F356F0BA5B1F50912 /* LiveLocationRoomTimelineView.swift */,
|
52947F8F356F0BA5B1F50912 /* LiveLocationRoomTimelineView.swift */,
|
||||||
@@ -8544,6 +8553,9 @@
|
|||||||
7807B1DEE32617896886A8E5 /* FormattingToolbar.swift in Sources */,
|
7807B1DEE32617896886A8E5 /* FormattingToolbar.swift in Sources */,
|
||||||
5705511EBE083295EF98F998 /* FrequentlyUsedEmoji.swift in Sources */,
|
5705511EBE083295EF98F998 /* FrequentlyUsedEmoji.swift in Sources */,
|
||||||
46BA7F4B4D3A7164DED44B88 /* FullscreenDialog.swift in Sources */,
|
46BA7F4B4D3A7164DED44B88 /* FullscreenDialog.swift in Sources */,
|
||||||
|
E08DFC18E97623B5C91C5025 /* GalleryRoomTimelineItem.swift in Sources */,
|
||||||
|
1CA6E1843E1B3680544EB584 /* GalleryRoomTimelineItemContent.swift in Sources */,
|
||||||
|
30DA1A668482E5B95E8F1F2B /* GalleryRoomTimelineView.swift in Sources */,
|
||||||
F18CA61A58C77C84F551B8E7 /* GeneratedMocks.swift in Sources */,
|
F18CA61A58C77C84F551B8E7 /* GeneratedMocks.swift in Sources */,
|
||||||
4295E5F850897710A51AE114 /* GeoURI.swift in Sources */,
|
4295E5F850897710A51AE114 /* GeoURI.swift in Sources */,
|
||||||
F3C9CAD26FD4D7D6EBACF501 /* HTMLFixtures.swift in Sources */,
|
F3C9CAD26FD4D7D6EBACF501 /* HTMLFixtures.swift in Sources */,
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ struct SearchScreenMessage: Identifiable, Equatable {
|
|||||||
content.formattedBody ?? AttributedString(content.body)
|
content.formattedBody ?? AttributedString(content.body)
|
||||||
case .emote(let content):
|
case .emote(let content):
|
||||||
content.formattedBody ?? AttributedString(content.body)
|
content.formattedBody ?? AttributedString(content.body)
|
||||||
case .audio, .file, .image, .video:
|
case .audio, .file, .image, .video, .gallery:
|
||||||
nil
|
nil
|
||||||
case .voice:
|
case .voice:
|
||||||
AttributedString(L10n.commonVoiceMessage)
|
AttributedString(L10n.commonVoiceMessage)
|
||||||
@@ -130,7 +130,7 @@ struct SearchScreenMessage: Identifiable, Equatable {
|
|||||||
return .init(title: content.caption ?? content.filename,
|
return .init(title: content.caption ?? content.filename,
|
||||||
details: mediaDetails(filename: content.filename, fileSize: content.videoInfo.fileSize),
|
details: mediaDetails(filename: content.filename, fileSize: content.videoInfo.fileSize),
|
||||||
kind: .video(thumbnail: content.thumbnailInfo, blurhash: content.blurhash))
|
kind: .video(thumbnail: content.thumbnailInfo, blurhash: content.blurhash))
|
||||||
case .text, .notice, .emote, .voice, .location:
|
case .text, .notice, .emote, .voice, .location, .gallery:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,13 @@ struct TimelineReplyView: View {
|
|||||||
plainBody: L10n.commonSharedLocation,
|
plainBody: L10n.commonSharedLocation,
|
||||||
formattedBody: nil,
|
formattedBody: nil,
|
||||||
icon: .init(kind: .icon(\.locationPin)))
|
icon: .init(kind: .icon(\.locationPin)))
|
||||||
|
case .gallery(let content):
|
||||||
|
ReplyView(sender: sender,
|
||||||
|
plainBody: content.caption ?? content.body,
|
||||||
|
formattedBody: content.formattedCaption,
|
||||||
|
icon: content.items.first?.thumbnailSource.map { .init(kind: .mediaSource($0)) }
|
||||||
|
?? content.items.first?.mediaSource.map { .init(kind: .mediaSource($0)) }
|
||||||
|
?? .init(kind: .icon(\.attachment)))
|
||||||
}
|
}
|
||||||
case .poll(let question):
|
case .poll(let question):
|
||||||
ReplyView(sender: sender,
|
ReplyView(sender: sender,
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ struct TimelineThreadSummaryView: View {
|
|||||||
plainBody: L10n.commonSharedLocation,
|
plainBody: L10n.commonSharedLocation,
|
||||||
formattedBody: nil,
|
formattedBody: nil,
|
||||||
numberOfReplies: numberOfReplies)
|
numberOfReplies: numberOfReplies)
|
||||||
|
case .gallery(let content):
|
||||||
|
ThreadView(senderID: senderID,
|
||||||
|
sender: sender,
|
||||||
|
plainBody: content.caption ?? content.body,
|
||||||
|
formattedBody: content.formattedCaption,
|
||||||
|
numberOfReplies: numberOfReplies)
|
||||||
}
|
}
|
||||||
case .poll(let question):
|
case .poll(let question):
|
||||||
ThreadView(senderID: senderID,
|
ThreadView(senderID: senderID,
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// Copyright 2026 Element Creations Ltd.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
// Please see LICENSE files in the repository root for full details.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Compound
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GalleryRoomTimelineView: View {
|
||||||
|
let timelineItem: GalleryRoomTimelineItem
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TimelineStyler(timelineItem: timelineItem) {
|
||||||
|
// Placeholder rendering — the grid/list media views land in a follow-up.
|
||||||
|
FormattedBodyText(text: timelineItem.content.caption ?? timelineItem.content.body,
|
||||||
|
trailingReservedSize: timelineItem.trailingReservedSize,
|
||||||
|
boostFontSize: timelineItem.shouldBoost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-1
@@ -18,6 +18,7 @@ nonisolated enum EventBasedMessageTimelineItemContentType: Hashable {
|
|||||||
case video(VideoRoomTimelineItemContent)
|
case video(VideoRoomTimelineItemContent)
|
||||||
case location(LocationRoomTimelineItemContent)
|
case location(LocationRoomTimelineItemContent)
|
||||||
case voice(AudioRoomTimelineItemContent)
|
case voice(AudioRoomTimelineItemContent)
|
||||||
|
case gallery(GalleryRoomTimelineItemContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated protocol EventBasedMessageTimelineItemProtocol: EventBasedTimelineItemProtocol {
|
nonisolated protocol EventBasedMessageTimelineItemProtocol: EventBasedTimelineItemProtocol {
|
||||||
@@ -27,7 +28,7 @@ nonisolated protocol EventBasedMessageTimelineItemProtocol: EventBasedTimelineIt
|
|||||||
nonisolated extension EventBasedMessageTimelineItemProtocol {
|
nonisolated extension EventBasedMessageTimelineItemProtocol {
|
||||||
var supportsMediaCaption: Bool {
|
var supportsMediaCaption: Bool {
|
||||||
switch contentType {
|
switch contentType {
|
||||||
case .audio, .file, .image, .video:
|
case .audio, .file, .image, .video, .gallery:
|
||||||
true
|
true
|
||||||
case .emote, .notice, .text, .location, .voice:
|
case .emote, .notice, .text, .location, .voice:
|
||||||
false
|
false
|
||||||
@@ -48,6 +49,8 @@ nonisolated extension EventBasedMessageTimelineItemProtocol {
|
|||||||
content.caption
|
content.caption
|
||||||
case .video(let content):
|
case .video(let content):
|
||||||
content.caption
|
content.caption
|
||||||
|
case .gallery(let content):
|
||||||
|
content.caption
|
||||||
case .emote, .notice, .text, .location, .voice:
|
case .emote, .notice, .text, .location, .voice:
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
@@ -63,6 +66,8 @@ nonisolated extension EventBasedMessageTimelineItemProtocol {
|
|||||||
content.formattedCaption
|
content.formattedCaption
|
||||||
case .video(let content):
|
case .video(let content):
|
||||||
content.formattedCaption
|
content.formattedCaption
|
||||||
|
case .gallery(let content):
|
||||||
|
content.formattedCaption
|
||||||
case .emote, .notice, .text, .location, .voice:
|
case .emote, .notice, .text, .location, .voice:
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -103,7 +103,7 @@ nonisolated extension EventBasedTimelineItemProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch messageBasedItem.contentType {
|
switch messageBasedItem.contentType {
|
||||||
case .audio, .file, .image, .video, .location, .voice:
|
case .audio, .file, .image, .video, .location, .voice, .gallery:
|
||||||
return false
|
return false
|
||||||
case .text, .emote, .notice:
|
case .text, .emote, .notice:
|
||||||
return true
|
return true
|
||||||
|
|||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
//
|
||||||
|
// Copyright 2026 Element Creations Ltd.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
// Please see LICENSE files in the repository root for full details.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
nonisolated struct GalleryRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatable {
|
||||||
|
let id: TimelineItemIdentifier
|
||||||
|
let timestamp: Date
|
||||||
|
let isOutgoing: Bool
|
||||||
|
let isEditable: Bool
|
||||||
|
let canBeRepliedTo: Bool
|
||||||
|
var shouldBoost = false
|
||||||
|
|
||||||
|
let sender: TimelineItemSender
|
||||||
|
|
||||||
|
let content: GalleryRoomTimelineItemContent
|
||||||
|
|
||||||
|
var properties = RoomTimelineItemProperties()
|
||||||
|
|
||||||
|
var body: String {
|
||||||
|
content.caption ?? content.body
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentType: EventBasedMessageTimelineItemContentType {
|
||||||
|
.gallery(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
//
|
||||||
|
// Copyright 2026 Element Creations Ltd.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
// Please see LICENSE files in the repository root for full details.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
nonisolated struct GalleryRoomTimelineItemContent: Hashable {
|
||||||
|
let body: String
|
||||||
|
var caption: String?
|
||||||
|
var formattedCaption: AttributedString?
|
||||||
|
/// The original textual representation of the formatted caption directly from the event (usually HTML code)
|
||||||
|
var formattedCaptionHTMLString: String?
|
||||||
|
let items: [GalleryItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identifies a single attachment within a gallery message.
|
||||||
|
nonisolated struct GalleryItemID: Hashable {
|
||||||
|
/// The unique ID of the parent gallery timeline item.
|
||||||
|
let timelineItemUniqueID: TimelineItemIdentifier.UniqueID
|
||||||
|
/// The item's position within the gallery.
|
||||||
|
let mediaIndex: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single attachment of a gallery message. The SDK hands back the same content types it uses for
|
||||||
|
/// individual messages, so we reuse them here rather than duplicating their fields.
|
||||||
|
nonisolated enum GalleryItem: Hashable, Identifiable {
|
||||||
|
case image(id: GalleryItemID, ImageRoomTimelineItemContent)
|
||||||
|
case video(id: GalleryItemID, VideoRoomTimelineItemContent)
|
||||||
|
case audio(id: GalleryItemID, AudioRoomTimelineItemContent)
|
||||||
|
case file(id: GalleryItemID, FileRoomTimelineItemContent)
|
||||||
|
/// An item of an unknown type. It has no media source, so there's nothing to preview.
|
||||||
|
case other(id: GalleryItemID, filename: String)
|
||||||
|
|
||||||
|
var id: GalleryItemID {
|
||||||
|
switch self {
|
||||||
|
case .image(let id, _), .video(let id, _), .audio(let id, _), .file(let id, _), .other(let id, _):
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isImage: Bool {
|
||||||
|
if case .image = self {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isVideo: Bool {
|
||||||
|
if case .video = self {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isAudio: Bool {
|
||||||
|
if case .audio = self {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isFile: Bool {
|
||||||
|
if case .file = self {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var filename: String {
|
||||||
|
switch self {
|
||||||
|
case .image(_, let content): content.filename
|
||||||
|
case .video(_, let content): content.filename
|
||||||
|
case .audio(_, let content): content.filename
|
||||||
|
case .file(_, let content): content.filename
|
||||||
|
case .other(_, let filename): filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var mediaSource: MediaSourceProxy? {
|
||||||
|
switch self {
|
||||||
|
case .image(_, let content): content.imageInfo.source
|
||||||
|
case .video(_, let content): content.videoInfo.source
|
||||||
|
case .audio(_, let content): content.source
|
||||||
|
case .file(_, let content): content.source
|
||||||
|
case .other: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var thumbnailSource: MediaSourceProxy? {
|
||||||
|
switch self {
|
||||||
|
case .image(_, let content): content.thumbnailInfo?.source
|
||||||
|
case .video(_, let content): content.thumbnailInfo?.source
|
||||||
|
case .file(_, let content): content.thumbnailSource
|
||||||
|
case .audio, .other: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var size: CGSize? {
|
||||||
|
switch self {
|
||||||
|
case .image(_, let content): content.imageInfo.size
|
||||||
|
case .video(_, let content): content.videoInfo.size
|
||||||
|
case .audio, .file, .other: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var blurhash: String? {
|
||||||
|
switch self {
|
||||||
|
case .image(_, let content): content.blurhash
|
||||||
|
case .video(_, let content): content.blurhash
|
||||||
|
case .audio, .file, .other: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var duration: TimeInterval? {
|
||||||
|
switch self {
|
||||||
|
case .video(_, let content): content.videoInfo.duration
|
||||||
|
case .audio(_, let content): content.duration
|
||||||
|
case .image, .file, .other: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentType: UTType? {
|
||||||
|
switch self {
|
||||||
|
case .image(_, let content): content.contentType
|
||||||
|
case .video(_, let content): content.contentType
|
||||||
|
case .audio(_, let content): content.contentType
|
||||||
|
case .file(_, let content): content.contentType
|
||||||
|
case .other: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the item can render a visible thumbnail. Images always can (their media source
|
||||||
|
/// IS the image); for everything else we need an explicit thumbnail source.
|
||||||
|
var hasThumbnail: Bool {
|
||||||
|
isImage || thumbnailSource != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,7 +149,7 @@ nonisolated struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
|||||||
case .location(let content):
|
case .location(let content):
|
||||||
.location(buildLocationTimelineItemContent(content))
|
.location(buildLocationTimelineItemContent(content))
|
||||||
case .gallery(let content):
|
case .gallery(let content):
|
||||||
.text(.init(body: content.body))
|
.gallery(buildGalleryTimelineItemContent(content, uniqueID: .init(senderID)))
|
||||||
case .other(_, let body):
|
case .other(_, let body):
|
||||||
.text(.init(body: body))
|
.text(.init(body: body))
|
||||||
case .none:
|
case .none:
|
||||||
@@ -410,23 +410,54 @@ nonisolated struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
|||||||
_ messageContent: MessageContent,
|
_ messageContent: MessageContent,
|
||||||
_ galleryMessageContent: GalleryMessageContent,
|
_ galleryMessageContent: GalleryMessageContent,
|
||||||
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
|
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
|
||||||
TextRoomTimelineItem(id: eventItemProxy.id,
|
GalleryRoomTimelineItem(id: eventItemProxy.id,
|
||||||
timestamp: eventItemProxy.timestamp,
|
timestamp: eventItemProxy.timestamp,
|
||||||
isOutgoing: isOutgoing,
|
isOutgoing: isOutgoing,
|
||||||
isEditable: eventItemProxy.isEditable,
|
isEditable: eventItemProxy.isEditable,
|
||||||
canBeRepliedTo: eventItemProxy.canBeRepliedTo,
|
canBeRepliedTo: eventItemProxy.canBeRepliedTo,
|
||||||
shouldBoost: eventItemProxy.shouldBoost,
|
shouldBoost: eventItemProxy.shouldBoost,
|
||||||
sender: eventItemProxy.sender,
|
sender: eventItemProxy.sender,
|
||||||
content: .init(body: galleryMessageContent.body),
|
content: buildGalleryTimelineItemContent(galleryMessageContent, uniqueID: eventItemProxy.id.uniqueID),
|
||||||
properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo),
|
properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo),
|
||||||
isThreaded: messageLikeContent.threadRoot != nil,
|
isThreaded: messageLikeContent.threadRoot != nil,
|
||||||
threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary),
|
threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary),
|
||||||
isEdited: messageContent.isEdited,
|
isEdited: messageContent.isEdited,
|
||||||
reactions: buildAggregatedReactions(messageLikeContent.reactions),
|
reactions: buildAggregatedReactions(messageLikeContent.reactions),
|
||||||
deliveryStatus: eventItemProxy.deliveryStatus,
|
deliveryStatus: eventItemProxy.deliveryStatus,
|
||||||
orderedReadReceipts: buildOrderedReadReceipts(eventItemProxy.readReceipts),
|
orderedReadReceipts: buildOrderedReadReceipts(eventItemProxy.readReceipts),
|
||||||
encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState),
|
encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState),
|
||||||
encryptionForwarder: eventItemProxy.forwarder))
|
encryptionForwarder: eventItemProxy.forwarder))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildGalleryTimelineItemContent(_ messageContent: GalleryMessageContent, uniqueID: TimelineItemIdentifier.UniqueID) -> GalleryRoomTimelineItemContent {
|
||||||
|
let htmlCaption = messageContent.formatted?.format == .html ? messageContent.formatted?.body : nil
|
||||||
|
let plainCaption = messageContent.formatted?.format != .html ? messageContent.formatted?.body : nil
|
||||||
|
let formattedCaption = htmlCaption != nil ? attributedStringBuilder.fromHTML(htmlCaption) : (plainCaption.flatMap(attributedStringBuilder.fromPlain))
|
||||||
|
|
||||||
|
let items = messageContent.itemtypes.enumerated().map { index, itemType in
|
||||||
|
buildGalleryItem(itemType, id: GalleryItemID(timelineItemUniqueID: uniqueID, mediaIndex: index))
|
||||||
|
}
|
||||||
|
|
||||||
|
return GalleryRoomTimelineItemContent(body: messageContent.body,
|
||||||
|
caption: plainCaption ?? messageContent.body,
|
||||||
|
formattedCaption: formattedCaption,
|
||||||
|
formattedCaptionHTMLString: htmlCaption,
|
||||||
|
items: items)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildGalleryItem(_ itemType: GalleryItemType, id: GalleryItemID) -> GalleryItem {
|
||||||
|
switch itemType {
|
||||||
|
case .image(let content):
|
||||||
|
.image(id: id, buildImageTimelineItemContent(content))
|
||||||
|
case .video(let content):
|
||||||
|
.video(id: id, buildVideoTimelineItemContent(content))
|
||||||
|
case .audio(let content):
|
||||||
|
.audio(id: id, buildAudioTimelineItemContent(content))
|
||||||
|
case .file(let content):
|
||||||
|
.file(id: id, buildFileTimelineItemContent(content))
|
||||||
|
case .other(_, let body):
|
||||||
|
.other(id: id, filename: body)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildStickerTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
|
private func buildStickerTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ struct RoomTimelineItemView: View {
|
|||||||
AudioRoomTimelineView(timelineItem: item)
|
AudioRoomTimelineView(timelineItem: item)
|
||||||
case .file(let item):
|
case .file(let item):
|
||||||
FileRoomTimelineView(timelineItem: item)
|
FileRoomTimelineView(timelineItem: item)
|
||||||
|
case .gallery(let item):
|
||||||
|
GalleryRoomTimelineView(timelineItem: item)
|
||||||
case .emote(let item):
|
case .emote(let item):
|
||||||
EmoteRoomTimelineView(timelineItem: item)
|
EmoteRoomTimelineView(timelineItem: item)
|
||||||
case .notice(let item):
|
case .notice(let item):
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ enum RoomTimelineItemType: Equatable {
|
|||||||
case video(VideoRoomTimelineItem)
|
case video(VideoRoomTimelineItem)
|
||||||
case audio(AudioRoomTimelineItem)
|
case audio(AudioRoomTimelineItem)
|
||||||
case file(FileRoomTimelineItem)
|
case file(FileRoomTimelineItem)
|
||||||
|
case gallery(GalleryRoomTimelineItem)
|
||||||
case emote(EmoteRoomTimelineItem)
|
case emote(EmoteRoomTimelineItem)
|
||||||
case notice(NoticeRoomTimelineItem)
|
case notice(NoticeRoomTimelineItem)
|
||||||
case redacted(RedactedRoomTimelineItem)
|
case redacted(RedactedRoomTimelineItem)
|
||||||
@@ -83,6 +84,8 @@ enum RoomTimelineItemType: Equatable {
|
|||||||
self = .audio(item)
|
self = .audio(item)
|
||||||
case let item as FileRoomTimelineItem:
|
case let item as FileRoomTimelineItem:
|
||||||
self = .file(item)
|
self = .file(item)
|
||||||
|
case let item as GalleryRoomTimelineItem:
|
||||||
|
self = .gallery(item)
|
||||||
case let item as SeparatorRoomTimelineItem:
|
case let item as SeparatorRoomTimelineItem:
|
||||||
self = .separator(item)
|
self = .separator(item)
|
||||||
case let item as NoticeRoomTimelineItem:
|
case let item as NoticeRoomTimelineItem:
|
||||||
@@ -132,6 +135,7 @@ enum RoomTimelineItemType: Equatable {
|
|||||||
.video(let item as RoomTimelineItemProtocol),
|
.video(let item as RoomTimelineItemProtocol),
|
||||||
.audio(let item as RoomTimelineItemProtocol),
|
.audio(let item as RoomTimelineItemProtocol),
|
||||||
.file(let item as RoomTimelineItemProtocol),
|
.file(let item as RoomTimelineItemProtocol),
|
||||||
|
.gallery(let item as RoomTimelineItemProtocol),
|
||||||
.emote(let item as RoomTimelineItemProtocol),
|
.emote(let item as RoomTimelineItemProtocol),
|
||||||
.notice(let item as RoomTimelineItemProtocol),
|
.notice(let item as RoomTimelineItemProtocol),
|
||||||
.redacted(let item as RoomTimelineItemProtocol),
|
.redacted(let item as RoomTimelineItemProtocol),
|
||||||
@@ -171,6 +175,8 @@ enum RoomTimelineItemType: Equatable {
|
|||||||
return item.timestamp
|
return item.timestamp
|
||||||
case .file(let item):
|
case .file(let item):
|
||||||
return item.timestamp
|
return item.timestamp
|
||||||
|
case .gallery(let item):
|
||||||
|
return item.timestamp
|
||||||
case .emote(let item):
|
case .emote(let item):
|
||||||
return item.timestamp
|
return item.timestamp
|
||||||
case .notice(let item):
|
case .notice(let item):
|
||||||
|
|||||||
Reference in New Issue
Block a user