ios: interactive media and link previews in the list of chats

This commit is contained in:
Avently
2024-07-19 08:03:57 -07:00
parent 95776e0951
commit ae1365411e
8 changed files with 323 additions and 107 deletions
@@ -9,6 +9,7 @@ import SwiftUI
class AnimatedImageView: UIView {
var image: UIImage? = nil
var imageView: UIImageView? = nil
var cMode: UIView.ContentMode = .scaleAspectFit
override init(frame: CGRect) {
super.init(frame: frame)
@@ -18,11 +19,12 @@ class AnimatedImageView: UIView {
fatalError("Not implemented")
}
convenience init(image: UIImage) {
convenience init(image: UIImage, contentMode: UIView.ContentMode) {
self.init()
self.image = image
self.cMode = contentMode
imageView = UIImageView(gifImage: image)
imageView!.contentMode = .scaleAspectFit
imageView!.contentMode = contentMode
self.addSubview(imageView!)
}
@@ -35,7 +37,7 @@ class AnimatedImageView: UIView {
if let subview = self.subviews.first as? UIImageView {
if image.imageData != subview.gifImage?.imageData {
imageView = UIImageView(gifImage: image)
imageView!.contentMode = .scaleAspectFit
imageView!.contentMode = contentMode
self.addSubview(imageView!)
subview.removeFromSuperview()
}
@@ -47,13 +49,15 @@ class AnimatedImageView: UIView {
struct SwiftyGif: UIViewRepresentable {
private let image: UIImage
private let contentMode: UIView.ContentMode
init(image: UIImage) {
init(image: UIImage, contentMode: UIView.ContentMode = .scaleAspectFit) {
self.image = image
self.contentMode = contentMode
}
func makeUIView(context: Context) -> AnimatedImageView {
AnimatedImageView(image: image)
AnimatedImageView(image: image, contentMode: contentMode)
}
func updateUIView(_ imageView: AnimatedImageView, context: Context) {
@@ -14,39 +14,45 @@ struct CIFileView: View {
@EnvironmentObject var theme: AppTheme
let file: CIFile?
let edited: Bool
var smallView: Bool = false
var body: some View {
let metaReserve = edited
? " "
: " "
Button(action: fileAction) {
HStack(alignment: .bottom, spacing: 6) {
fileIndicator()
.padding(.top, 5)
.padding(.bottom, 3)
if let file = file {
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
VStack(alignment: .leading, spacing: 2) {
Text(file.fileName)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.onBackground)
Text(prettyFileSize + metaReserve)
.font(.caption)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.secondary)
if smallView {
fileIndicator()
.onTapGesture(perform: fileAction)
} else {
let metaReserve = edited
? " "
: " "
Button(action: fileAction) {
HStack(alignment: .bottom, spacing: 6) {
fileIndicator()
.padding(.top, 5)
.padding(.bottom, 3)
if let file = file {
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
VStack(alignment: .leading, spacing: 2) {
Text(file.fileName)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.onBackground)
Text(prettyFileSize + metaReserve)
.font(.caption)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.secondary)
}
} else {
Text(metaReserve)
}
} else {
Text(metaReserve)
}
.padding(.top, 4)
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
}
.padding(.top, 4)
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
.disabled(!itemInteractive)
}
.disabled(!itemInteractive)
}
private var itemInteractive: Bool {
@@ -199,17 +205,17 @@ struct CIFileView: View {
Image(systemName: icon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30, height: 30)
.frame(width: smallView ? 36 : 30, height: smallView ? 36 : 30)
.foregroundColor(color)
if let innerIcon = innerIcon,
let innerIconSize = innerIconSize {
let innerIconSize = innerIconSize, (!smallView || file?.showStatusIconInSmallView == true) {
Image(systemName: innerIcon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxHeight: 16)
.frame(width: innerIconSize, height: innerIconSize)
.foregroundColor(.white)
.padding(.top, 12)
.padding(.top, smallView ? 15 : 12)
}
}
}
@@ -15,13 +15,14 @@ struct CIImageView: View {
var preview: UIImage?
let maxWidth: CGFloat
var imgWidth: CGFloat?
var smallView: Bool = false
@State private var showFullScreenImage = false
var body: some View {
let file = chatItem.file
VStack(alignment: .center, spacing: 6) {
if let uiImage = getLoadedImage(file) {
imageView(uiImage)
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } }
.fullScreenCover(isPresented: $showFullScreenImage) {
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage)
}
@@ -30,7 +31,7 @@ struct CIImageView: View {
showFullScreenImage = false
}
} else if let preview {
imageView(preview)
Group { if smallView { smallViewImageView(preview) } else { imageView(preview) } }
.onTapGesture {
if let file = file {
switch file.fileStatus {
@@ -102,6 +103,23 @@ struct CIImageView: View {
}
}
private func smallViewImageView(_ img: UIImage) -> some View {
ZStack(alignment: .topTrailing) {
if img.imageData == nil {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: maxWidth, height: maxWidth)
} else {
SwiftyGif(image: img, contentMode: .scaleAspectFill)
.frame(width: maxWidth, height: maxWidth)
}
if chatItem.file?.showStatusIconInSmallView == true {
loadingIndicator()
}
}
}
@ViewBuilder private func loadingIndicator() -> some View {
if let file = chatItem.file {
switch file.fileStatus {
@@ -20,6 +20,7 @@ struct CIVideoView: View {
@State private var videoPlaying: Bool = false
private let maxWidth: CGFloat
private var videoWidth: CGFloat?
private let smallView: Bool
@State private var player: AVPlayer?
@State private var fullPlayer: AVPlayer?
@State private var url: URL?
@@ -29,13 +30,15 @@ struct CIVideoView: View {
@State private var timeObserver: Any? = nil
@State private var fullScreenTimeObserver: Any? = nil
@State private var publisher: AnyCancellable? = nil
private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 }
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?) {
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false) {
self.chatItem = chatItem
self.preview = preview
self._duration = State(initialValue: duration)
self.maxWidth = maxWidth
self.videoWidth = videoWidth
self.smallView = smallView
if let url = getLoadedVideo(chatItem.file) {
let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet()
self._urlDecrypted = State(initialValue: decrypted)
@@ -49,16 +52,19 @@ struct CIVideoView: View {
var body: some View {
let file = chatItem.file
ZStack {
ZStack(alignment: smallView ? .topLeading : .center) {
ZStack(alignment: .topLeading) {
if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
if let file = file, let preview = preview, let decrypted = urlDecrypted, smallView {
smallVideoView(decrypted, file, preview)
} else if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
videoView(player, decrypted, file, preview, duration)
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil, smallView {
smallVideoViewEncrypted(file, defaultPreview)
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
videoViewEncrypted(file, defaultPreview, duration)
} else if let preview {
imageView(preview)
.onTapGesture {
if let file = file {
} else if let preview, let file {
Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
.onTapGesture {
switch file.fileStatus {
case .rcvInvitation, .rcvAborted:
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
@@ -82,16 +88,22 @@ struct CIVideoView: View {
default: ()
}
}
}
}
durationProgress()
if !smallView {
durationProgress()
}
}
if let file = file, showDownloadButton(file.fileStatus) {
if !smallView, let file = file, showDownloadButton(file.fileStatus) {
Button {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: {
playPauseIcon("play.fill")
}
} else if let file = file, showDownloadButton(file.fileStatus) && !file.showStatusIconInSmallView {
playPauseIcon("play.fill")
.onTapGesture {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
}
}
}
}
@@ -194,14 +206,63 @@ struct CIVideoView: View {
}
}
private func smallVideoViewEncrypted(_ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .center) {
ZStack(alignment: .topLeading) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
smallViewImageView(preview, file)
.onTapGesture {
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
}
}
if file.showStatusIconInSmallView {
// Show nothing
} else if !decryptionInProgress {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
} else {
videoDecryptionProgress()
}
}
if file.showStatusIconInSmallView {
fileStatusIcon()
}
}
}
private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .center) {
ZStack(alignment: .topLeading) {
smallViewImageView(preview, file)
.fullScreenCover(isPresented: $showFullScreenPlayer) {
fullScreenPlayer(url)
}
.onTapGesture {
showFullScreenPlayer = true
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !file.showStatusIconInSmallView {
playPauseIcon("play.fill")
}
}
if file.showStatusIconInSmallView {
fileStatusIcon()
}
}
}
private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 12, height: 12)
.frame(width: smallView ? 12 * sizeMultiplier * 1.6 : 12, height: smallView ? 12 * sizeMultiplier * 1.6 : 12)
.foregroundColor(color)
.padding(.leading, 4)
.frame(width: 40, height: 40)
.padding(.leading, smallView ? 0 : 4)
.frame(width: 40 * sizeMultiplier, height: 40 * sizeMultiplier)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
}
@@ -209,9 +270,9 @@ struct CIVideoView: View {
private func videoDecryptionProgress(_ color: Color = .white) -> some View {
ProgressView()
.progressViewStyle(.circular)
.frame(width: 12, height: 12)
.frame(width: smallView ? 12 * sizeMultiplier : 12, height: smallView ? 12 * sizeMultiplier : 12)
.tint(color)
.frame(width: 40, height: 40)
.frame(width: smallView ? 40 * sizeMultiplier * 0.9 : 40, height: smallView ? 40 * sizeMultiplier * 0.9 : 40)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
}
@@ -251,6 +312,15 @@ struct CIVideoView: View {
}
}
private func smallViewImageView(_ img: UIImage, _ file: CIFile) -> some View {
ZStack(alignment: .topTrailing) {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: maxWidth, height: maxWidth)
}
}
@ViewBuilder private func fileStatusIcon() -> some View {
if let file = chatItem.file {
switch file.fileStatus {
@@ -322,7 +392,7 @@ struct CIVideoView: View {
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
.foregroundColor(.white)
.padding(padding)
.padding(smallView ? 0 : padding)
}
private func progressView() -> some View {
@@ -330,7 +400,7 @@ struct CIVideoView: View {
.progressViewStyle(.circular)
.frame(width: 16, height: 16)
.tint(.white)
.padding(11)
.padding(smallView ? 0 : 11)
}
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
@@ -342,7 +412,7 @@ struct CIVideoView: View {
)
.rotationEffect(.degrees(-90))
.frame(width: 16, height: 16)
.padding([.trailing, .top], 11)
.padding([.trailing, .top], smallView ? 0 : 11)
}
// TODO encrypt: where file size is checked?
@@ -19,11 +19,23 @@ struct CIVoiceView: View {
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@Binding var allowMenu: Bool
var smallView: Bool = false
@State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View {
Group {
if chatItem.chatDir.sent {
if smallView {
VStack (alignment: .leading, spacing: 6) {
HStack {
player()
playerTime()
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
playbackSlider()
}
}
.frame(alignment: .leading)
}
} else if chatItem.chatDir.sent {
VStack (alignment: .trailing, spacing: 6) {
HStack {
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
@@ -317,6 +329,11 @@ struct VoiceMessagePlayer: View {
}
}
func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat {
let squareToCircleRatio = 0.935
return squareSize + squareSize * (1 - squareToCircleRatio)
}
struct CIVoiceView_Previews: PreviewProvider {
static var previews: some View {
let sentVoiceMessage: ChatItem = ChatItem(
@@ -16,6 +16,11 @@ struct ChatPreviewView: View {
@Binding var progressByTimeout: Bool
@State var deleting: Bool = false
var darkGreen = Color(red: 0, green: 0.5, blue: 0)
@State private var activeVoicePreview: ActiveVoicePreview? = nil
@State var audioPlayer: AudioPlayer? = nil
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval? = nil
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
@@ -43,7 +48,28 @@ struct ChatPreviewView: View {
.padding(.horizontal, 8)
ZStack(alignment: .topTrailing) {
chatMessagePreview(cItem)
let chat = activeVoicePreview?.chat ?? chat
let ci = activeVoicePreview?.ci ?? chat.chatItems.last
let mc = ci?.content.msgContent
HStack {
if let ci, (showChatPreviews && chatModel.draftChatId != chat.id) || activeVoicePreview != nil {
chatItemContentPreview(chat, ci)
}
let mcIsVoice = switch mc { case .voice: true; default: false }
if !mcIsVoice || mc?.text != "" || chatModel.draftChatId == chat.id {
let xOffset: CGFloat = if case .file = mc { -10 } else { 0 }
ZStack{
chatMessagePreview(cItem)
}
.offset(x: xOffset)
}
}
.onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in
checkActiveVoice(chat, ci, mc)
}
.onChange(of: activeVoicePreview) { _ in
checkActiveVoice(chat, ci, mc)
}
chatStatusImage()
.padding(.top, 26)
.frame(maxWidth: .infinity, alignment: .trailing)
@@ -58,6 +84,21 @@ struct ChatPreviewView: View {
.onChange(of: chatModel.deletedChats.contains(chat.chatInfo.id)) { contains in
deleting = contains
}
func checkActiveVoice(_ chat: Chat, _ ci: ChatItem?, _ mc: MsgContent?) {
let playing = chatModel.stopPreviousRecPlay
if playing == nil {
activeVoicePreview = nil
} else if activeVoicePreview == nil {
if let ci, let mc, let fileSource = ci.file?.fileSource, case .voice = mc, playing?.path.hasSuffix(fileSource.filePath) == true {
activeVoicePreview = ActiveVoicePreview(chat: chat, ci: ci, mc: mc)
}
} else {
if let playing, let fileSource = ci?.file?.fileSource, !playing.path.hasSuffix(fileSource.filePath) {
activeVoicePreview = nil
}
}
}
}
@ViewBuilder private func chatPreviewImageOverlayIcon() -> some View {
@@ -172,7 +213,7 @@ struct ChatPreviewView: View {
func chatItemPreview(_ cItem: ChatItem) -> Text {
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
// same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey;
// can be refactored into a single function if functions calling these are changed to return same type
@@ -225,6 +266,53 @@ struct ChatPreviewView: View {
}
}
@ViewBuilder func chatItemContentPreview(_ chat: Chat, _ ci: ChatItem) -> some View {
let mc = ci.content.msgContent
switch mc {
case let .link(_, preview):
smallContentPreview(
ZStack(alignment: .topTrailing) {
Image(uiImage: UIImage(base64Encoded: preview.image) ?? UIImage(systemName: "arrow.up.right")!)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 36, height: 36)
ZStack {
Image(systemName: "arrow.up.right")
.resizable()
.colorMultiply(Color.white)
.frame(width: 8, height: 8)
}
.frame(width: 16, height: 16)
.background(Color.black.opacity(0.25))
.cornerRadius(8)
}
.onTapGesture {
UIApplication.shared.open(preview.uri)
}
)
case let .image(_, image):
smallContentPreview(
CIImageView(chatItem: ci, preview: UIImage(base64Encoded: image), maxWidth: 36, smallView: true)
.environmentObject(ReverseListScrollModel<ChatItem>())
)
case let .video(_,image, duration):
smallContentPreview(
CIVideoView(chatItem: ci, preview: UIImage(base64Encoded: image), duration: duration, maxWidth: 36, videoWidth: nil, smallView: true)
.environmentObject(ReverseListScrollModel<ChatItem>())
)
case let .voice(_, duration):
smallContentPreviewVoice(
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: Binding.constant(true), smallView: true)
)
case .file:
smallContentPreviewFile(
CIFileView(file: ci.file, edited: ci.meta.itemEdited, smallView: true)
)
default: EmptyView()
}
}
@ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View {
groupInfo.membership.memberIncognito
? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)")
@@ -294,10 +382,50 @@ struct ChatPreviewView: View {
}
}
func smallContentPreview(_ view: some View) -> some View {
ZStack {
view
.frame(width: 36, height: 36)
}
.cornerRadius(8)
.overlay(RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true))
.padding(.leading, 3)
.offset(x: 6)
}
func smallContentPreviewVoice(_ view: some View) -> some View {
ZStack {
view
.frame(height: voiceMessageSizeBasedOnSquareSize(36))
}
.padding(.leading, 3)
.offset(x: 6)
}
func smallContentPreviewFile(_ view: some View) -> some View {
ZStack {
view
.frame(width: 36, height: 36)
}
.padding(.leading, 5)
.offset(y: -1)
}
func unreadCountText(_ n: Int) -> Text {
Text(n > 999 ? "\(n / 1000)k" : n > 0 ? "\(n)" : "")
}
private struct ActiveVoicePreview: Equatable {
var chat: Chat
var ci: ChatItem
var mc: MsgContent
static func == (lhs: ActiveVoicePreview, rhs: ActiveVoicePreview) -> Bool {
lhs.chat.id == rhs.chat.id && lhs.ci.id == rhs.ci.id && lhs.mc == rhs.mc
}
}
struct ChatPreviewView_Previews: PreviewProvider {
static var previews: some View {
Group {
@@ -1,49 +0,0 @@
{
"originHash" : "e2611d1e91fd8071abc106776ba14ee2e395d2ad08a78e073381294abc10f115",
"pins" : [
{
"identity" : "codescanner",
"kind" : "remoteSourceControl",
"location" : "https://github.com/twostraws/CodeScanner",
"state" : {
"revision" : "c27a66149b7483fe42e2ec6aad61d5c3fffe522d",
"version" : "2.1.1"
}
},
{
"identity" : "lzstring-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Ibrahimhass/lzstring-swift",
"state" : {
"revision" : "7f62f21de5b18582a950e1753b775cc614722407"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif",
"state" : {
"branch" : "master",
"revision" : "5e8619335d394901379c9add5c4c1c2f420b3800"
}
},
{
"identity" : "webrtc",
"kind" : "remoteSourceControl",
"location" : "https://github.com/simplex-chat/WebRTC.git",
"state" : {
"revision" : "34bedc50f9c58dccf4967ea59c7e6a47d620803b"
}
},
{
"identity" : "yams",
"kind" : "remoteSourceControl",
"location" : "https://github.com/jpsim/Yams",
"state" : {
"revision" : "9234124cff5e22e178988c18d8b95a8ae8007f76",
"version" : "5.1.2"
}
}
],
"version" : 3
}
+22
View File
@@ -3294,6 +3294,28 @@ public struct CIFile: Decodable, Hashable {
}
}
}
public var showStatusIconInSmallView: Bool {
get {
switch fileStatus {
case .sndStored: fileProtocol != .local
case .sndTransfer: true
case .sndComplete: false
case .sndCancelled: true
case .sndError: true
case .sndWarning: true
case .rcvInvitation: false
case .rcvAccepted: true
case .rcvTransfer: true
case .rcvAborted: true
case .rcvCancelled: true
case .rcvComplete: false
case .rcvError: true
case .rcvWarning: true
case .invalid: true
}
}
}
}
public struct CryptoFile: Codable, Hashable {