Added group chat info page media tab components UI and logics

This commit is contained in:
Suren Poghosyan
2026-01-20 05:06:42 +04:00
parent 2e4720fdf4
commit a37de49fff
15 changed files with 1565 additions and 20 deletions
@@ -36,6 +36,8 @@ struct GroupChatInfoView: View {
@FocusState private var searchFocussed
@State private var showSecrets: Set<Int> = []
@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
@@ -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)
}
}
@@ -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
}
}
}
@@ -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)
}
}
@@ -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
}
}
}
@@ -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)
}
}
@@ -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
}
}
}
}
}
}
}
@@ -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."
)
}
}
@@ -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
}
}
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)")
}
}
@@ -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 = "<group>"; };
18415DAAAD1ADBEDB0EDA852 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = "<group>"; };
18415FD2E36F13F596A45BB4 /* CIVideoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CIVideoView.swift; sourceTree = "<group>"; };
338D33482F1F08F100F96964 /* GroupImagesTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupImagesTabView.swift; sourceTree = "<group>"; };
338D33492F1F08F100F96964 /* ImageThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageThumbnailView.swift; sourceTree = "<group>"; };
338D334C2F1F092100F96964 /* GroupVideosTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupVideosTabView.swift; sourceTree = "<group>"; };
338D334D2F1F092100F96964 /* VideoPlayerFullScreenWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerFullScreenWrapper.swift; sourceTree = "<group>"; };
338D334E2F1F092100F96964 /* VideoThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoThumbnailView.swift; sourceTree = "<group>"; };
338D33522F1F093100F96964 /* FileListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileListRowView.swift; sourceTree = "<group>"; };
338D33532F1F093100F96964 /* GroupFilesTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupFilesTabView.swift; sourceTree = "<group>"; };
338D33562F1F094100F96964 /* GroupLinksTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupLinksTabView.swift; sourceTree = "<group>"; };
338D33572F1F094100F96964 /* LinkListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkListRowView.swift; sourceTree = "<group>"; };
338D335A2F1F094F00F96964 /* GroupVoicesTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupVoicesTabView.swift; sourceTree = "<group>"; };
338D335B2F1F094F00F96964 /* VoiceListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceListRowView.swift; sourceTree = "<group>"; };
338D335C2F1F094F00F96964 /* VoicePlayerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoicePlayerCell.swift; sourceTree = "<group>"; };
33AC96112F15068200C672B9 /* GroupSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupSettingsView.swift; sourceTree = "<group>"; };
3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = "<group>"; };
3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = "<group>"; };
@@ -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 = "<group>";
};
338D33432F1F08C000F96964 /* Images */ = {
isa = PBXGroup;
children = (
338D33482F1F08F100F96964 /* GroupImagesTabView.swift */,
338D33492F1F08F100F96964 /* ImageThumbnailView.swift */,
);
path = Images;
sourceTree = "<group>";
};
338D33442F1F08C700F96964 /* Videos */ = {
isa = PBXGroup;
children = (
338D334C2F1F092100F96964 /* GroupVideosTabView.swift */,
338D334D2F1F092100F96964 /* VideoPlayerFullScreenWrapper.swift */,
338D334E2F1F092100F96964 /* VideoThumbnailView.swift */,
);
path = Videos;
sourceTree = "<group>";
};
338D33452F1F08CE00F96964 /* Files */ = {
isa = PBXGroup;
children = (
338D33522F1F093100F96964 /* FileListRowView.swift */,
338D33532F1F093100F96964 /* GroupFilesTabView.swift */,
);
path = Files;
sourceTree = "<group>";
};
338D33462F1F08D500F96964 /* Links */ = {
isa = PBXGroup;
children = (
338D33562F1F094100F96964 /* GroupLinksTabView.swift */,
338D33572F1F094100F96964 /* LinkListRowView.swift */,
);
path = Links;
sourceTree = "<group>";
};
338D33472F1F08DC00F96964 /* Voices */ = {
isa = PBXGroup;
children = (
338D335A2F1F094F00F96964 /* GroupVoicesTabView.swift */,
338D335B2F1F094F00F96964 /* VoiceListRowView.swift */,
338D335C2F1F094F00F96964 /* VoicePlayerCell.swift */,
);
path = Voices;
sourceTree = "<group>";
};
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 */,
+22
View File
@@ -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)
}