Compare commits

...

15 Commits

Author SHA1 Message Date
Evgeny Poberezkin ba0e61b422 reduce to 2 seconds 2024-08-02 12:39:02 +01:00
Evgeny Poberezkin 65055e2582 fix, simplify 2024-08-02 12:35:29 +01:00
Evgeny Poberezkin 308ceaf5cd restore special case for the current chat 2024-08-01 23:13:12 +01:00
Evgeny Poberezkin 178634d581 diff 2024-08-01 22:00:47 +01:00
Evgeny Poberezkin 2f4da10dc1 remove file 2024-08-01 21:56:09 +01:00
Evgeny Poberezkin 6d66593688 better updateChats 2024-08-01 21:53:29 +01:00
Evgeny Poberezkin d5689a922b optimize 2024-08-01 21:38:37 +01:00
Evgeny Poberezkin 892b8b988e fix 2024-08-01 21:11:47 +01:00
Evgeny Poberezkin 25d39877f4 move chats every 3 seconds 2024-08-01 21:10:06 +01:00
Evgeny Poberezkin 4dbefcce32 dont pop the first chat 2024-08-01 21:04:21 +01:00
Evgeny Poberezkin 31ebfb03fa throttle, update unread 2024-08-01 20:58:19 +01:00
Levitating Pineapple cf6e608384 cleanup 2024-08-01 18:59:00 +03:00
Levitating Pineapple e9797da238 ios: add throttling for incoming messages 2024-08-01 18:41:07 +03:00
Arturs Krumins bfab2d9fb6 ios: fix alert reseting list position (#4554)
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-08-01 10:21:31 +01:00
Evgeny Poberezkin 229ea80499 ios: fix sharing links with previews (#4552)
* desktop: fix app without user crashing when trying to get subscription data (#4551)

* use newline

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2024-08-01 09:51:22 +01:00
4 changed files with 173 additions and 123 deletions
+59 -14
View File
@@ -337,12 +337,10 @@ final class ChatModel: ObservableObject {
increaseUnreadCounter(user: currentUser!)
}
if i > 0 {
if chatId == nil {
withAnimation { popChat_(i) }
} else if chatId == cInfo.id {
if chatId == cInfo.id {
chatToTop = cInfo.id
} else {
popChat_(i)
popChatCollector.addChat(cInfo.id)
}
}
} else {
@@ -579,7 +577,7 @@ final class ChatModel: ObservableObject {
// update current chat
markChatItemRead_(itemIndex)
// update preview
unreadCollector.decreaseUnreadCounter(chatIndex)
unreadCollector.decreaseUnreadCounter(cInfo.id)
}
}
}
@@ -588,29 +586,76 @@ final class ChatModel: ObservableObject {
private let unreadCollector = UnreadCollector()
class UnreadCollector {
private let subject = PassthroughSubject<Int, Never>()
private let subject = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>()
private var dictionary = Dictionary<Int, Int>()
private var unreadCounts: [ChatId: Int] = [:]
init() {
subject
.debounce(for: 1, scheduler: DispatchQueue.main)
.sink { _ in
self.dictionary.forEach { key, value in
ChatModel.shared.decreaseUnreadCounter(key, by: value)
.sink {
let m = ChatModel.shared
for (chatId, count) in self.unreadCounts {
if let i = m.getChatIndex(chatId) {
m.decreaseUnreadCounter(i, by: count)
}
}
self.dictionary = Dictionary<Int, Int>()
self.unreadCounts = [:]
}
.store(in: &bag)
}
// Only call from main thread
func decreaseUnreadCounter(_ chatIndex: Int) {
dictionary[chatIndex] = (dictionary[chatIndex] ?? 0) + 1
subject.send(chatIndex)
func decreaseUnreadCounter(_ chatId: ChatId) {
unreadCounts[chatId] = (unreadCounts[chatId] ?? 0) + 1
subject.send()
}
}
let popChatCollector = PopChatCollector()
class PopChatCollector {
private let subject = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>()
private var chatsToPop: [ChatId: Date] = [:]
init() {
subject
.throttle(for: 2, scheduler: DispatchQueue.main, latest: true)
.sink { self.popRecentChats() }
.store(in: &bag)
}
// Only call from main thread
func addChat(_ chatId: ChatId) {
chatsToPop[chatId] = Date.now
subject.send()
}
func popRecentChats() {
let m = ChatModel.shared
var ixs: IndexSet = []
var chs: [(Chat, Date)] = []
// collect chats that received updates
for (chatId, ts) in chatsToPop {
if m.chatId != chatId, let i = m.getChatIndex(chatId) {
ixs.insert(i)
chs.append((m.chats[i], ts))
}
}
// sort chats by timestamp in descending order
chs.sort { ch1, ch2 in ch1.1 > ch2.1 }
withAnimation {
m.chats.remove(atOffsets: ixs)
m.chats.insert(contentsOf: chs.map { $0.0 }, at: 0)
}
chatsToPop = [:]
}
}
private func markChatItemRead_(_ i: Int) {
let meta = im.reversedChatItems[i].meta
if case .rcvNew = meta.itemStatus {
@@ -158,8 +158,8 @@ struct ChatListView: View {
.offset(x: -8)
}
}
.onChange(of: chatModel.chatId) { _ in
if chatModel.chatId == nil, let chatId = chatModel.chatToTop {
.onChange(of: chatModel.chatId) { chId in
if chId == nil, let chatId = chatModel.chatToTop {
chatModel.chatToTop = nil
chatModel.popChat(chatId)
}
+94 -89
View File
@@ -19,10 +19,10 @@ private let MAX_DOWNSAMPLE_SIZE: Int64 = 2000
class ShareModel: ObservableObject {
@Published var sharedContent: SharedContent?
@Published var chats = Array<ChatData>()
@Published var profileImages = Dictionary<ChatInfo.ID, UIImage>()
@Published var search = String()
@Published var comment = String()
@Published var chats: [ChatData] = []
@Published var profileImages: [ChatInfo.ID: UIImage] = [:]
@Published var search = ""
@Published var comment = ""
@Published var selected: ChatData?
@Published var isLoaded = false
@Published var bottomBar: BottomBar = .loadingSpinner
@@ -51,6 +51,13 @@ class ShareModel: ObservableObject {
private var itemProvider: NSItemProvider?
var isSendDisbled: Bool { sharedContent == nil || selected == nil || isProhibited(selected) }
var isLinkPreview: Bool {
switch sharedContent {
case .url: true
default: false
}
}
func isProhibited(_ chat: ChatData?) -> Bool {
if let chat, let sharedContent {
@@ -58,7 +65,7 @@ class ShareModel: ObservableObject {
} else { false }
}
var filteredChats: Array<ChatData> {
var filteredChats: [ChatData] {
search.isEmpty
? filterChatsToForwardTo(chats: chats)
: filterChatsToForwardTo(chats: chats)
@@ -111,7 +118,7 @@ class ShareModel: ObservableObject {
}
// Process Attachment
Task {
switch await self.itemProvider!.sharedContent() {
switch await getSharedContent(self.itemProvider!) {
case let .success(chatItemContent):
await MainActor.run {
self.sharedContent = chatItemContent
@@ -381,7 +388,7 @@ enum SharedContent {
switch self {
case let .image(preview, _): .image(text: comment, image: preview)
case let .movie(preview, duration, _): .video(text: comment, image: preview, duration: duration)
case let .url(preview): .link(text: comment, preview: preview)
case let .url(preview): .link(text: preview.uri.absoluteString + (comment == "" ? "" : "\n" + comment), preview: preview)
case .text: .text(comment)
case .data: .file(comment)
}
@@ -396,91 +403,89 @@ enum SharedContent {
}
}
extension NSItemProvider {
fileprivate func sharedContent() async -> Result<SharedContent, ErrorAlert> {
if let type = firstMatching(of: [.image, .movie, .fileURL, .url, .text]) {
switch type {
// Prepare Image message
case .image:
// Animated
return if hasItemConformingToTypeIdentifier(UTType.gif.identifier) {
if let url = try? await inPlaceUrl(type: type),
let data = try? Data(contentsOf: url),
let image = UIImage(data: data),
let cryptoFile = saveFile(data, generateNewFileName("IMG", "gif"), encrypted: privacyEncryptLocalFilesGroupDefault.get()),
let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE) {
.success(.image(preview: preview, cryptoFile: cryptoFile))
} else { .failure(ErrorAlert("Error preparing message")) }
// Static
} else {
if let image = await staticImage(),
let cryptoFile = saveImage(image),
let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE) {
.success(.image(preview: preview, cryptoFile: cryptoFile))
} else { .failure(ErrorAlert("Error preparing message")) }
}
// Prepare Movie message
case .movie:
fileprivate func getSharedContent(_ ip: NSItemProvider) async -> Result<SharedContent, ErrorAlert> {
if let type = firstMatching(of: [.image, .movie, .fileURL, .url, .text]) {
switch type {
// Prepare Image message
case .image:
// Animated
return if ip.hasItemConformingToTypeIdentifier(UTType.gif.identifier) {
if let url = try? await inPlaceUrl(type: type),
let trancodedUrl = await transcodeVideo(from: url),
let (image, duration) = AVAsset(url: trancodedUrl).generatePreview(),
let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE),
let cryptoFile = moveTempFileFromURL(trancodedUrl) {
try? FileManager.default.removeItem(at: trancodedUrl)
return .success(.movie(preview: preview, duration: duration, cryptoFile: cryptoFile))
} else { return .failure(ErrorAlert("Error preparing message")) }
// Prepare Data message
case .fileURL:
if let url = try? await inPlaceUrl(type: .data) {
if isFileTooLarge(for: url) {
let sizeString = ByteCountFormatter.string(
fromByteCount: Int64(getMaxFileSize(.xftp)),
countStyle: .binary
)
return .failure(
ErrorAlert(
title: "Large file!",
message: "Currently maximum supported file size is \(sizeString)."
)
)
}
if let file = saveFileFromURL(url) {
return .success(.data(cryptoFile: file))
}
}
return .failure(ErrorAlert("Error preparing file"))
// Prepare Link message
case .url:
if let url = try? await loadItem(forTypeIdentifier: type.identifier) as? URL {
let content: SharedContent =
if privacyLinkPreviewsGroupDefault.get(), let linkPreview = await getLinkPreview(for: url) {
.url(preview: linkPreview)
} else {
.text(string: url.absoluteString)
}
return .success(content)
} else { return .failure(ErrorAlert("Error preparing message")) }
// Prepare Text message
case .text:
return if let text = try? await loadItem(forTypeIdentifier: type.identifier) as? String {
.success(.text(string: text))
let data = try? Data(contentsOf: url),
let image = UIImage(data: data),
let cryptoFile = saveFile(data, generateNewFileName("IMG", "gif"), encrypted: privacyEncryptLocalFilesGroupDefault.get()),
let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE) {
.success(.image(preview: preview, cryptoFile: cryptoFile))
} else { .failure(ErrorAlert("Error preparing message")) }
// Static
} else {
if let image = await staticImage(),
let cryptoFile = saveImage(image),
let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE) {
.success(.image(preview: preview, cryptoFile: cryptoFile))
} else { .failure(ErrorAlert("Error preparing message")) }
default: return .failure(ErrorAlert("Unsupported format"))
}
} else {
return .failure(ErrorAlert("Unsupported format"))
// Prepare Movie message
case .movie:
if let url = try? await inPlaceUrl(type: type),
let trancodedUrl = await transcodeVideo(from: url),
let (image, duration) = AVAsset(url: trancodedUrl).generatePreview(),
let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE),
let cryptoFile = moveTempFileFromURL(trancodedUrl) {
try? FileManager.default.removeItem(at: trancodedUrl)
return .success(.movie(preview: preview, duration: duration, cryptoFile: cryptoFile))
} else { return .failure(ErrorAlert("Error preparing message")) }
// Prepare Data message
case .fileURL:
if let url = try? await inPlaceUrl(type: .data) {
if isFileTooLarge(for: url) {
let sizeString = ByteCountFormatter.string(
fromByteCount: Int64(getMaxFileSize(.xftp)),
countStyle: .binary
)
return .failure(
ErrorAlert(
title: "Large file!",
message: "Currently maximum supported file size is \(sizeString)."
)
)
}
if let file = saveFileFromURL(url) {
return .success(.data(cryptoFile: file))
}
}
return .failure(ErrorAlert("Error preparing file"))
// Prepare Link message
case .url:
if let url = try? await ip.loadItem(forTypeIdentifier: type.identifier) as? URL {
let content: SharedContent =
if privacyLinkPreviewsGroupDefault.get(), let linkPreview = await getLinkPreview(for: url) {
.url(preview: linkPreview)
} else {
.text(string: url.absoluteString)
}
return .success(content)
} else { return .failure(ErrorAlert("Error preparing message")) }
// Prepare Text message
case .text:
return if let text = try? await ip.loadItem(forTypeIdentifier: type.identifier) as? String {
.success(.text(string: text))
} else { .failure(ErrorAlert("Error preparing message")) }
default: return .failure(ErrorAlert("Unsupported format"))
}
} else {
return .failure(ErrorAlert("Unsupported format"))
}
private func inPlaceUrl(type: UTType) async throws -> URL {
func inPlaceUrl(type: UTType) async throws -> URL {
try await withCheckedThrowingContinuation { cont in
let _ = loadInPlaceFileRepresentation(forTypeIdentifier: type.identifier) { url, bool, error in
let _ = ip.loadInPlaceFileRepresentation(forTypeIdentifier: type.identifier) { url, bool, error in
if let url = url {
cont.resume(returning: url)
} else if let error = error {
@@ -492,21 +497,21 @@ extension NSItemProvider {
}
}
private func firstMatching(of types: Array<UTType>) -> UTType? {
func firstMatching(of types: Array<UTType>) -> UTType? {
for type in types {
if hasItemConformingToTypeIdentifier(type.identifier) { return type }
if ip.hasItemConformingToTypeIdentifier(type.identifier) { return type }
}
return nil
}
private func staticImage() async -> UIImage? {
func staticImage() async -> UIImage? {
if let url = try? await inPlaceUrl(type: .image),
let downsampledImage = downsampleImage(at: url, to: MAX_DOWNSAMPLE_SIZE) {
downsampledImage
} else {
/// Fallback to loading image directly from `ItemProvider`
/// in case loading from disk is not possible. Required for sharing screenshots.
try? await loadItem(forTypeIdentifier: UTType.image.identifier) as? UIImage
try? await ip.loadItem(forTypeIdentifier: UTType.image.identifier) as? UIImage
}
}
}
+18 -18
View File
@@ -72,25 +72,25 @@ extension View {
_ errorAlert: Binding<ErrorAlert?>,
@ViewBuilder actions: (ErrorAlert) -> A = { _ in EmptyView() }
) -> some View {
if let alert = errorAlert.wrappedValue {
self.alert(
alert.title,
isPresented: Binding<Bool>(
get: { errorAlert.wrappedValue != nil },
set: { if !$0 { errorAlert.wrappedValue = nil } }
),
actions: {
if let actions_ = alert.actions {
actions_()
} else {
actions(alert)
}
},
message: {
if let message = alert.message { Text(message) }
alert(
errorAlert.wrappedValue?.title ?? "",
isPresented: Binding<Bool>(
get: { errorAlert.wrappedValue != nil },
set: { if !$0 { errorAlert.wrappedValue = nil } }
),
actions: {
if let actions_ = errorAlert.wrappedValue?.actions {
actions_()
} else {
if let alert = errorAlert.wrappedValue { actions(alert) }
}
)
} else { self }
},
message: {
if let message = errorAlert.wrappedValue?.message {
Text(message)
}
}
)
}
}