mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-19 02:56:43 +00:00
Added group chat info page media tab components UI and logics
This commit is contained in:
@@ -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 */,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user