diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index eb4c23c9a7..26cd6687e6 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -36,6 +36,8 @@ struct GroupChatInfoView: View { @FocusState private var searchFocussed @State private var showSecrets: Set = [] @State private var selectedTab: GroupInfoTab = .members + @State private var selectedImage: ChatItem? + @State private var showImageFullScreen = false enum GroupInfoTab: CaseIterable { case members @@ -102,26 +104,7 @@ struct GroupChatInfoView: View { .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) .padding(.bottom, 18) - // TODO hide if there are no gallery tabs, only show needed tabs - Section { - Picker("", selection: $selectedTab) { - ForEach(GroupInfoTab.allCases, id: \.self) { tab in - Image(systemName: tab.imageName) - .tag(tab) - } - } - .pickerStyle(.segmented) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - } - - if selectedTab == .members { - membersTabContent(members: members) - } else { - //TODO: After adding media API calls, add exact UI elements - noDataAvailableView() - } + tabContentView(members: members) } .modifier(ThemedBackground(grouped: true)) .navigationBarHidden(true) @@ -132,6 +115,16 @@ struct GroupChatInfoView: View { ProgressView().scaleEffect(2) } } + .fullScreenCover(isPresented: $showImageFullScreen) { + if let item = selectedImage, let image = getLoadedImage(item.file) { + FullScreenMediaView( + chatItem: item, + scrollToItem: nil, + image: image, + showView: $showImageFullScreen + ) + } + } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .alert(item: $alert) { alertItem in @@ -160,6 +153,42 @@ struct GroupChatInfoView: View { } } + @ViewBuilder + private func tabContentView(members: [GMember]) -> some View { + switch selectedTab { + case .members: + membersTabContent(members: members) + case .images: + Section { + GroupImagesTabView( + groupInfo: groupInfo, + selectedImage: $selectedImage, + showImageFullScreen: $showImageFullScreen + ) + } + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + case .videos: + Section { + GroupVideosTabView(groupInfo: groupInfo) + } + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + case .files: + Section { + GroupFilesTabView(groupInfo: groupInfo) + } + case .links: + Section { + GroupLinksTabView(groupInfo: groupInfo) + } + case .voices: + Section { + GroupVoicesTabView(groupInfo: groupInfo) + } + } + } + private func groupInfoHeader() -> some View { VStack { let cInfo = chat.chatInfo diff --git a/apps/ios/Shared/Views/Chat/Group/MediaTabs/Files/FileListRowView.swift b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Files/FileListRowView.swift new file mode 100644 index 0000000000..d41c61e241 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Files/FileListRowView.swift @@ -0,0 +1,68 @@ +// +// FileListRowView.swift +// SimpleX +// +// Created by Suren Poghosyan on 20.01.26. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct FileListRowView: View { + let chatItem: ChatItem + @EnvironmentObject var theme: AppTheme + + var body: some View { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(Color(.systemGray5)) + .frame(width: 40, height: 40) + + Image(systemName: "doc.fill") + .font(.system(size: 20)) + .foregroundColor(theme.colors.primary) + } + + VStack(alignment: .leading, spacing: 4) { + Text(itemTitle) + .font(.body) + .foregroundColor(theme.colors.onBackground) + .lineLimit(1) + + HStack(spacing: 8) { + Text(formatTimestampChatInfo(chatItem.meta.itemTs)) + .font(.caption) + .foregroundColor(theme.colors.secondary) + + if let file = chatItem.file { + Text("• \(formatFileSize(file.fileSize))") + .font(.caption) + .foregroundColor(theme.colors.secondary) + } + } + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 14)) + .foregroundColor(theme.colors.secondary) + } + .padding(.vertical, 8) + } + + private var itemTitle: String { + if let file = chatItem.file { + return file.fileName + } + return "File" + } + + private func formatFileSize(_ bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: bytes) + } +} diff --git a/apps/ios/Shared/Views/Chat/Group/MediaTabs/Files/GroupFilesTabView.swift b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Files/GroupFilesTabView.swift new file mode 100644 index 0000000000..536404c066 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Files/GroupFilesTabView.swift @@ -0,0 +1,196 @@ +// +// GroupFilesTabView.swift +// SimpleX (iOS) +// +// Created by Suren Poghosyan on 14.01.26. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct GroupFilesTabView: View { + @EnvironmentObject var m: ChatModel + @EnvironmentObject var theme: AppTheme + + @State private var mediaItems: [ChatItem] = [] + @State private var isLoading = false + @State private var isLoadingMore = false + @State private var hasMore = true + + private let pageSize: Int = 100 + let groupInfo: GroupInfo + + var body: some View { + Group { + if isLoading { + loadingView + } else if mediaItems.isEmpty { + emptyView + } else { + fileListView + } + } + .onAppear { + loadFiles() + } + } + + private var loadingView: some View { + HStack { + Spacer() + ProgressView() + .padding(.vertical, 40) + Spacer() + } + } + + private var emptyView: some View { + HStack { + Spacer() + VStack(spacing: 12) { + Image(systemName: "doc") + .font(.system(size: 48)) + .foregroundColor(theme.colors.secondary) + Text("No files") + .foregroundColor(theme.colors.secondary) + } + .padding(.vertical, 60) + Spacer() + } + } + + private var fileListView: some View { + LazyVStack(spacing: 0) { + ForEach(Array(mediaItems.enumerated()), id: \.element.id) { index, item in + FileListRowView(chatItem: item) + .onTapGesture { + handleFileTap(item) + } + .onAppear { + let isLast = index == mediaItems.count - 1 + if isLast { + loadMoreIfNeeded() + } + } + } + if isLoadingMore { + HStack { + Spacer() + ProgressView().padding() + Spacer() + } + } + } + } + + private func loadFiles(reset: Bool = true) { + if reset { + guard !isLoading else { return } + isLoading = true + mediaItems = [] + hasMore = true + } else { + guard !isLoadingMore && hasMore else { return } + isLoadingMore = true + } + + let count = pageSize + let pagination: ChatPagination = { + if reset { + return .last(count: count) + } else if let lastId = mediaItems.last?.id { + return .before(chatItemId: lastId, count: count) + } else { + return .last(count: count) + } + }() + + Task { + do { + let (chat, _) = try await apiGetChat( + chatId: groupInfo.id, + scope: nil, + contentTag: .file, + pagination: pagination, + search: "" + ) + await MainActor.run { + if reset { + mediaItems = chat.chatItems + isLoading = false + } else { + mediaItems.append(contentsOf: chat.chatItems) + isLoadingMore = false + } + hasMore = chat.chatItems.count == pageSize + } + } catch { + logger.error("loadFiles error: \(responseError(error))") + await MainActor.run { + if reset { isLoading = false } else { isLoadingMore = false } + } + } + } + } + + private func loadMoreIfNeeded() { + loadFiles(reset: false) + } + + private func handleFileTap(_ item: ChatItem) { + guard let file = item.file else { return } + + switch file.fileStatus { + case .rcvInvitation, .rcvAborted: + if fileSizeValid(file) { + Task { + if let user = m.currentUser { + await receiveFile(user: user, fileId: file.fileId) + } + } + } else { + let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol), countStyle: .binary) + AlertManager.shared.showAlertMsg( + title: "Large file!", + message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))." + ) + } + case .rcvAccepted: + switch file.fileProtocol { + case .xftp: + AlertManager.shared.showAlertMsg( + title: "Waiting for file", + message: "File will be received when your contact completes uploading it." + ) + case .smp: + AlertManager.shared.showAlertMsg( + title: "Waiting for file", + message: "File will be received when your contact is online, please wait or check later!" + ) + case .local: () + } + case .rcvComplete: + if let fileSource = getLoadedFileSource(file) { + saveCryptoFile(fileSource) + } + case let .rcvError(rcvFileError): + showFileErrorAlert(rcvFileError) + case let .rcvWarning(rcvFileError): + showFileErrorAlert(rcvFileError, temporary: true) + case .sndStored: + if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) { + saveCryptoFile(fileSource) + } + case .sndComplete: + if let fileSource = getLoadedFileSource(file) { + saveCryptoFile(fileSource) + } + case let .sndError(sndFileError): + showFileErrorAlert(sndFileError) + case let .sndWarning(sndFileError): + showFileErrorAlert(sndFileError, temporary: true) + default: break + } + } +} diff --git a/apps/ios/Shared/Views/Chat/Group/MediaTabs/Images/GroupImagesTabView.swift b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Images/GroupImagesTabView.swift new file mode 100644 index 0000000000..4b2e0ab8cc --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Images/GroupImagesTabView.swift @@ -0,0 +1,170 @@ +// +// GroupImagesTabView.swift +// SimpleX (iOS) +// +// Created for media filtering feature +// + +import SwiftUI +import SimpleXChat + +struct GroupImagesTabView: View { + @EnvironmentObject var theme: AppTheme + let groupInfo: GroupInfo + @Binding var selectedImage: ChatItem? + @Binding var showImageFullScreen: Bool + + @State private var mediaItems: [ChatItem] = [] + @State private var isLoading = false + @State private var isLoadingMore = false + @State private var hasMore = true + private let pageSize: Int = 100 + + var body: some View { + Group { + if isLoading { + loadingView + } else if mediaItems.isEmpty { + emptyView + } else { + VStack(spacing: 0) { + imageGridView + } + } + } + .onAppear { + loadImages() + } + } + + private var loadingView: some View { + HStack { + Spacer() + ProgressView() + .padding(.vertical, 40) + Spacer() + } + } + + private var emptyView: some View { + HStack { + Spacer() + VStack(spacing: 12) { + Image(systemName: "photo") + .font(.system(size: 48)) + .foregroundColor(theme.colors.secondary) + Text("No images") + .foregroundColor(theme.colors.secondary) + } + .padding(.vertical, 60) + Spacer() + } + } + + private var imageGridView: some View { + VStack(spacing: 0) { + GeometryReader { geometry in + let spacing: CGFloat = 1 + let availableWidth = geometry.size.width + let imageSize = (availableWidth - (spacing * 2)) / 3 + let numberOfRows = ceil(Double(mediaItems.count) / 3.0) + let totalHeight = (imageSize * CGFloat(numberOfRows)) + (spacing * max(0, CGFloat(numberOfRows-1))) + let columns = [ + GridItem(.fixed(imageSize), spacing: spacing), + GridItem(.fixed(imageSize), spacing: spacing), + GridItem(.fixed(imageSize), spacing: spacing) + ] + + LazyVGrid(columns: columns, spacing: spacing) { + ForEach(Array(mediaItems.enumerated()), id: \.element.id) { index, item in + if let file = item.file { + ImageThumbnailView( + chatItem: item, + file: file, + size: imageSize, + selectedImage: $selectedImage, + showFullScreen: $showImageFullScreen + ) + .onAppear { + if index == mediaItems.count - 1 { + loadMoreIfNeeded() + } + } + } + } + } + .frame(width: availableWidth, height: totalHeight, alignment: .topLeading) + if isLoadingMore { + HStack { Spacer(); ProgressView().padding(); Spacer() } + } + } + .frame(height: calculateEstimatedHeight()) + .clipped() + } + } + + private func calculateEstimatedHeight() -> CGFloat { + guard !mediaItems.isEmpty else { return 0 } + let spacing: CGFloat = 1 + let screenWidth = UIScreen.main.bounds.width - 32 + let imageSize = (screenWidth - (spacing * 2)) / 3 + let numberOfRows = ceil(Double(mediaItems.count) / 3.0) + let totalHeight = (imageSize * CGFloat(numberOfRows)) + (spacing * max(0, CGFloat(numberOfRows - 1))) + return totalHeight + } + + private func loadImages(reset: Bool = true) { + if reset { + guard !isLoading else { return } + isLoading = true + mediaItems = [] + hasMore = true + } else { + guard !isLoadingMore && hasMore else { return } + isLoadingMore = true + } + + let count = pageSize + let pagination: ChatPagination = { + if reset { + return .last(count: count) + } else if let lastId = mediaItems.last?.id { + return .before(chatItemId: lastId, count: count) + } else { + return .last(count: count) + } + }() + + Task { + do { + let (chat, _) = try await apiGetChat( + chatId: groupInfo.id, + scope: nil, + contentTag: .image, + pagination: pagination, + search: "" + ) + await MainActor.run { + if reset { + mediaItems = chat.chatItems + isLoading = false + } else { + mediaItems.append(contentsOf: chat.chatItems) + isLoadingMore = false + } + hasMore = chat.chatItems.count == pageSize + } + } catch { + logger.error("loadImages error: \(responseError(error))") + await MainActor.run { + if reset { isLoading = false } else { isLoadingMore = false } + } + } + } + } + + private func loadMoreIfNeeded() { + loadImages(reset: false) + } +} + diff --git a/apps/ios/Shared/Views/Chat/Group/MediaTabs/Images/ImageThumbnailView.swift b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Images/ImageThumbnailView.swift new file mode 100644 index 0000000000..8f0885b534 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Images/ImageThumbnailView.swift @@ -0,0 +1,47 @@ +// +// ImageThumbnailView.swift +// SimpleX +// +// Created by Suren Poghosyan on 20.01.26. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ImageThumbnailView: View { + let chatItem: ChatItem + let file: CIFile + let size: CGFloat + + @Binding var selectedImage: ChatItem? + @Binding var showFullScreen: Bool + @EnvironmentObject var theme: AppTheme + + var body: some View { + ZStack { + if let image = getLoadedImage(file) { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: size, height: size) + .clipped() + } else { + Rectangle() + .fill(Color(.systemGray5)) + .frame(width: size, height: size) + .overlay { + Image(systemName: "photo") + .font(.system(size: 32)) + .foregroundColor(.gray) + } + } + } + .frame(width: size, height: size) + .contentShape(Rectangle()) + .onTapGesture { + selectedImage = chatItem + showFullScreen = true + } + } +} diff --git a/apps/ios/Shared/Views/Chat/Group/MediaTabs/Links/GroupLinksTabView.swift b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Links/GroupLinksTabView.swift new file mode 100644 index 0000000000..4772d8223d --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Links/GroupLinksTabView.swift @@ -0,0 +1,132 @@ +// +// GroupLinksTabView.swift +// SimpleX (iOS) +// +// Created by Suren Poghosyan on 14.01.26. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct GroupLinksTabView: View { + @EnvironmentObject var theme: AppTheme + + @State private var mediaItems: [ChatItem] = [] + @State private var isLoading = false + @State private var isLoadingMore = false + @State private var hasMore = true + + private let pageSize: Int = 100 + let groupInfo: GroupInfo + + var body: some View { + Group { + if isLoading { + loadingView + } else if mediaItems.isEmpty { + emptyView + } else { + linkListView + } + } + .onAppear { + loadLinks() + } + } + + private var loadingView: some View { + HStack { + Spacer() + ProgressView() + .padding(.vertical, 40) + Spacer() + } + } + + private var emptyView: some View { + HStack { + Spacer() + VStack(spacing: 12) { + Image(systemName: "link") + .font(.system(size: 48)) + .foregroundColor(theme.colors.secondary) + Text("No links") + .foregroundColor(theme.colors.secondary) + } + .padding(.vertical, 60) + Spacer() + } + } + + private var linkListView: some View { + LazyVStack(spacing: 0) { + ForEach(Array(mediaItems.enumerated()), id: \.element.id) { index, item in + LinkListRowView(chatItem: item) + .onTapGesture { openBrowserAlert(uri: item.content.text) } + .onAppear { + if index == mediaItems.count - 1 { + loadMoreIfNeeded() + } + } + } + if isLoadingMore { + HStack { Spacer(); ProgressView().padding(); Spacer() } + } + } + } + + private func loadLinks(reset: Bool = true) { + if reset { + guard !isLoading else { return } + isLoading = true + mediaItems = [] + hasMore = true + } else { + guard !isLoadingMore && hasMore else { return } + isLoadingMore = true + } + + let count = pageSize + let pagination: ChatPagination = { + if reset { + return .last(count: count) + } else if let lastId = mediaItems.last?.id { + return .before(chatItemId: lastId, count: count) + } else { + return .last(count: count) + } + }() + + Task { + do { + let (chat, _) = try await apiGetChat( + chatId: groupInfo.id, + scope: nil, + contentTag: .link, + pagination: pagination, + search: "" + ) + await MainActor.run { + if reset { + mediaItems = chat.chatItems + isLoading = false + } else { + mediaItems.append(contentsOf: chat.chatItems) + isLoadingMore = false + } + hasMore = chat.chatItems.count == pageSize + } + } catch { + logger.error("GroupLinksTabView: loadLinks error: \(responseError(error))") + await MainActor.run { + if reset { isLoading = false } else { isLoadingMore = false } + } + } + } + } + + private func loadMoreIfNeeded() { + loadLinks(reset: false) + } +} diff --git a/apps/ios/Shared/Views/Chat/Group/MediaTabs/Links/LinkListRowView.swift b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Links/LinkListRowView.swift new file mode 100644 index 0000000000..9303a24951 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Links/LinkListRowView.swift @@ -0,0 +1,89 @@ +// +// LinkListRowView.swift +// SimpleX +// +// Created by Suren Poghosyan on 20.01.26. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat +import LinkPresentation + +struct LinkListRowView: View { + + let chatItem: ChatItem + + @EnvironmentObject var theme: AppTheme + + @State private var linkThumbnail: UIImage? = nil + + var body: some View { + HStack(spacing: 12) { + if let image = linkThumbnail { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .clipShape(Circle()) + .frame(width: 40, height: 40) + } else { + ZStack { + Circle() + .fill(Color(.systemGray5)) + .frame(width: 40, height: 40) + + Image(systemName: "link") + .font(.system(size: 20)) + .foregroundColor(theme.colors.primary) + } + } + + VStack(alignment: .leading, spacing: 4) { + Text(linkText) + .font(.body) + .foregroundColor(theme.colors.onBackground) + .lineLimit(2) + + Text(formatTimestampChatInfo(chatItem.meta.itemTs)) + .font(.caption) + .foregroundColor(theme.colors.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 14)) + .foregroundColor(theme.colors.secondary) + } + .padding(.vertical, 8) + .onAppear { + loadThumbnail() + } + } + + private var linkText: String { + chatItem.content.text + } + + private var linkURL: URL? { + URL(string: linkText.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + private func loadThumbnail() { + guard linkURL != nil, linkThumbnail == nil else { return } + + let provider = LPMetadataProvider() + provider.startFetchingMetadata(for: linkURL!) { metadata, error in + guard error == nil, let metadata = metadata else { return } + if let imageProvider = metadata.imageProvider { + imageProvider.loadObject(ofClass: UIImage.self) { image, error in + if let uiImage = image as? UIImage { + DispatchQueue.main.async { + self.linkThumbnail = uiImage + } + } + } + } + } + } +} diff --git a/apps/ios/Shared/Views/Chat/Group/MediaTabs/Videos/GroupVideosTabView.swift b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Videos/GroupVideosTabView.swift new file mode 100644 index 0000000000..962f290586 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Videos/GroupVideosTabView.swift @@ -0,0 +1,285 @@ +// +// GroupVideosTabView.swift +// SimpleX (iOS) +// +// Created by Suren Poghosyan on 14.01.26. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat +import AVKit + +struct GroupVideosTabView: View { + + // MARK: - Environment + @EnvironmentObject var theme: AppTheme + @EnvironmentObject var m: ChatModel + + // MARK: - Properties + let groupInfo: GroupInfo + private let pageSize: Int = 100 + + // MARK: - State + @State private var mediaItems: [ChatItem] = [] + @State private var isLoading = false + @State private var isLoadingMore = false + @State private var hasMore = true + @State private var selectedVideo: ChatItem? + @State private var showFullScreenVideo = false + @State private var videoURL: URL? + @State private var videoPlayer: AVPlayer? + @State private var isDecrypting = false + @State private var lastRequestedBeforeId: Int64? + + // MARK: - Body + var body: some View { + Group { + if isLoading { + loadingView + } else if mediaItems.isEmpty { + emptyView + } else { + videoGridView + } + } + .task { + if mediaItems.isEmpty && !isLoading { + loadVideos(reset: true) + } + } + } + + // MARK: - Subviews + private var loadingView: some View { + HStack { + Spacer() + ProgressView() + .padding(.vertical, 40) + Spacer() + } + } + + private var emptyView: some View { + HStack { + Spacer() + VStack(spacing: 12) { + Image(systemName: "video") + .font(.system(size: 48)) + .foregroundColor(theme.colors.secondary) + Text("No videos") + .foregroundColor(theme.colors.secondary) + } + .padding(.vertical, 60) + Spacer() + } + } + + private var videoGridView: some View { + VStack(spacing: 0) { + GeometryReader { geometry in + let spacing: CGFloat = 1 + let availableWidth = geometry.size.width + let videoSize = (availableWidth - (spacing * 2)) / 3 + let numberOfRows = ceil(Double(mediaItems.count) / 3.0) + let totalHeight = (videoSize * CGFloat(numberOfRows)) + (spacing * max(0, CGFloat(numberOfRows - 1))) + + let columns = [ + GridItem(.fixed(videoSize), spacing: spacing), + GridItem(.fixed(videoSize), spacing: spacing), + GridItem(.fixed(videoSize), spacing: spacing) + ] + + LazyVGrid(columns: columns, spacing: spacing) { + ForEach(Array(mediaItems.enumerated()), id: \.element.id) { index, item in + if let file = item.file { + VideoThumbnailView( + chatItem: item, + file: file, + size: videoSize + ) + .onTapGesture { openVideoPlayer(item) } + .onAppear { + if index == mediaItems.count - 1 { + loadMoreIfNeeded() + } + } + } + } + } + .frame(width: availableWidth, height: totalHeight, alignment: .topLeading) + + if isLoadingMore { + HStack { Spacer(); ProgressView().padding(); Spacer() } + } + } + .frame(height: mediaItems.isEmpty ? 1 : calculateEstimatedHeight()) + .clipped() + } + .fullScreenCover(isPresented: $showFullScreenVideo) { + fullScreenVideoContent + } + } + + @ViewBuilder + private var fullScreenVideoContent: some View { + if let selectedVideo, let videoURL, let videoPlayer { + FullScreenMediaView( + chatItem: selectedVideo, + scrollToItem: nil, + player: videoPlayer, + url: videoURL, + showView: $showFullScreenVideo + ) + .onDisappear(perform: cleanupVideoPlayer) + } else if isDecrypting { + decryptingView + } + } + + private var decryptingView: some View { + ZStack { + Color.black.edgesIgnoringSafeArea(.all) + ProgressView() + .tint(.white) + .scaleEffect(1.5) + } + } + + // MARK: - Helper Methods + private func calculateEstimatedHeight() -> CGFloat { + guard !mediaItems.isEmpty else { return 0 } + + let spacing: CGFloat = 1 + let screenWidth = UIScreen.main.bounds.width - 32 + let videoSize = (screenWidth - (spacing * 2)) / 3 + let numberOfRows = ceil(Double(mediaItems.count) / 3.0) + let totalHeight = (videoSize * CGFloat(numberOfRows)) + (spacing * max(0, CGFloat(numberOfRows - 1))) + + return totalHeight + } + + private func loadVideos(reset: Bool = true) { + if reset { + guard !isLoading else { return } + isLoading = true + mediaItems = [] + hasMore = true + } else { + guard !isLoadingMore && hasMore else { return } + isLoadingMore = true + } + + let count = pageSize + let pagination: ChatPagination = { + if reset { + return .last(count: count) + } else if let lastId = mediaItems.last?.id { + return .before(chatItemId: lastId, count: count) + } else { + return .last(count: count) + } + }() + + Task { + do { + let (chat, _) = try await apiGetChat( + chatId: groupInfo.id, + scope: nil, + contentTag: .video, + pagination: pagination, + search: "" + ) + await MainActor.run { + if reset { + mediaItems = chat.chatItems + isLoading = false + } else { + mediaItems.append(contentsOf: chat.chatItems) + isLoadingMore = false + } + hasMore = chat.chatItems.count == pageSize + } + } catch { + logger.error("GroupVideosTabView.loadVideos error: \(responseError(error))") + await MainActor.run { + if reset { isLoading = false } else { isLoadingMore = false } + } + } + } + } + + private func loadMoreIfNeeded() { + guard !isLoadingMore && hasMore else { return } + guard let lastId = mediaItems.last?.id else { return } + guard lastRequestedBeforeId != lastId else { return } + lastRequestedBeforeId = lastId + loadVideos(reset: false) + } + + private func openVideoPlayer(_ item: ChatItem) { + guard let file = item.file else { + return + } + + guard file.loaded else { + showVideoNotAvailableAlert() + return + } + + selectedVideo = item + isDecrypting = true + showFullScreenVideo = true + + Task { + await decryptAndPlayVideo(file: file) + } + } + + private func decryptAndPlayVideo(file: CIFile) async { + guard let url = getLoadedVideo(file) else { + logger.error("GroupVideosTabView: Failed to get video URL") + dismissVideoPlayer() + return + } + + let decryptedURL = file.fileSource?.cryptoArgs == nil + ? url + : await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete) + + guard let finalURL = decryptedURL else { + logger.error("GroupVideosTabView: Failed to decrypt video") + dismissVideoPlayer() + return + } + + let player = VideoPlayerView.getOrCreatePlayer(finalURL, true) + + await MainActor.run { + videoURL = finalURL + videoPlayer = player + isDecrypting = false + } + } + + @MainActor + private func dismissVideoPlayer() { + showFullScreenVideo = false + isDecrypting = false + cleanupVideoPlayer() + } + + private func cleanupVideoPlayer() { + selectedVideo = nil + videoURL = nil + videoPlayer = nil + } + + private func showVideoNotAvailableAlert() { + AlertManager.shared.showAlertMsg( + title: "Video not available", + message: "This video hasn't been downloaded yet. Please download it from the chat first." + ) + } +} + diff --git a/apps/ios/Shared/Views/Chat/Group/MediaTabs/Videos/VideoPlayerFullScreenWrapper.swift b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Videos/VideoPlayerFullScreenWrapper.swift new file mode 100644 index 0000000000..062629aaf1 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Videos/VideoPlayerFullScreenWrapper.swift @@ -0,0 +1,76 @@ +// +// VideoPlayerFullScreenWrapper.swift +// SimpleX +// +// Created by Suren Poghosyan on 20.01.26. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat +import AVKit + +struct VideoPlayerFullScreenWrapper: View { + + let chatItem: ChatItem + + @Binding var showView: Bool + @State private var videoURL: URL? + @State private var videoPlayer: AVPlayer? + @State private var isDecrypting = true + + var body: some View { + ZStack { + if let videoURL, let videoPlayer, !isDecrypting { + FullScreenMediaView( + chatItem: chatItem, + scrollToItem: nil, + player: videoPlayer, + url: videoURL, + showView: $showView + ) + } else { + Color.black.edgesIgnoringSafeArea(.all) + ProgressView() + .tint(.white) + .scaleEffect(1.5) + } + } + .onAppear { + decryptAndLoadVideo() + } + .onDisappear { + videoPlayer?.pause() + videoPlayer = nil + } + } + + private func decryptAndLoadVideo() { + guard let file = chatItem.file, + let url = getLoadedVideo(file) else { + showView = false + return + } + + Task { + let decryptedURL = file.fileSource?.cryptoArgs == nil + ? url + : await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete) + + guard let finalURL = decryptedURL else { + await MainActor.run { + showView = false + } + return + } + + let player = VideoPlayerView.getOrCreatePlayer(finalURL, true) + + await MainActor.run { + videoURL = finalURL + videoPlayer = player + isDecrypting = false + } + } + } +} diff --git a/apps/ios/Shared/Views/Chat/Group/MediaTabs/Videos/VideoThumbnailView.swift b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Videos/VideoThumbnailView.swift new file mode 100644 index 0000000000..545ff11c6f --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Videos/VideoThumbnailView.swift @@ -0,0 +1,77 @@ +// +// VideoThumbnailView.swift +// SimpleX +// +// Created by Suren Poghosyan on 20.01.26. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat +import AVKit + +struct VideoThumbnailView: View { + + // MARK: - Properties + let chatItem: ChatItem + let file: CIFile + let size: CGFloat + + // MARK: - Environment + @EnvironmentObject var theme: AppTheme + + // MARK: - State + @State private var thumbnail: UIImage? + + // MARK: - Body + var body: some View { + ZStack { + thumbnailImage + playIcon + } + .frame(width: size, height: size) + .contentShape(Rectangle()) + .onAppear(perform: loadThumbnail) + } + + // MARK: - Subviews + @ViewBuilder + private var thumbnailImage: some View { + if let thumbnail { + Image(uiImage: thumbnail) + .resizable() + .scaledToFill() + .frame(width: size, height: size) + .clipped() + } else { + placeholderView + } + } + + private var placeholderView: some View { + Rectangle() + .fill(Color(.systemGray5)) + .frame(width: size, height: size) + .overlay { + Image(systemName: "video.fill") + .font(.system(size: 32)) + .foregroundColor(.gray) + } + } + + private var playIcon: some View { + Image(systemName: "play.circle.fill") + .font(.system(size: 40)) + .foregroundColor(.white) + .shadow(radius: 3) + } + + // MARK: - Helper Methods + private func loadThumbnail() { + guard thumbnail == nil, + case let .video(_, image, _) = chatItem.content.msgContent else { + return + } + thumbnail = imageFromBase64(image) + } +} diff --git a/apps/ios/Shared/Views/Chat/Group/MediaTabs/Voices/GroupVoicesTabView.swift b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Voices/GroupVoicesTabView.swift new file mode 100644 index 0000000000..7e44b38ac8 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Voices/GroupVoicesTabView.swift @@ -0,0 +1,135 @@ +// +// GroupVoicesTabView.swift +// SimpleX (iOS) +// +// Created by Suren Poghosyan on 14.01.26. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct GroupVoicesTabView: View { + @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + + @State private var mediaItems: [ChatItem] = [] + @State private var isLoading = false + @State private var isLoadingMore = false + @State private var hasMore = true + + private let pageSize: Int = 100 + let groupInfo: GroupInfo + + var body: some View { + Group { + if isLoading { + loadingView + } else if mediaItems.isEmpty { + emptyView + } else { + voiceListView + } + } + .onAppear { + loadVoices() + } + } + + private var loadingView: some View { + HStack { + Spacer() + ProgressView() + .padding(.vertical, 40) + Spacer() + } + } + + private var emptyView: some View { + HStack { + Spacer() + VStack(spacing: 12) { + Image(systemName: "waveform") + .font(.system(size: 48)) + .foregroundColor(theme.colors.secondary) + Text("No voice messages") + .foregroundColor(theme.colors.secondary) + } + .padding(.vertical, 60) + Spacer() + } + } + + private var voiceListView: some View { + LazyVStack(spacing: 0) { + ForEach(Array(mediaItems.enumerated()), id: \.element.id) { index, item in + VoiceListRowView( + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: nil), chatItems: []), + chatItem: item + ) + .onAppear { + if index == mediaItems.count - 1 { + loadMoreIfNeeded() + } + } + } + if isLoadingMore { + HStack { Spacer(); ProgressView().padding(); Spacer() } + } + } + } + + private func loadVoices(reset: Bool = true) { + if reset { + guard !isLoading else { return } + isLoading = true + mediaItems = [] + hasMore = true + } else { + guard !isLoadingMore && hasMore else { return } + isLoadingMore = true + } + + let count = pageSize + let pagination: ChatPagination = { + if reset { + return .last(count: count) + } else if let lastId = mediaItems.last?.id { + return .before(chatItemId: lastId, count: count) + } else { + return .last(count: count) + } + }() + + Task { + do { + let (chat, _) = try await apiGetChat( + chatId: groupInfo.id, + scope: nil, + contentTag: .voice, + pagination: pagination, + search: "" + ) + await MainActor.run { + if reset { + mediaItems = chat.chatItems + isLoading = false + } else { + mediaItems.append(contentsOf: chat.chatItems) + isLoadingMore = false + } + hasMore = chat.chatItems.count == pageSize + } + } catch { + logger.error("loadVoices error: \(responseError(error))") + await MainActor.run { + if reset { isLoading = false } else { isLoadingMore = false } + } + } + } + } + + private func loadMoreIfNeeded() { + loadVoices(reset: false) + } +} diff --git a/apps/ios/Shared/Views/Chat/Group/MediaTabs/Voices/VoiceListRowView.swift b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Voices/VoiceListRowView.swift new file mode 100644 index 0000000000..6869646a27 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Voices/VoiceListRowView.swift @@ -0,0 +1,75 @@ +// +// VoiceListRowView.swift +// SimpleX +// +// Created by Suren Poghosyan on 20.01.26. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct VoiceListRowView: View { + @EnvironmentObject var theme: AppTheme + @ObservedObject var chat: Chat + + var chatItem: ChatItem + + var body: some View { + HStack(spacing: 12) { + if let recordingFile = chatItem.file, let duration = voiceDuration { + VoicePlayerCell( + chat: chat, + chatItem: chatItem, + recordingFile: recordingFile, + duration: duration + ) + } else { + ZStack { + Circle() + .fill(chatItemFrameColor(chatItem, theme)) + .frame(width: 40, height: 40) + + Image(systemName: "waveform") + .font(.system(size: 20)) + .foregroundColor(theme.colors.primary) + } + } + + VStack(alignment: .leading, spacing: 4) { + Text("Voice Message") + .font(.body) + .foregroundColor(theme.colors.onBackground) + .lineLimit(1) + + HStack(spacing: 8) { + Text(formatTimestampChatInfo(chatItem.meta.itemTs)) + .font(.caption) + .foregroundColor(theme.colors.secondary) + + if let duration = voiceDuration { + Text("• \(formatDuration(duration))") + .font(.caption) + .foregroundColor(theme.colors.secondary) + } + } + } + + Spacer() + } + .padding(.vertical, 8) + } + + private var voiceDuration: Int? { + if case let .voice(_, duration) = chatItem.content.msgContent { + return duration + } + return nil + } + + private func formatDuration(_ seconds: Int) -> String { + let mins = seconds / 60 + let secs = seconds % 60 + return String(format: "%d:%02d", mins, secs) + } +} diff --git a/apps/ios/Shared/Views/Chat/Group/MediaTabs/Voices/VoicePlayerCell.swift b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Voices/VoicePlayerCell.swift new file mode 100644 index 0000000000..f64f3fca19 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MediaTabs/Voices/VoicePlayerCell.swift @@ -0,0 +1,48 @@ +// +// VoicePlayerCell.swift +// SimpleX +// +// Created by Suren Poghosyan on 20.01.26. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct VoicePlayerCell: View { + @EnvironmentObject var theme: AppTheme + @ObservedObject var chat: Chat + + @State var audioPlayer: AudioPlayer? = nil + @State var playbackState: VoiceMessagePlaybackState = .noPlayback + @State var playbackTime: TimeInterval? = nil + @State var allowMenu: Bool = true + @State private var seek: (TimeInterval) -> Void = { _ in } + + var chatItem: ChatItem + var recordingFile: CIFile + var duration: Int + + var body: some View { + ZStack { + Circle() + .fill(chatItemFrameColor(chatItem, theme)) + .frame(width: 40, height: 40) + + VoiceMessagePlayer( + chat: chat, + chatItem: chatItem, + recordingFile: recordingFile, + recordingTime: TimeInterval(duration), + showBackground: false, + seek: $seek, + audioPlayer: $audioPlayer, + playbackState: $playbackState, + playbackTime: $playbackTime, + allowMenu: $allowMenu, + sizeMultiplier: 0.71 + ) + } + .id("\(chatItem.id)_\(playbackTime ?? 0)") + } +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index fd037f6b38..6fddf8a4f9 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -16,6 +16,18 @@ 18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415323A4082FC92887F906 /* WebRTCClient.swift */; }; 18415F9A2D551F9757DA4654 /* CIVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415FD2E36F13F596A45BB4 /* CIVideoView.swift */; }; 18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1841516F0CE5992B0EDFB377 /* GroupWelcomeView.swift */; }; + 338D334A2F1F08F100F96964 /* GroupImagesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 338D33482F1F08F100F96964 /* GroupImagesTabView.swift */; }; + 338D334B2F1F08F100F96964 /* ImageThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 338D33492F1F08F100F96964 /* ImageThumbnailView.swift */; }; + 338D334F2F1F092100F96964 /* GroupVideosTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 338D334C2F1F092100F96964 /* GroupVideosTabView.swift */; }; + 338D33502F1F092100F96964 /* VideoPlayerFullScreenWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 338D334D2F1F092100F96964 /* VideoPlayerFullScreenWrapper.swift */; }; + 338D33512F1F092100F96964 /* VideoThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 338D334E2F1F092100F96964 /* VideoThumbnailView.swift */; }; + 338D33542F1F093100F96964 /* GroupFilesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 338D33532F1F093100F96964 /* GroupFilesTabView.swift */; }; + 338D33552F1F093100F96964 /* FileListRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 338D33522F1F093100F96964 /* FileListRowView.swift */; }; + 338D33582F1F094100F96964 /* GroupLinksTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 338D33562F1F094100F96964 /* GroupLinksTabView.swift */; }; + 338D33592F1F094100F96964 /* LinkListRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 338D33572F1F094100F96964 /* LinkListRowView.swift */; }; + 338D335D2F1F094F00F96964 /* VoiceListRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 338D335B2F1F094F00F96964 /* VoiceListRowView.swift */; }; + 338D335E2F1F094F00F96964 /* GroupVoicesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 338D335A2F1F094F00F96964 /* GroupVoicesTabView.swift */; }; + 338D335F2F1F094F00F96964 /* VoicePlayerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 338D335C2F1F094F00F96964 /* VoicePlayerCell.swift */; }; 33AC96122F15068300C672B9 /* GroupSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33AC96112F15068200C672B9 /* GroupSettingsView.swift */; }; 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; }; @@ -336,6 +348,18 @@ 18415B08031E8FB0F7FC27F9 /* CallViewRenderers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallViewRenderers.swift; sourceTree = ""; }; 18415DAAAD1ADBEDB0EDA852 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; 18415FD2E36F13F596A45BB4 /* CIVideoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CIVideoView.swift; sourceTree = ""; }; + 338D33482F1F08F100F96964 /* GroupImagesTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupImagesTabView.swift; sourceTree = ""; }; + 338D33492F1F08F100F96964 /* ImageThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageThumbnailView.swift; sourceTree = ""; }; + 338D334C2F1F092100F96964 /* GroupVideosTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupVideosTabView.swift; sourceTree = ""; }; + 338D334D2F1F092100F96964 /* VideoPlayerFullScreenWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerFullScreenWrapper.swift; sourceTree = ""; }; + 338D334E2F1F092100F96964 /* VideoThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoThumbnailView.swift; sourceTree = ""; }; + 338D33522F1F093100F96964 /* FileListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileListRowView.swift; sourceTree = ""; }; + 338D33532F1F093100F96964 /* GroupFilesTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupFilesTabView.swift; sourceTree = ""; }; + 338D33562F1F094100F96964 /* GroupLinksTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupLinksTabView.swift; sourceTree = ""; }; + 338D33572F1F094100F96964 /* LinkListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkListRowView.swift; sourceTree = ""; }; + 338D335A2F1F094F00F96964 /* GroupVoicesTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupVoicesTabView.swift; sourceTree = ""; }; + 338D335B2F1F094F00F96964 /* VoiceListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceListRowView.swift; sourceTree = ""; }; + 338D335C2F1F094F00F96964 /* VoicePlayerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoicePlayerCell.swift; sourceTree = ""; }; 33AC96112F15068200C672B9 /* GroupSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupSettingsView.swift; sourceTree = ""; }; 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = ""; }; 3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = ""; }; @@ -727,6 +751,65 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 338D33422F1F08BA00F96964 /* MediaTabs */ = { + isa = PBXGroup; + children = ( + 338D33432F1F08C000F96964 /* Images */, + 338D33442F1F08C700F96964 /* Videos */, + 338D33452F1F08CE00F96964 /* Files */, + 338D33462F1F08D500F96964 /* Links */, + 338D33472F1F08DC00F96964 /* Voices */, + ); + path = MediaTabs; + sourceTree = ""; + }; + 338D33432F1F08C000F96964 /* Images */ = { + isa = PBXGroup; + children = ( + 338D33482F1F08F100F96964 /* GroupImagesTabView.swift */, + 338D33492F1F08F100F96964 /* ImageThumbnailView.swift */, + ); + path = Images; + sourceTree = ""; + }; + 338D33442F1F08C700F96964 /* Videos */ = { + isa = PBXGroup; + children = ( + 338D334C2F1F092100F96964 /* GroupVideosTabView.swift */, + 338D334D2F1F092100F96964 /* VideoPlayerFullScreenWrapper.swift */, + 338D334E2F1F092100F96964 /* VideoThumbnailView.swift */, + ); + path = Videos; + sourceTree = ""; + }; + 338D33452F1F08CE00F96964 /* Files */ = { + isa = PBXGroup; + children = ( + 338D33522F1F093100F96964 /* FileListRowView.swift */, + 338D33532F1F093100F96964 /* GroupFilesTabView.swift */, + ); + path = Files; + sourceTree = ""; + }; + 338D33462F1F08D500F96964 /* Links */ = { + isa = PBXGroup; + children = ( + 338D33562F1F094100F96964 /* GroupLinksTabView.swift */, + 338D33572F1F094100F96964 /* LinkListRowView.swift */, + ); + path = Links; + sourceTree = ""; + }; + 338D33472F1F08DC00F96964 /* Voices */ = { + isa = PBXGroup; + children = ( + 338D335A2F1F094F00F96964 /* GroupVoicesTabView.swift */, + 338D335B2F1F094F00F96964 /* VoiceListRowView.swift */, + 338D335C2F1F094F00F96964 /* VoicePlayerCell.swift */, + ); + path = Voices; + sourceTree = ""; + }; 3C714775281C080100CB4D4B /* Call */ = { isa = PBXGroup; children = ( @@ -1131,6 +1214,7 @@ 6440CA01288AEC770062C672 /* Group */ = { isa = PBXGroup; children = ( + 338D33422F1F08BA00F96964 /* MediaTabs */, 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */, 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */, 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */, @@ -1472,8 +1556,12 @@ 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */, 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */, 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, + 338D33582F1F094100F96964 /* GroupLinksTabView.swift in Sources */, + 338D33592F1F094100F96964 /* LinkListRowView.swift in Sources */, 644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */, 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */, + 338D33542F1F093100F96964 /* GroupFilesTabView.swift in Sources */, + 338D33552F1F093100F96964 /* FileListRowView.swift in Sources */, 8CC317462D4FEBA800292A20 /* ScrollViewCells.swift in Sources */, 5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */, 5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */, @@ -1530,7 +1618,12 @@ 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */, 5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */, 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */, + 338D334A2F1F08F100F96964 /* GroupImagesTabView.swift in Sources */, + 338D334B2F1F08F100F96964 /* ImageThumbnailView.swift in Sources */, 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */, + 338D334F2F1F092100F96964 /* GroupVideosTabView.swift in Sources */, + 338D33502F1F092100F96964 /* VideoPlayerFullScreenWrapper.swift in Sources */, + 338D33512F1F092100F96964 /* VideoThumbnailView.swift in Sources */, 64FC8F9D2E3B6DEF0068F384 /* ContextMemberContactActionsView.swift in Sources */, 5C05DF532840AA1D00C683F9 /* CallSettings.swift in Sources */, 640417CD2B29B8C200CCB412 /* NewChatMenuButton.swift in Sources */, @@ -1568,6 +1661,9 @@ 5C6BA667289BD954009B8ECC /* DismissSheets.swift in Sources */, 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */, 6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */, + 338D335D2F1F094F00F96964 /* VoiceListRowView.swift in Sources */, + 338D335E2F1F094F00F96964 /* GroupVoicesTabView.swift in Sources */, + 338D335F2F1F094F00F96964 /* VoicePlayerCell.swift in Sources */, 33AC96122F15068300C672B9 /* GroupSettingsView.swift in Sources */, 8CAD466F2D15A8100078D18F /* ChatScrollHelpers.swift in Sources */, 644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index e1bf8614e2..e0ce8c187c 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3542,6 +3542,28 @@ public func formatTimestampText(_ date: Date) -> Text { )) } +public func formatTimestampChatInfo(_ date: Date) -> String { + let calendar = Calendar.current + let now = Date() + + if calendar.isDateInToday(date) { + let formatter = DateFormatter() + formatter.dateFormat = "hh:mm a" + return "Today, \(formatter.string(from: date))" + } + + let currentYear = calendar.component(.year, from: now) + let dateYear = calendar.component(.year, from: date) + + let formatter = DateFormatter() + if currentYear == dateYear { + formatter.dateFormat = "d MMM, hh:mm a" + } else { + formatter.dateFormat = "d MMM, yyyy hh:mm a" + } + return formatter.string(from: date) +} + public func formatTimestampMeta(_ date: Date) -> String { date.formatted(date: .omitted, time: .shortened) }