mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce1a57ad54 | |||
| 8c49d66e4b | |||
| 0064155825 | |||
| 69654e187e | |||
| f62ec7aaae | |||
| 8263106e19 | |||
| f83154f44f | |||
| b7148fed06 | |||
| 56edfd176f | |||
| 9d58a37cd6 | |||
| d0d7e63f31 | |||
| 4d61cda551 | |||
| 7d4d30afe2 | |||
| 8e31b45be8 | |||
| 014c19fe3a |
@@ -15,11 +15,16 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
|
||||
application.registerForRemoteNotifications()
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
|
||||
removePasscodesIfReinstalled()
|
||||
prepareForLaunch()
|
||||
return true
|
||||
}
|
||||
|
||||
@objc func pasteboardChanged() {
|
||||
ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
|
||||
logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)")
|
||||
|
||||
@@ -61,7 +61,7 @@ class ItemsModel: ObservableObject {
|
||||
|
||||
init() {
|
||||
publisher
|
||||
.throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
|
||||
.throttle(for: 0.25, scheduler: DispatchQueue.main, latest: true)
|
||||
.sink { self.objectWillChange.send() }
|
||||
.store(in: &bag)
|
||||
}
|
||||
@@ -191,6 +191,7 @@ final class ChatModel: ObservableObject {
|
||||
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
|
||||
@Published var draft: ComposeState?
|
||||
@Published var draftChatId: String?
|
||||
@Published var pasteboardHasStrings: Bool = UIPasteboard.general.hasStrings
|
||||
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
|
||||
|
||||
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
||||
@@ -562,7 +563,6 @@ final class ChatModel: ObservableObject {
|
||||
// update preview
|
||||
_updateChat(cInfo.id) { chat in
|
||||
self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount)
|
||||
self.updateFloatingButtons(unreadCount: 0)
|
||||
chat.chatStats = ChatStats()
|
||||
}
|
||||
// update current chat
|
||||
@@ -579,12 +579,6 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func updateFloatingButtons(unreadCount: Int) {
|
||||
let fbm = ChatView.FloatingButtonModel.shared
|
||||
fbm.totalUnread = unreadCount
|
||||
fbm.objectWillChange.send()
|
||||
}
|
||||
|
||||
func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) {
|
||||
if let cItem = aboveItem {
|
||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||
@@ -603,7 +597,6 @@ final class ChatModel: ObservableObject {
|
||||
if markedCount > 0 {
|
||||
chat.chatStats.unreadCount -= markedCount
|
||||
self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount)
|
||||
self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -633,15 +626,19 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) {
|
||||
if self.chatId == cInfo.id {
|
||||
for itemId in itemIds {
|
||||
if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) {
|
||||
markChatItemRead_(i)
|
||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
|
||||
if chatId == cInfo.id,
|
||||
let itemIndex = getChatItemIndex(cItem),
|
||||
im.reversedChatItems[itemIndex].isRcvNew {
|
||||
await MainActor.run {
|
||||
withTransaction(Transaction()) {
|
||||
// update current chat
|
||||
markChatItemRead_(itemIndex)
|
||||
// update preview
|
||||
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count)
|
||||
}
|
||||
|
||||
private let unreadCollector = UnreadCollector()
|
||||
@@ -667,10 +664,9 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func changeUnreadCounter(_ chatId: ChatId, by count: Int) {
|
||||
if chatId == ChatModel.shared.chatId {
|
||||
ChatView.FloatingButtonModel.shared.totalUnread += count
|
||||
DispatchQueue.main.async {
|
||||
self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
|
||||
}
|
||||
self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
|
||||
subject.send()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1000,10 +1000,6 @@ func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async thr
|
||||
try await sendCommandOkResp(.apiChatRead(type: type, id: id, itemRange: itemRange))
|
||||
}
|
||||
|
||||
func apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) async throws {
|
||||
try await sendCommandOkResp(.apiChatItemsRead(type: type, id: id, itemIds: itemIds))
|
||||
}
|
||||
|
||||
func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
|
||||
try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat))
|
||||
}
|
||||
@@ -1291,23 +1287,11 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async {
|
||||
|
||||
func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
|
||||
do {
|
||||
logger.debug("apiMarkChatItemRead: \(cItem.id)")
|
||||
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
|
||||
DispatchQueue.main.async {
|
||||
ChatModel.shared.markChatItemsRead(cInfo, [cItem.id])
|
||||
}
|
||||
await ChatModel.shared.markChatItemRead(cInfo, cItem)
|
||||
} catch {
|
||||
logger.error("apiChatRead error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) async {
|
||||
do {
|
||||
try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds)
|
||||
DispatchQueue.main.async {
|
||||
ChatModel.shared.markChatItemsRead(cInfo, itemIds)
|
||||
}
|
||||
} catch {
|
||||
logger.error("apiChatItemsRead error: \(responseError(error))")
|
||||
logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ struct CIGroupInvitationView: View {
|
||||
.foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary)
|
||||
.font(.callout)
|
||||
+ Text(" ")
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
)
|
||||
.overlay(DetermineWidth())
|
||||
}
|
||||
@@ -54,7 +54,7 @@ struct CIGroupInvitationView: View {
|
||||
(
|
||||
groupInvitationText()
|
||||
+ Text(" ")
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
)
|
||||
.overlay(DetermineWidth())
|
||||
}
|
||||
|
||||
@@ -165,9 +165,9 @@ struct CIImageView: View {
|
||||
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
|
||||
Image(systemName: icon)
|
||||
.resizable()
|
||||
.invertedForegroundStyle()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(.white)
|
||||
.padding(padding)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ struct CIMetaView: View {
|
||||
var paleMetaColor = Color(UIColor.tertiaryLabel)
|
||||
var showStatus = true
|
||||
var showEdited = true
|
||||
var invertedMaterial = false
|
||||
|
||||
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
|
||||
|
||||
@@ -26,59 +25,18 @@ struct CIMetaView: View {
|
||||
if chatItem.isDeletedContent {
|
||||
chatItem.timestampText.font(.caption).foregroundColor(metaColor)
|
||||
} else {
|
||||
ZStack {
|
||||
ciMetaText(
|
||||
chatItem.meta,
|
||||
chatTTL: chat.chatInfo.timedMessagesTTL,
|
||||
encrypted: chatItem.encryptedFile,
|
||||
color: metaColor,
|
||||
paleColor: paleMetaColor,
|
||||
colorMode: invertedMaterial
|
||||
? .invertedMaterial
|
||||
: .normal,
|
||||
showStatus: showStatus,
|
||||
showEdited: showEdited,
|
||||
showViaProxy: showSentViaProxy,
|
||||
showTimesamp: showTimestamp
|
||||
).invertedForegroundStyle(enabled: invertedMaterial)
|
||||
if invertedMaterial {
|
||||
ciMetaText(
|
||||
chatItem.meta,
|
||||
chatTTL: chat.chatInfo.timedMessagesTTL,
|
||||
encrypted: chatItem.encryptedFile,
|
||||
colorMode: .normal,
|
||||
onlyOverrides: true,
|
||||
showStatus: showStatus,
|
||||
showEdited: showEdited,
|
||||
showViaProxy: showSentViaProxy,
|
||||
showTimesamp: showTimestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum MetaColorMode {
|
||||
// Renders provided colours
|
||||
case normal
|
||||
// Fully transparent meta - used for reserving space
|
||||
case transparent
|
||||
// Renders white on dark backgrounds and black on light ones
|
||||
case invertedMaterial
|
||||
|
||||
func resolve(_ c: Color?) -> Color? {
|
||||
switch self {
|
||||
case .normal: c
|
||||
case .transparent: .clear
|
||||
case .invertedMaterial: nil
|
||||
}
|
||||
}
|
||||
|
||||
var statusSpacer: Text {
|
||||
switch self {
|
||||
case .normal, .transparent: Text(Image(systemName: "circlebadge.fill")).foregroundColor(.clear)
|
||||
case .invertedMaterial: Text(" ").kerning(13)
|
||||
ciMetaText(
|
||||
chatItem.meta,
|
||||
chatTTL: chat.chatInfo.timedMessagesTTL,
|
||||
encrypted: chatItem.encryptedFile,
|
||||
color: chatItem.meta.itemStatus.sndProgress == .partial
|
||||
? paleMetaColor
|
||||
: metaColor,
|
||||
showStatus: showStatus,
|
||||
showEdited: showEdited,
|
||||
showViaProxy: showSentViaProxy,
|
||||
showTimesamp: showTimestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,77 +45,47 @@ func ciMetaText(
|
||||
_ meta: CIMeta,
|
||||
chatTTL: Int?,
|
||||
encrypted: Bool?,
|
||||
color: Color = .clear, // we use this function to reserve space without rendering meta
|
||||
paleColor: Color? = nil,
|
||||
color: Color = .clear,
|
||||
primaryColor: Color = .accentColor,
|
||||
colorMode: MetaColorMode = .normal,
|
||||
onlyOverrides: Bool = false, // only render colors that differ from base
|
||||
transparent: Bool = false,
|
||||
showStatus: Bool = true,
|
||||
showEdited: Bool = true,
|
||||
showViaProxy: Bool,
|
||||
showTimesamp: Bool
|
||||
) -> Text {
|
||||
var r = Text("")
|
||||
var space: Text? = nil
|
||||
let appendSpace = {
|
||||
if let sp = space {
|
||||
r = r + sp
|
||||
space = nil
|
||||
}
|
||||
}
|
||||
let resolved = colorMode.resolve(color)
|
||||
if showEdited, meta.itemEdited {
|
||||
r = r + statusIconText("pencil", resolved)
|
||||
r = r + statusIconText("pencil", color)
|
||||
}
|
||||
if meta.disappearing {
|
||||
r = r + statusIconText("timer", resolved).font(.caption2)
|
||||
r = r + statusIconText("timer", color).font(.caption2)
|
||||
let ttl = meta.itemTimed?.ttl
|
||||
if ttl != chatTTL {
|
||||
r = r + colored(Text(shortTimeText(ttl)), resolved)
|
||||
r = r + Text(shortTimeText(ttl)).foregroundColor(color)
|
||||
}
|
||||
space = Text(" ")
|
||||
r = r + Text(" ")
|
||||
}
|
||||
if showViaProxy, meta.sentViaProxy == true {
|
||||
appendSpace()
|
||||
r = r + statusIconText("arrow.forward", resolved?.opacity(0.67)).font(.caption2)
|
||||
r = r + statusIconText("arrow.forward", color.opacity(0.67)).font(.caption2)
|
||||
}
|
||||
if showStatus {
|
||||
appendSpace()
|
||||
if let (image, statusColor) = meta.itemStatus.statusIcon(color, paleColor ?? color, primaryColor) {
|
||||
let metaColor = if onlyOverrides && statusColor == color {
|
||||
Color.clear
|
||||
} else {
|
||||
colorMode.resolve(statusColor)
|
||||
}
|
||||
r = r + colored(Text(image), metaColor)
|
||||
space = Text(" ")
|
||||
if let (image, statusColor) = meta.itemStatus.statusIcon(color, primaryColor) {
|
||||
r = r + Text(image).foregroundColor(transparent ? .clear : statusColor) + Text(" ")
|
||||
} else if !meta.disappearing {
|
||||
space = colorMode.statusSpacer + Text(" ")
|
||||
r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
|
||||
}
|
||||
}
|
||||
if let enc = encrypted {
|
||||
appendSpace()
|
||||
r = r + statusIconText(enc ? "lock" : "lock.open", resolved)
|
||||
space = Text(" ")
|
||||
r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
|
||||
}
|
||||
if showTimesamp {
|
||||
appendSpace()
|
||||
r = r + colored(meta.timestampText, resolved)
|
||||
r = r + meta.timestampText.foregroundColor(color)
|
||||
}
|
||||
return r.font(.caption)
|
||||
}
|
||||
|
||||
private func statusIconText(_ icon: String, _ color: Color?) -> Text {
|
||||
colored(Text(Image(systemName: icon)), color)
|
||||
}
|
||||
|
||||
// Applying `foregroundColor(nil)` breaks `.invertedForegroundStyle` modifier
|
||||
private func colored(_ t: Text, _ color: Color?) -> Text {
|
||||
if let color {
|
||||
t.foregroundColor(color)
|
||||
} else {
|
||||
t
|
||||
}
|
||||
private func statusIconText(_ icon: String, _ color: Color) -> Text {
|
||||
Text(Image(systemName: icon)).foregroundColor(color)
|
||||
}
|
||||
|
||||
struct CIMetaView_Previews: PreviewProvider {
|
||||
|
||||
@@ -126,7 +126,7 @@ struct CIRcvDecryptionError: View {
|
||||
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
|
||||
.font(.callout)
|
||||
+ Text(" ")
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
@@ -145,7 +145,7 @@ struct CIRcvDecryptionError: View {
|
||||
.foregroundColor(.red)
|
||||
.italic()
|
||||
+ Text(" ")
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||
|
||||
@@ -292,20 +292,28 @@ struct CIVideoView: View {
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
private var fileSizeString: String {
|
||||
if let file = chatItem.file, !videoPlaying {
|
||||
" " + ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private func durationProgress() -> some View {
|
||||
Text((durationText(videoPlaying ? progress : duration)) + fileSizeString)
|
||||
.invertedForegroundStyle()
|
||||
HStack {
|
||||
Text("\(durationText(videoPlaying ? progress : duration))")
|
||||
.foregroundColor(.white)
|
||||
.font(.caption)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 3)
|
||||
.padding(.horizontal, 6)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.cornerRadius(10)
|
||||
.padding([.top, .leading], 6)
|
||||
|
||||
if let file = chatItem.file, !videoPlaying {
|
||||
Text("\(ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary))")
|
||||
.foregroundColor(.white)
|
||||
.font(.caption)
|
||||
.padding(.vertical, 3)
|
||||
.padding(.horizontal, 6)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.cornerRadius(10)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func imageView(_ img: UIImage) -> some View {
|
||||
@@ -403,9 +411,9 @@ struct CIVideoView: View {
|
||||
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
|
||||
Image(systemName: icon)
|
||||
.resizable()
|
||||
.invertedForegroundStyle()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(.white)
|
||||
.padding(smallView ? 0 : padding)
|
||||
}
|
||||
|
||||
@@ -420,8 +428,10 @@ struct CIVideoView: View {
|
||||
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
|
||||
Circle()
|
||||
.trim(from: 0, to: Double(progress) / Double(total))
|
||||
.stroke(style: StrokeStyle(lineWidth: 2))
|
||||
.invertedForegroundStyle()
|
||||
.stroke(
|
||||
Color(uiColor: .white),
|
||||
style: StrokeStyle(lineWidth: 2)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 16, height: 16)
|
||||
.padding([.trailing, .top], smallView ? 0 : 11)
|
||||
|
||||
@@ -64,17 +64,12 @@ struct FramedItemView: View {
|
||||
.overlay(DetermineWidth())
|
||||
}
|
||||
|
||||
if let content = chatItem.content.msgContent {
|
||||
CIMetaView(
|
||||
chat: chat,
|
||||
chatItem: chatItem,
|
||||
metaColor: theme.colors.secondary,
|
||||
invertedMaterial: useWhiteMetaColor
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 6)
|
||||
.overlay(DetermineWidth())
|
||||
.accessibilityLabel("")
|
||||
if chatItem.content.msgContent != nil {
|
||||
CIMetaView(chat: chat, chatItem: chatItem, metaColor: useWhiteMetaColor ? Color.white : theme.colors.secondary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 6)
|
||||
.overlay(DetermineWidth())
|
||||
.accessibilityLabel("")
|
||||
}
|
||||
}
|
||||
.background { chatItemFrameColorMaybeImageOrVideo(chatItem, theme).modifier(ChatTailPadding()) }
|
||||
|
||||
@@ -85,7 +85,7 @@ struct MsgContentView: View {
|
||||
}
|
||||
|
||||
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
|
||||
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ struct ChatView: View {
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@State @ObservedObject var chat: Chat
|
||||
@StateObject private var scrollModel = ReverseListScrollModel()
|
||||
@StateObject private var floatingButtonModel: FloatingButtonModel = .shared
|
||||
@State private var showChatInfoSheet: Bool = false
|
||||
@State private var showAddMembersSheet: Bool = false
|
||||
@State private var composeState = ComposeState()
|
||||
@@ -75,7 +76,8 @@ struct ChatView: View {
|
||||
VStack(spacing: 0) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
chatItemsList()
|
||||
FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat)
|
||||
// TODO: Extract into a separate view, to reduce the scope of `FloatingButtonModel` updates
|
||||
floatingButtons(unreadBelow: floatingButtonModel.unreadBelow, isNearBottom: floatingButtonModel.isNearBottom)
|
||||
}
|
||||
connectingText()
|
||||
if selectedChatItems == nil {
|
||||
@@ -339,7 +341,6 @@ struct ChatView: View {
|
||||
await markChatUnread(chat, unreadChat: false)
|
||||
}
|
||||
}
|
||||
ChatView.FloatingButtonModel.shared.totalUnread = chat.chatStats.unreadCount
|
||||
}
|
||||
|
||||
private func searchToolbar() -> some View {
|
||||
@@ -426,7 +427,7 @@ struct ChatView: View {
|
||||
.onChange(of: im.itemAdded) { added in
|
||||
if added {
|
||||
im.itemAdded = false
|
||||
if FloatingButtonModel.shared.isReallyNearBottom {
|
||||
if floatingButtonModel.isReallyNearBottom {
|
||||
scrollModel.scrollToBottom()
|
||||
}
|
||||
}
|
||||
@@ -452,162 +453,86 @@ struct ChatView: View {
|
||||
static let shared = FloatingButtonModel()
|
||||
@Published var unreadBelow: Int = 0
|
||||
@Published var isNearBottom: Bool = true
|
||||
@Published var date: Date?
|
||||
@Published var isDateVisible: Bool = false
|
||||
var totalUnread: Int = 0
|
||||
var isReallyNearBottom: Bool = true
|
||||
var hideDateWorkItem: DispatchWorkItem?
|
||||
var isReallyNearBottom: Bool { scrollOffset.value > 0 && scrollOffset.value < 500 }
|
||||
let visibleItems = PassthroughSubject<[String], Never>()
|
||||
let scrollOffset = CurrentValueSubject<Double, Never>(0)
|
||||
private var bag = Set<AnyCancellable>()
|
||||
|
||||
func updateOnListChange(_ listState: ListState) {
|
||||
let im = ItemsModel.shared
|
||||
let unreadBelow =
|
||||
if let id = listState.bottomItemId,
|
||||
let index = im.reversedChatItems.firstIndex(where: { $0.id == id })
|
||||
{
|
||||
im.reversedChatItems[..<index].reduce(into: 0) { unread, chatItem in
|
||||
if chatItem.isRcvNew { unread += 1 }
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
let date: Date? =
|
||||
if let topItemDate = listState.topItemDate {
|
||||
Calendar.current.startOfDay(for: topItemDate)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
// set the counters and date indicator
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let it = self else { return }
|
||||
it.setDate(visibility: true)
|
||||
it.unreadBelow = unreadBelow
|
||||
it.date = date
|
||||
it.isReallyNearBottom = listState.scrollOffset > 0 && listState.scrollOffset < 500
|
||||
}
|
||||
|
||||
// set floating button indication mode
|
||||
let nearBottom = listState.scrollOffset < 800
|
||||
if nearBottom != self.isNearBottom {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
|
||||
self?.isNearBottom = nearBottom
|
||||
}
|
||||
}
|
||||
|
||||
// hide Date indicator after 1 second of no scrolling
|
||||
hideDateWorkItem?.cancel()
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
guard let it = self else { return }
|
||||
it.setDate(visibility: false)
|
||||
it.hideDateWorkItem = nil
|
||||
}
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.hideDateWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem)
|
||||
}
|
||||
}
|
||||
|
||||
func resetDate() {
|
||||
date = nil
|
||||
isDateVisible = false
|
||||
}
|
||||
|
||||
private func setDate(visibility isVisible: Bool) {
|
||||
if isVisible {
|
||||
if !isNearBottom,
|
||||
!isDateVisible,
|
||||
let date, !Calendar.current.isDateInToday(date) {
|
||||
withAnimation { self.isDateVisible = true }
|
||||
}
|
||||
} else if isDateVisible {
|
||||
withAnimation { self.isDateVisible = false }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct FloatingButtons: View {
|
||||
let theme: AppTheme
|
||||
let scrollModel: ReverseListScrollModel
|
||||
let chat: Chat
|
||||
@ObservedObject var model = FloatingButtonModel.shared
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
if let date = model.date {
|
||||
DateSeparator(date: date)
|
||||
.padding(.vertical, 4).padding(.horizontal, 8)
|
||||
.background(.thinMaterial)
|
||||
.clipShape(Capsule())
|
||||
.opacity(model.isDateVisible ? 1 : 0)
|
||||
}
|
||||
VStack {
|
||||
let unreadAbove = model.totalUnread - model.unreadBelow
|
||||
if unreadAbove > 0 {
|
||||
circleButton {
|
||||
unreadCountText(unreadAbove)
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
init() {
|
||||
visibleItems
|
||||
.receive(on: DispatchQueue.global(qos: .background))
|
||||
.map { itemIds in
|
||||
if let viewId = itemIds.first,
|
||||
let index = ItemsModel.shared.reversedChatItems.firstIndex(where: { $0.viewId == viewId }) {
|
||||
ItemsModel.shared.reversedChatItems[..<index].reduce(into: 0) { unread, chatItem in
|
||||
if chatItem.isRcvNew { unread += 1 }
|
||||
}
|
||||
.onTapGesture {
|
||||
scrollModel.scrollToNextPage()
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
Task {
|
||||
await markChatRead(chat)
|
||||
}
|
||||
} label: {
|
||||
Label("Mark read", systemImage: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if model.unreadBelow > 0 {
|
||||
circleButton {
|
||||
unreadCountText(model.unreadBelow)
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.onTapGesture {
|
||||
scrollModel.scrollToBottom()
|
||||
}
|
||||
} else if !model.isNearBottom {
|
||||
circleButton {
|
||||
Image(systemName: "chevron.down")
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.onTapGesture { scrollModel.scrollToBottom() }
|
||||
}
|
||||
} else { 0 }
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.onDisappear(perform: model.resetDate)
|
||||
}
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: \.unreadBelow, on: self)
|
||||
.store(in: &bag)
|
||||
|
||||
private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.frame(width: 44, height: 44)
|
||||
content()
|
||||
}
|
||||
scrollOffset
|
||||
.map { $0 < 800 }
|
||||
.removeDuplicates()
|
||||
// Delay the state change until scroll to bottom animation is finished
|
||||
.delay(for: 0.35, scheduler: DispatchQueue.main)
|
||||
.assign(to: \.isNearBottom, on: self)
|
||||
.store(in: &bag)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DateSeparator: View {
|
||||
let date: Date
|
||||
private func floatingButtons(unreadBelow: Int, isNearBottom: Bool) -> some View {
|
||||
VStack {
|
||||
let unreadAbove = chat.chatStats.unreadCount - unreadBelow
|
||||
if unreadAbove > 0 {
|
||||
circleButton {
|
||||
unreadCountText(unreadAbove)
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.onTapGesture {
|
||||
scrollModel.scrollToNextPage()
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
Task {
|
||||
await markChatRead(chat)
|
||||
}
|
||||
} label: {
|
||||
Label("Mark read", systemImage: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if unreadBelow > 0 {
|
||||
circleButton {
|
||||
unreadCountText(unreadBelow)
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.onTapGesture {
|
||||
scrollModel.scrollToBottom()
|
||||
}
|
||||
} else if !isNearBottom {
|
||||
circleButton {
|
||||
Image(systemName: "chevron.down")
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.onTapGesture { scrollModel.scrollToBottom() }
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(String.localizedStringWithFormat(
|
||||
NSLocalizedString("%@, %@", comment: "format for date separator in chat"),
|
||||
date.formatted(.dateTime.weekday(.abbreviated)),
|
||||
date.formatted(.dateTime.day().month(.abbreviated))
|
||||
))
|
||||
.font(.callout)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.frame(width: 44, height: 44)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -768,7 +693,6 @@ struct ChatView: View {
|
||||
@Binding var selectedChatItems: Set<Int64>?
|
||||
|
||||
@State private var allowMenu: Bool = true
|
||||
@State private var markedRead = false
|
||||
|
||||
var revealed: Bool { chatItem == revealedChatItem }
|
||||
|
||||
@@ -780,7 +704,7 @@ struct ChatView: View {
|
||||
let nextItem = im.reversedChatItems[i - 1]
|
||||
let largeGap = !nextItem.chatDir.sameDirection(chatItem.chatDir) || nextItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60
|
||||
return (
|
||||
timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(nextItem.meta.itemTs),
|
||||
timestamp: largeGap || formatTimestampText(chatItem.meta.itemTs) != formatTimestampText(nextItem.meta.itemTs),
|
||||
largeGap: largeGap,
|
||||
date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: nextItem.meta.itemTs) ? nil : nextItem.meta.itemTs
|
||||
)
|
||||
@@ -819,7 +743,15 @@ struct ChatView: View {
|
||||
VStack(spacing: 0) {
|
||||
chatItemView(chatItem, range, prevItem, timeSeparation)
|
||||
if let date = timeSeparation.date {
|
||||
DateSeparator(date: date).padding(8)
|
||||
Text(String.localizedStringWithFormat(
|
||||
NSLocalizedString("%@, %@", comment: "format for date separator in chat"),
|
||||
date.formatted(.dateTime.weekday(.abbreviated)),
|
||||
date.formatted(.dateTime.day().month(.abbreviated))
|
||||
))
|
||||
.font(.callout)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
@@ -835,16 +767,12 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if markedRead {
|
||||
return
|
||||
} else {
|
||||
markedRead = true
|
||||
}
|
||||
if let range {
|
||||
let itemIds = unreadItemIds(range)
|
||||
if !itemIds.isEmpty {
|
||||
if let items = unreadItems(range) {
|
||||
waitToMarkRead {
|
||||
await apiMarkChatItemsRead(chat.chatInfo, itemIds)
|
||||
for ci in items {
|
||||
await apiMarkChatItemRead(chat.chatInfo, ci)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if chatItem.isRcvNew {
|
||||
@@ -854,24 +782,24 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func unreadItemIds(_ range: ClosedRange<Int>) -> [ChatItem.ID] {
|
||||
|
||||
private func unreadItems(_ range: ClosedRange<Int>) -> [ChatItem]? {
|
||||
let im = ItemsModel.shared
|
||||
return range.compactMap { i in
|
||||
let items = range.compactMap { i in
|
||||
if i >= 0 && i < im.reversedChatItems.count {
|
||||
let ci = im.reversedChatItems[i]
|
||||
return if ci.isRcvNew { ci.id } else { nil }
|
||||
return if ci.isRcvNew { ci } else { nil }
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return if items.isEmpty { nil } else { items }
|
||||
}
|
||||
|
||||
private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) {
|
||||
Task {
|
||||
_ = try? await Task.sleep(nanoseconds: 600_000000)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||
if m.chatId == chat.chatInfo.id {
|
||||
await op()
|
||||
Task(operation: op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,14 +107,9 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
name: notificationName,
|
||||
object: nil
|
||||
)
|
||||
|
||||
updateFloatingButtons
|
||||
.throttle(for: 0.2, scheduler: DispatchQueue.global(qos: .background), latest: true)
|
||||
.sink {
|
||||
if let listState = DispatchQueue.main.sync(execute: { [weak self] in self?.getListState() }) {
|
||||
ChatView.FloatingButtonModel.shared.updateOnListChange(listState)
|
||||
}
|
||||
}
|
||||
.throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
|
||||
.sink { self.updateVisibleItems() }
|
||||
.store(in: &bag)
|
||||
}
|
||||
|
||||
@@ -208,35 +203,25 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
updateFloatingButtons.send()
|
||||
}
|
||||
|
||||
func getListState() -> ListState? {
|
||||
if let visibleRows = tableView.indexPathsForVisibleRows,
|
||||
visibleRows.last?.item ?? 0 < representer.items.count {
|
||||
let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset
|
||||
let topItemDate: Date? =
|
||||
if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) {
|
||||
representer.items[lastVisible.item].meta.itemTs
|
||||
} else {
|
||||
nil
|
||||
private func updateVisibleItems() {
|
||||
let fbm = ChatView.FloatingButtonModel.shared
|
||||
fbm.scrollOffset.send(tableView.contentOffset.y + InvertedTableView.inset)
|
||||
fbm.visibleItems.send(
|
||||
(tableView.indexPathsForVisibleRows ?? [])
|
||||
.compactMap { indexPath -> String? in
|
||||
guard let relativeFrame = tableView.superview?.convert(
|
||||
tableView.rectForRow(at: indexPath),
|
||||
from: tableView
|
||||
) else { return nil }
|
||||
// Checks that the cell is visible accounting for the added insets
|
||||
let isVisible =
|
||||
relativeFrame.maxY > InvertedTableView.inset &&
|
||||
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
|
||||
return indexPath.item < representer.items.count && isVisible
|
||||
? representer.items[indexPath.item].viewId
|
||||
: nil
|
||||
}
|
||||
let bottomItemId: ChatItem.ID? =
|
||||
if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) {
|
||||
representer.items[firstVisible.item].id
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
return (scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func isVisible(indexPath: IndexPath) -> Bool {
|
||||
if let relativeFrame = tableView.superview?.convert(
|
||||
tableView.rectForRow(at: indexPath),
|
||||
from: tableView
|
||||
) {
|
||||
relativeFrame.maxY > InvertedTableView.inset &&
|
||||
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
|
||||
} else { false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,12 +265,6 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
typealias ListState = (
|
||||
scrollOffset: Double,
|
||||
topItemDate: Date?,
|
||||
bottomItemId: ChatItem.ID?
|
||||
)
|
||||
|
||||
/// Manages ``ReverseList`` scrolling
|
||||
class ReverseListScrollModel: ObservableObject {
|
||||
/// Represents Scroll State of ``ReverseList``
|
||||
|
||||
@@ -31,7 +31,7 @@ struct ChatListView: View {
|
||||
@State private var searchChatFilteredBySimplexLink: String? = nil
|
||||
@State private var scrollToSearchBar = false
|
||||
@State private var activeUserPickerSheet: UserPickerSheet? = nil
|
||||
@State private var userPickerShown: Bool = false
|
||||
@State private var isUserPickerPresented: Bool = false
|
||||
|
||||
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
|
||||
@@ -58,7 +58,7 @@ struct ChatListView: View {
|
||||
destination: chatView
|
||||
) { chatListView }
|
||||
}
|
||||
.sheet(isPresented: $userPickerShown) {
|
||||
.sheet(isPresented: $isUserPickerPresented) {
|
||||
UserPicker(activeSheet: $activeUserPickerSheet)
|
||||
.sheet(item: $activeUserPickerSheet) { sheet in
|
||||
if let currentUser = chatModel.currentUser {
|
||||
@@ -207,7 +207,7 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
userPickerShown = true
|
||||
isUserPickerPresented = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ struct UserPicker: View {
|
||||
}
|
||||
} else {
|
||||
let v = userPickerRow(otherUsers, size: 44)
|
||||
.padding(.leading, -11)
|
||||
.padding(.leading, -8)
|
||||
if #available(iOS 16.0, *) {
|
||||
v
|
||||
} else {
|
||||
@@ -78,7 +78,7 @@ struct UserPicker: View {
|
||||
activeSheet = .useFromDesktop
|
||||
}
|
||||
|
||||
ZStack(alignment: .trailing) {
|
||||
HStack {
|
||||
openSheetOnTap(title: "Settings", icon: "gearshape") {
|
||||
activeSheet = .settings
|
||||
}
|
||||
@@ -89,6 +89,8 @@ struct UserPicker: View {
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.frame(maxWidth: 20, maxHeight: 20)
|
||||
}
|
||||
.padding(.leading, 16).padding(.vertical, 8).padding(.trailing, 16)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if (colorScheme == .light) {
|
||||
ThemeManager.applyTheme(systemDarkThemeDefault.get())
|
||||
@@ -99,7 +101,9 @@ struct UserPicker: View {
|
||||
.onLongPressGesture {
|
||||
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
|
||||
}
|
||||
.padding(.leading, -16).padding(.vertical, -8).padding(.trailing, -16)
|
||||
}
|
||||
.padding(.horizontal, -3)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
@@ -124,27 +128,49 @@ struct UserPicker: View {
|
||||
HStack(spacing: 6) {
|
||||
let s = ScrollView(.horizontal) {
|
||||
HStack(spacing: 27) {
|
||||
// Image(systemName: "person.crop.rectangle.stack.fill")
|
||||
// .resizable()
|
||||
// .scaledToFit()
|
||||
// .frame(height: size)
|
||||
// .foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground).asAnotherColorFromSecondaryVariant(theme))
|
||||
// .padding([.top, .trailing], 3)
|
||||
// Image(systemName: "theatermasks.fill")
|
||||
// .resizable()
|
||||
// .scaledToFit()
|
||||
// .frame(width: size, height: size)
|
||||
// .foregroundColor(.indigo)
|
||||
// .padding([.top, .trailing], 3)
|
||||
ForEach(users) { u in
|
||||
if !u.user.hidden && u.user.userId != m.currentUser?.userId {
|
||||
userView(u, size: size)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
.padding(.leading, 2)
|
||||
.padding(.trailing, 22)
|
||||
}
|
||||
ZStack(alignment: .trailing) {
|
||||
ZStack {
|
||||
if #available(iOS 16.0, *) {
|
||||
s.scrollIndicators(.hidden)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
LinearGradient(
|
||||
colors: [.clear, .black],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
.frame(width: size, height: size + 3)
|
||||
HStack(spacing: 0) {
|
||||
LinearGradient(
|
||||
colors: [.black, .clear],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
.frame(width: 2)
|
||||
Color.clear
|
||||
LinearGradient(
|
||||
colors: [.clear, .black],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
.frame(width: size)
|
||||
}
|
||||
.frame(height: size + 3)
|
||||
.blendMode(.destinationOut)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
//
|
||||
// Test.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Levitating Pineapple on 31/08/2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func invertedForegroundStyle(enabled: Bool = true) -> some View {
|
||||
if enabled {
|
||||
foregroundStyle(Material.ultraThin)
|
||||
.environment(\.colorScheme, .dark)
|
||||
.grayscale(1)
|
||||
.contrast(-20)
|
||||
} else { self }
|
||||
}
|
||||
}
|
||||
@@ -8,47 +8,53 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
func getTopViewController() -> UIViewController? {
|
||||
func showShareSheet(items: [Any], completed: (() -> Void)? = nil) {
|
||||
let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene
|
||||
if let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first,
|
||||
let rootViewController = keyWindow.rootViewController {
|
||||
// Find the top-most presented view controller
|
||||
var topController = rootViewController
|
||||
while let presentedViewController = topController.presentedViewController {
|
||||
topController = presentedViewController
|
||||
}
|
||||
return topController
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func showShareSheet(items: [Any], completed: (() -> Void)? = nil) {
|
||||
if let topController = getTopViewController() {
|
||||
let presentedViewController = keyWindow.rootViewController?.presentedViewController ?? keyWindow.rootViewController {
|
||||
let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
if let completed = completed {
|
||||
activityViewController.completionWithItemsHandler = { _, _, _, _ in
|
||||
completed()
|
||||
}
|
||||
}
|
||||
topController.present(activityViewController, animated: true)
|
||||
let handler: UIActivityViewController.CompletionWithItemsHandler = { _,_,_,_ in completed() }
|
||||
activityViewController.completionWithItemsHandler = handler
|
||||
}
|
||||
presentedViewController.present(activityViewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
func showAlert(
|
||||
title: String,
|
||||
message: String? = nil,
|
||||
buttonTitle: String,
|
||||
buttonAction: @escaping () -> Void,
|
||||
cancelButton: Bool
|
||||
) -> Void {
|
||||
if let topController = getTopViewController() {
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: buttonTitle, style: .default) { _ in
|
||||
buttonAction()
|
||||
})
|
||||
if cancelButton {
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel))
|
||||
extension View {
|
||||
func shareSheet(item: Binding<ShareItem?>) -> some View {
|
||||
sheet(item: item) { item in
|
||||
Group {
|
||||
if #available(iOS 16.0, *) {
|
||||
ActivityView(item: item)
|
||||
.presentationDetents([.medium, .large])
|
||||
} else {
|
||||
ActivityView(item: item)
|
||||
}
|
||||
}.ignoresSafeArea()
|
||||
}
|
||||
topController.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
struct ShareItem: Identifiable {
|
||||
let content: any Hashable
|
||||
var id: Int { content.hashValue }
|
||||
}
|
||||
|
||||
private struct ActivityView: UIViewControllerRepresentable {
|
||||
let item: ShareItem
|
||||
|
||||
func makeUIViewController(
|
||||
context: UIViewControllerRepresentableContext<ActivityView>
|
||||
) -> UIActivityViewController {
|
||||
UIActivityViewController(
|
||||
activityItems: [item.content],
|
||||
applicationActivities: nil
|
||||
)
|
||||
}
|
||||
|
||||
func updateUIViewController(
|
||||
_ uiViewController: UIActivityViewController,
|
||||
context: UIViewControllerRepresentableContext<ActivityView>
|
||||
) { }
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ private enum MigrationFromState: Equatable {
|
||||
}
|
||||
|
||||
private enum MigrateFromDeviceViewAlert: Identifiable {
|
||||
case finishMigration(_ fileId: Int64, _ ctrl: chat_ctrl)
|
||||
case deleteChat(_ title: LocalizedStringKey = "Delete chat profile?", _ text: LocalizedStringKey = "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.")
|
||||
case startChat(_ title: LocalizedStringKey = "Start chat?", _ text: LocalizedStringKey = "Warning: starting chat on multiple devices is not supported and will cause message delivery failures")
|
||||
|
||||
@@ -39,7 +38,6 @@ private enum MigrateFromDeviceViewAlert: Identifiable {
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .finishMigration: return "finishMigration"
|
||||
case let .deleteChat(title, text): return "\(title) \(text)"
|
||||
case let .startChat(title, text): return "\(title) \(text)"
|
||||
|
||||
@@ -135,15 +133,6 @@ struct MigrateFromDevice: View {
|
||||
}
|
||||
.alert(item: $alert) { alert in
|
||||
switch alert {
|
||||
case let .finishMigration(fileId, ctrl):
|
||||
return Alert(
|
||||
title: Text("Remove archive?"),
|
||||
message: Text("The uploaded database archive will be permanently removed from the servers."),
|
||||
primaryButton: .destructive(Text("Continue")) {
|
||||
finishMigration(fileId, ctrl)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case let .startChat(title, text):
|
||||
return Alert(
|
||||
title: Text(title),
|
||||
@@ -324,7 +313,7 @@ struct MigrateFromDevice: View {
|
||||
Text("Cancel migration").foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
Button(action: { alert = .finishMigration(fileId, ctrl) }) {
|
||||
Button(action: { finishMigration(fileId, ctrl) }) {
|
||||
settingsRow("checkmark", color: theme.colors.secondary) {
|
||||
Text("Finalize migration").foregroundColor(theme.colors.primary)
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ struct MigrateToDevice: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@Binding var migrationState: MigrationToState?
|
||||
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
|
||||
@State private var alert: MigrateToDeviceViewAlert?
|
||||
@@ -101,7 +102,6 @@ struct MigrateToDevice: View {
|
||||
// Prevent from hiding the view until migration is finished or app deleted
|
||||
@State private var backDisabled: Bool = false
|
||||
@State private var showQRCodeScanner: Bool = true
|
||||
@State private var pasteboardHasStrings = UIPasteboard.general.hasStrings
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
@@ -197,8 +197,10 @@ struct MigrateToDevice: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
Section(header: Text("Or paste archive link").foregroundColor(theme.colors.secondary)) {
|
||||
pasteLinkView()
|
||||
if developerTools {
|
||||
Section(header: Text("Or paste archive link").foregroundColor(theme.colors.secondary)) {
|
||||
pasteLinkView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,7 +218,7 @@ struct MigrateToDevice: View {
|
||||
} label: {
|
||||
Text("Tap to paste link")
|
||||
}
|
||||
.disabled(!pasteboardHasStrings)
|
||||
.disabled(!ChatModel.shared.pasteboardHasStrings)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
@@ -485,9 +487,6 @@ struct MigrateToDevice: View {
|
||||
do {
|
||||
if !hasChatCtrl() {
|
||||
chatInitControllerRemovingDatabases()
|
||||
} else if ChatModel.shared.chatRunning == true {
|
||||
// cannot delete storage if chat is running
|
||||
try await apiStopChat()
|
||||
}
|
||||
try await apiDeleteStorage()
|
||||
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
||||
@@ -557,22 +556,11 @@ struct MigrateToDevice: View {
|
||||
do {
|
||||
try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory())
|
||||
MigrationToDeviceState.save(nil)
|
||||
try ObjC.catchException {
|
||||
appSettings.importIntoApp()
|
||||
}
|
||||
do {
|
||||
try SimpleX.startChat(refreshInvitations: true)
|
||||
AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Finalize migration on another device.")
|
||||
} catch let error {
|
||||
AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error))))
|
||||
}
|
||||
appSettings.importIntoApp()
|
||||
try SimpleX.startChat(refreshInvitations: true)
|
||||
AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Finalize migration on another device.")
|
||||
} catch let error {
|
||||
logger.error("Error importing settings: \(error.localizedDescription)")
|
||||
AlertManager.shared.showAlert(
|
||||
Alert(
|
||||
title: Text("Error migrating settings"),
|
||||
message: Text ("Not all settings were migrated. Repeat migration if you need them.") + Text("\n\n") + Text(responseError(error)))
|
||||
)
|
||||
AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error))))
|
||||
}
|
||||
hideView()
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ func chatContactType(chat: Chat) -> ContactType {
|
||||
case .contactRequest:
|
||||
return .request
|
||||
case let .direct(contact):
|
||||
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
|
||||
if contact.activeConn == nil && contact.profile.contactLink != nil {
|
||||
return .card
|
||||
} else if contact.chatDeleted {
|
||||
return .chatDeleted
|
||||
|
||||
@@ -585,7 +585,6 @@ private struct ConnectView: View {
|
||||
@Binding var pastedLink: String
|
||||
@Binding var alert: NewChatViewAlert?
|
||||
@State private var sheet: PlanAndConnectActionSheet?
|
||||
@State private var pasteboardHasStrings = UIPasteboard.general.hasStrings
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -622,7 +621,7 @@ private struct ConnectView: View {
|
||||
} label: {
|
||||
Text("Tap to paste link")
|
||||
}
|
||||
.disabled(!pasteboardHasStrings)
|
||||
.disabled(!ChatModel.shared.pasteboardHasStrings)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
} else {
|
||||
linkTextView(pastedLink)
|
||||
|
||||
@@ -52,10 +52,10 @@ extension AppSettings {
|
||||
profileImageCornerRadiusGroupDefault.set(val)
|
||||
def.setValue(val, forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
|
||||
}
|
||||
if let val = uiColorScheme { currentThemeDefault.set(val) }
|
||||
if let val = uiDarkColorScheme { systemDarkThemeDefault.set(val) }
|
||||
if let val = uiCurrentThemeIds { currentThemeIdsDefault.set(val) }
|
||||
if let val = uiThemes { themeOverridesDefault.set(val.skipDuplicates()) }
|
||||
if let val = uiColorScheme { def.setValue(val, forKey: DEFAULT_CURRENT_THEME) }
|
||||
if let val = uiDarkColorScheme { def.setValue(val, forKey: DEFAULT_SYSTEM_DARK_THEME) }
|
||||
if let val = uiCurrentThemeIds { def.setValue(val, forKey: DEFAULT_CURRENT_THEME_IDS) }
|
||||
if let val = uiThemes { def.setValue(val.skipDuplicates(), forKey: DEFAULT_THEME_OVERRIDES) }
|
||||
if let val = oneHandUI { groupDefaults.setValue(val, forKey: GROUP_DEFAULT_ONE_HAND_UI) }
|
||||
}
|
||||
|
||||
|
||||
@@ -34,13 +34,14 @@ struct PreferencesView: View {
|
||||
}
|
||||
.onDisappear {
|
||||
if currentPreferences != preferences {
|
||||
showAlert(
|
||||
title: NSLocalizedString("Your chat preferences", comment: "alert title"),
|
||||
message: NSLocalizedString("Chat preferences were changed.", comment: "alert message"),
|
||||
buttonTitle: NSLocalizedString("Save", comment: "alert button"),
|
||||
buttonAction: savePreferences,
|
||||
cancelButton: true
|
||||
)
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Your chat preferences"),
|
||||
message: Text("Chat preferences were changed."),
|
||||
primaryButton: .default(Text("Save")) {
|
||||
savePreferences()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ struct UserAddressView: View {
|
||||
@State private var mailViewResult: Result<MFMailComposeResult, Error>? = nil
|
||||
@State private var alert: UserAddressAlert?
|
||||
@State private var progressIndicator = false
|
||||
@State private var shareItem: ShareItem?
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
|
||||
private enum UserAddressAlert: Identifiable {
|
||||
@@ -45,13 +46,14 @@ struct UserAddressView: View {
|
||||
userAddressScrollView()
|
||||
.onDisappear {
|
||||
if savedAAS != aas {
|
||||
showAlert(
|
||||
title: NSLocalizedString("Auto-accept settings", comment: "alert title"),
|
||||
message: NSLocalizedString("Settings were changed.", comment: "alert message"),
|
||||
buttonTitle: NSLocalizedString("Save", comment: "alert button"),
|
||||
buttonAction: saveAAS,
|
||||
cancelButton: true
|
||||
)
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Auto-accept settings"),
|
||||
message: Text("Settings were changed."),
|
||||
primaryButton: .default(Text("Save")) {
|
||||
saveAAS()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +69,7 @@ struct UserAddressView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.shareSheet(item: $shareItem)
|
||||
}
|
||||
|
||||
@Namespace private var bottomID
|
||||
@@ -244,7 +247,7 @@ struct UserAddressView: View {
|
||||
|
||||
private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View {
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(userAddress.connReqContact)])
|
||||
shareItem = ShareItem(content: simplexChatLink(userAddress.connReqContact))
|
||||
} label: {
|
||||
settingsRow("square.and.arrow.up", color: theme.colors.secondary) {
|
||||
Text("Share address")
|
||||
|
||||
@@ -167,11 +167,6 @@
|
||||
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
|
||||
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
|
||||
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
|
||||
64A208622C8F2CCC00AE9D01 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A2085D2C8F2CCB00AE9D01 /* libgmpxx.a */; };
|
||||
64A208632C8F2CCC00AE9D01 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A2085E2C8F2CCB00AE9D01 /* libffi.a */; };
|
||||
64A208642C8F2CCC00AE9D01 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A2085F2C8F2CCB00AE9D01 /* libgmp.a */; };
|
||||
64A208652C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A208602C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a */; };
|
||||
64A208662C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A208612C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a */; };
|
||||
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
|
||||
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
|
||||
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; };
|
||||
@@ -182,8 +177,6 @@
|
||||
64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; };
|
||||
64EEB0F72C353F1C00972D62 /* ServersSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */; };
|
||||
64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; };
|
||||
8C01E9C12C8EFC33008A4B0A /* objc.m in Sources */ = {isa = PBXBuildFile; fileRef = 8C01E9C02C8EFC33008A4B0A /* objc.m */; };
|
||||
8C01E9C22C8EFF8F008A4B0A /* objc.h in Headers */ = {isa = PBXBuildFile; fileRef = 8C01E9BF2C8EFBB6008A4B0A /* objc.h */; };
|
||||
8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */; };
|
||||
8C74C3E52C1B900600039E77 /* ThemeTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7E3CE32C0DEAC400BFF63A /* ThemeTypes.swift */; };
|
||||
8C74C3E72C1B901900039E77 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C852B072C1086D100BA61E8 /* Color.swift */; };
|
||||
@@ -201,7 +194,6 @@
|
||||
8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; };
|
||||
8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; };
|
||||
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; };
|
||||
CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */; };
|
||||
CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; };
|
||||
CE2AD9CE2C452A4D00E844E3 /* ChatUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */; };
|
||||
CE3097FB2C4C0C9F00180898 /* ErrorAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */; };
|
||||
@@ -222,6 +214,11 @@
|
||||
D77B92DC2952372200A5A1CC /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = D77B92DB2952372200A5A1CC /* SwiftyGif */; };
|
||||
D7F0E33929964E7E0068AF69 /* LZString in Frameworks */ = {isa = PBXBuildFile; productRef = D7F0E33829964E7E0068AF69 /* LZString */; };
|
||||
E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51CC1E52C62085600DB91FE /* OneHandUICard.swift */; };
|
||||
E5BD84572C832BF9008C24D1 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5BD84522C832BF9008C24D1 /* libgmpxx.a */; };
|
||||
E5BD84582C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5BD84532C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N.a */; };
|
||||
E5BD84592C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5BD84542C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N-ghc9.6.3.a */; };
|
||||
E5BD845A2C832BF9008C24D1 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5BD84552C832BF9008C24D1 /* libgmp.a */; };
|
||||
E5BD845B2C832BF9008C24D1 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5BD84562C832BF9008C24D1 /* libffi.a */; };
|
||||
E5DCF8DB2C56FAC1007928CC /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; };
|
||||
E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; };
|
||||
E5DCF9842C5902CE007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9822C5902CE007928CC /* Localizable.strings */; };
|
||||
@@ -508,11 +505,6 @@
|
||||
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
|
||||
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
|
||||
64A2085D2C8F2CCB00AE9D01 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
64A2085E2C8F2CCB00AE9D01 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
64A2085F2C8F2CCB00AE9D01 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
64A208602C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
64A208612C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a"; sourceTree = "<group>"; };
|
||||
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = "<group>"; };
|
||||
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; };
|
||||
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = "<group>"; };
|
||||
@@ -524,8 +516,6 @@
|
||||
64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = "<group>"; };
|
||||
64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServersSummaryView.swift; sourceTree = "<group>"; };
|
||||
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = "<group>"; };
|
||||
8C01E9BF2C8EFBB6008A4B0A /* objc.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = objc.h; sourceTree = "<group>"; };
|
||||
8C01E9C02C8EFC33008A4B0A /* objc.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = objc.m; sourceTree = "<group>"; };
|
||||
8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
|
||||
8C74C3EB2C1B92A900039E77 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
|
||||
8C74C3ED2C1B942300039E77 /* ChatWallpaper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatWallpaper.swift; sourceTree = "<group>"; };
|
||||
@@ -542,7 +532,6 @@
|
||||
8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; };
|
||||
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = "<group>"; };
|
||||
B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = "<group>"; };
|
||||
CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedForegroundStyle.swift; sourceTree = "<group>"; };
|
||||
CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = "<group>"; };
|
||||
CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUtils.swift; sourceTree = "<group>"; };
|
||||
CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlert.swift; sourceTree = "<group>"; };
|
||||
@@ -561,6 +550,11 @@
|
||||
D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; };
|
||||
D7AA2C3429A936B400737B40 /* MediaEncryption.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; name = MediaEncryption.playground; path = Shared/MediaEncryption.playground; sourceTree = SOURCE_ROOT; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
|
||||
E51CC1E52C62085600DB91FE /* OneHandUICard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneHandUICard.swift; sourceTree = "<group>"; };
|
||||
E5BD84522C832BF9008C24D1 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
E5BD84532C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N.a"; sourceTree = "<group>"; };
|
||||
E5BD84542C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
E5BD84552C832BF9008C24D1 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
E5BD84562C832BF9008C24D1 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
E5DCF9702C590272007928CC /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E5DCF9722C590274007928CC /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E5DCF9732C590275007928CC /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
@@ -651,13 +645,13 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
64A208662C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a in Frameworks */,
|
||||
64A208622C8F2CCC00AE9D01 /* libgmpxx.a in Frameworks */,
|
||||
64A208652C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a in Frameworks */,
|
||||
E5BD84572C832BF9008C24D1 /* libgmpxx.a in Frameworks */,
|
||||
E5BD845A2C832BF9008C24D1 /* libgmp.a in Frameworks */,
|
||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||
E5BD845B2C832BF9008C24D1 /* libffi.a in Frameworks */,
|
||||
E5BD84582C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N.a in Frameworks */,
|
||||
E5BD84592C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N-ghc9.6.3.a in Frameworks */,
|
||||
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
||||
64A208632C8F2CCC00AE9D01 /* libffi.a in Frameworks */,
|
||||
64A208642C8F2CCC00AE9D01 /* libgmp.a in Frameworks */,
|
||||
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -735,11 +729,11 @@
|
||||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
64A2085E2C8F2CCB00AE9D01 /* libffi.a */,
|
||||
64A2085F2C8F2CCB00AE9D01 /* libgmp.a */,
|
||||
64A2085D2C8F2CCB00AE9D01 /* libgmpxx.a */,
|
||||
64A208602C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a */,
|
||||
64A208612C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a */,
|
||||
E5BD84562C832BF9008C24D1 /* libffi.a */,
|
||||
E5BD84552C832BF9008C24D1 /* libgmp.a */,
|
||||
E5BD84522C832BF9008C24D1 /* libgmpxx.a */,
|
||||
E5BD84542C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N-ghc9.6.3.a */,
|
||||
E5BD84532C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
@@ -795,7 +789,6 @@
|
||||
8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */,
|
||||
CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */,
|
||||
CE7548092C622630009579B7 /* SwipeLabel.swift */,
|
||||
CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
@@ -983,8 +976,6 @@
|
||||
5CE2BA96284537A800EC33A6 /* dummy.m */,
|
||||
5CD67B8D2B0E858A00C510B1 /* hs_init.h */,
|
||||
5CD67B8E2B0E858A00C510B1 /* hs_init.c */,
|
||||
8C01E9BF2C8EFBB6008A4B0A /* objc.h */,
|
||||
8C01E9C02C8EFC33008A4B0A /* objc.m */,
|
||||
);
|
||||
path = SimpleXChat;
|
||||
sourceTree = "<group>";
|
||||
@@ -1122,7 +1113,6 @@
|
||||
files = (
|
||||
5CE2BA77284530BF00EC33A6 /* SimpleXChat.h in Headers */,
|
||||
5CD67B8F2B0E858A00C510B1 /* hs_init.h in Headers */,
|
||||
8C01E9C22C8EFF8F008A4B0A /* objc.h in Headers */,
|
||||
5CE2BA952845354B00EC33A6 /* SimpleX.h in Headers */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -1499,7 +1489,6 @@
|
||||
5C9CC7A928C532AB00BEF955 /* DatabaseErrorView.swift in Sources */,
|
||||
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */,
|
||||
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */,
|
||||
CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */,
|
||||
5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */,
|
||||
5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */,
|
||||
5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */,
|
||||
@@ -1550,7 +1539,6 @@
|
||||
8C74C3E72C1B901900039E77 /* Color.swift in Sources */,
|
||||
5CD67B902B0E858A00C510B1 /* hs_init.c in Sources */,
|
||||
5CE2BA91284533A300EC33A6 /* Notifications.swift in Sources */,
|
||||
8C01E9C12C8EFC33008A4B0A /* objc.m in Sources */,
|
||||
5CE2BA79284530CC00EC33A6 /* SimpleXChat.docc in Sources */,
|
||||
5CE2BA90284533A300EC33A6 /* JSON.swift in Sources */,
|
||||
5CE2BA8B284533A300EC33A6 /* ChatTypes.swift in Sources */,
|
||||
|
||||
@@ -129,7 +129,6 @@ public enum ChatCommand {
|
||||
// WebRTC calls /
|
||||
case apiGetNetworkStatuses
|
||||
case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64))
|
||||
case apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64])
|
||||
case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool)
|
||||
case receiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?, inline: Bool?)
|
||||
case setFileToReceive(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?)
|
||||
@@ -294,7 +293,6 @@ public enum ChatCommand {
|
||||
case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)"
|
||||
case .apiGetNetworkStatuses: return "/_network_statuses"
|
||||
case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)"
|
||||
case let .apiChatItemsRead(type, id, itemIds): return "/_read chat items \(ref(type, id)) \(joinedIds(itemIds))"
|
||||
case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))"
|
||||
case let .receiveFile(fileId, userApprovedRelays, encrypt, inline): return "/freceive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))"
|
||||
case let .setFileToReceive(fileId, userApprovedRelays, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))"
|
||||
@@ -436,7 +434,6 @@ public enum ChatCommand {
|
||||
case .apiCallStatus: return "apiCallStatus"
|
||||
case .apiGetNetworkStatuses: return "apiGetNetworkStatuses"
|
||||
case .apiChatRead: return "apiChatRead"
|
||||
case .apiChatItemsRead: return "apiChatItemsRead"
|
||||
case .apiChatUnread: return "apiChatUnread"
|
||||
case .receiveFile: return "receiveFile"
|
||||
case .setFileToReceive: return "setFileToReceive"
|
||||
@@ -465,10 +462,6 @@ public enum ChatCommand {
|
||||
"\(type.rawValue)\(id)"
|
||||
}
|
||||
|
||||
func joinedIds(_ ids: [Int64]) -> String {
|
||||
ids.map { "\($0)" }.joined(separator: ",")
|
||||
}
|
||||
|
||||
func protoServersStr(_ servers: [ServerCfg]) -> String {
|
||||
encodeJSON(ProtoServersConfig(servers: servers))
|
||||
}
|
||||
|
||||
@@ -2713,7 +2713,7 @@ public struct CIMeta: Decodable, Hashable {
|
||||
public var deletable: Bool
|
||||
public var editable: Bool
|
||||
|
||||
public var timestampText: Text { Text(formatTimestampMeta(itemTs)) }
|
||||
public var timestampText: Text { get { formatTimestampText(itemTs) } }
|
||||
public var recent: Bool { updatedAt + 10 > .now }
|
||||
public var isLive: Bool { itemLive == true }
|
||||
public var disappearing: Bool { !isRcvNew && itemTimed?.deleteAt != nil }
|
||||
@@ -2761,30 +2761,8 @@ public struct CITimed: Decodable, Hashable {
|
||||
public var deleteAt: Date?
|
||||
}
|
||||
|
||||
let msgTimeFormat = Date.FormatStyle.dateTime.hour().minute()
|
||||
let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits)
|
||||
|
||||
public func formatTimestampText(_ date: Date) -> Text {
|
||||
Text(verbatim: date.formatted(recent(date) ? msgTimeFormat : msgDateFormat))
|
||||
}
|
||||
|
||||
public func formatTimestampMeta(_ date: Date) -> String {
|
||||
date.formatted(date: .omitted, time: .shortened)
|
||||
}
|
||||
|
||||
private func recent(_ date: Date) -> Bool {
|
||||
let now = Date()
|
||||
let calendar = Calendar.current
|
||||
|
||||
guard let previousDay = calendar.date(byAdding: DateComponents(day: -1), to: now),
|
||||
let previousDay18 = calendar.date(bySettingHour: 18, minute: 0, second: 0, of: previousDay),
|
||||
let currentDay00 = calendar.date(bySettingHour: 0, minute: 0, second: 0, of: now),
|
||||
let currentDay12 = calendar.date(bySettingHour: 12, minute: 0, second: 0, of: now) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let isSameDay = calendar.isDate(date, inSameDayAs: now)
|
||||
return isSameDay || (now < currentDay12 && date >= previousDay18 && date < currentDay00)
|
||||
Text(verbatim: date.formatted(date: .omitted, time: .shortened))
|
||||
}
|
||||
|
||||
public enum CIStatus: Decodable, Hashable {
|
||||
@@ -2812,14 +2790,13 @@ public enum CIStatus: Decodable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
public func statusIcon(_ metaColor: Color, _ paleMetaColor: Color, _ primaryColor: Color = .accentColor) -> (Image, Color)? {
|
||||
public func statusIcon(_ metaColor: Color, _ primaryColor: Color = .accentColor) -> (Image, Color)? {
|
||||
switch self {
|
||||
case .sndNew: nil
|
||||
case let .sndSent(sndProgress):
|
||||
(Image("checkmark.wide"), sndProgress == .partial ? paleMetaColor : metaColor)
|
||||
case let .sndRcvd(msgRcptStatus, sndProgress):
|
||||
case .sndSent: (Image("checkmark.wide"), metaColor)
|
||||
case let .sndRcvd(msgRcptStatus, _):
|
||||
switch msgRcptStatus {
|
||||
case .ok: (Image("checkmark.2"), sndProgress == .partial ? paleMetaColor : metaColor)
|
||||
case .ok: (Image("checkmark.2"), metaColor)
|
||||
case .badMsgHash: (Image("checkmark.2"), .red)
|
||||
}
|
||||
case .sndErrorAuth: (Image(systemName: "multiply"), .red)
|
||||
@@ -2831,6 +2808,14 @@ public enum CIStatus: Decodable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
public var sndProgress: SndCIStatusProgress? {
|
||||
switch self {
|
||||
case let .sndSent(sndProgress): sndProgress
|
||||
case let .sndRcvd(_ , sndProgress): sndProgress
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
public var statusInfo: (String, String)? {
|
||||
switch self {
|
||||
case .sndNew: return nil
|
||||
|
||||
@@ -391,9 +391,7 @@ public func imageFromBase64(_ base64Encoded: String?) -> UIImage? {
|
||||
return img
|
||||
} else if let data = Data(base64Encoded: dropImagePrefix(base64Encoded)),
|
||||
let img = UIImage(data: data) {
|
||||
imageCacheQueue.async {
|
||||
imageCache.setObject(img, forKey: base64Encoded as NSString)
|
||||
}
|
||||
imageCache.setObject(img, forKey: base64Encoded as NSString)
|
||||
return img
|
||||
} else {
|
||||
return nil
|
||||
@@ -403,8 +401,6 @@ public func imageFromBase64(_ base64Encoded: String?) -> UIImage? {
|
||||
}
|
||||
}
|
||||
|
||||
private let imageCacheQueue = DispatchQueue.global(qos: .background)
|
||||
|
||||
private var imageCache: NSCache<NSString, UIImage> = {
|
||||
var cache = NSCache<NSString, UIImage>()
|
||||
cache.countLimit = 1000
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
#define SimpleX_h
|
||||
|
||||
#include "hs_init.h"
|
||||
#include "objc.h"
|
||||
|
||||
extern void hs_init(int argc, char **argv[]);
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
//
|
||||
// objc.h
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Stanislav Dmitrenko on 09.09.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
#ifndef objc_h
|
||||
#define objc_h
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
@interface ObjC : NSObject
|
||||
|
||||
+ (BOOL)catchException:(void(^)(void))tryBlock error:(__autoreleasing NSError **)error;
|
||||
|
||||
@end
|
||||
|
||||
#endif /* objc_h */
|
||||
@@ -1,25 +0,0 @@
|
||||
//
|
||||
// objc.m
|
||||
// SimpleXChat
|
||||
//
|
||||
// Created by Stanislav Dmitrenko on 09.09.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
#import "objc.h"
|
||||
|
||||
@implementation ObjC
|
||||
|
||||
// https://stackoverflow.com/a/36454808
|
||||
+ (BOOL)catchException:(void(^)(void))tryBlock error:(__autoreleasing NSError **)error {
|
||||
@try {
|
||||
tryBlock();
|
||||
return YES;
|
||||
}
|
||||
@catch (NSException *exception) {
|
||||
*error = [[NSError alloc] initWithDomain: exception.name code: 0 userInfo: exception.userInfo];
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -5,8 +5,9 @@ import android.util.Log
|
||||
import androidx.work.*
|
||||
import chat.simplex.app.SimplexService.Companion.showPassphraseNotification
|
||||
import chat.simplex.common.model.ChatController
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.helpers.DBMigrationResult
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.platform.initChatControllerOnStart
|
||||
import chat.simplex.common.views.helpers.DatabaseUtils
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.Date
|
||||
@@ -29,12 +30,12 @@ object MessagesFetcherWorker {
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.build()
|
||||
|
||||
SimplexApp.context.getWorkManagerInstance().enqueueUniqueWork(UNIQUE_WORK_TAG, ExistingWorkPolicy.REPLACE, periodicWorkRequest)
|
||||
WorkManager.getInstance(SimplexApp.context).enqueueUniqueWork(UNIQUE_WORK_TAG, ExistingWorkPolicy.REPLACE, periodicWorkRequest)
|
||||
}
|
||||
|
||||
fun cancelAll() {
|
||||
Log.d(TAG, "Worker: canceled all tasks")
|
||||
SimplexApp.context.getWorkManagerInstance().cancelUniqueWork(UNIQUE_WORK_TAG)
|
||||
WorkManager.getInstance(SimplexApp.context).cancelUniqueWork(UNIQUE_WORK_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import chat.simplex.common.platform.Log
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.*
|
||||
import androidx.compose.animation.core.*
|
||||
import android.view.View
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -37,7 +37,7 @@ import java.util.concurrent.TimeUnit
|
||||
|
||||
const val TAG = "SIMPLEX"
|
||||
|
||||
class SimplexApp: Application(), LifecycleEventObserver, Configuration.Provider {
|
||||
class SimplexApp: Application(), LifecycleEventObserver {
|
||||
val chatModel: ChatModel
|
||||
get() = chatController.chatModel
|
||||
|
||||
@@ -66,7 +66,7 @@ class SimplexApp: Application(), LifecycleEventObserver, Configuration.Provider
|
||||
}
|
||||
}
|
||||
context = this
|
||||
initHaskell(packageName)
|
||||
initHaskell()
|
||||
initMultiplatform()
|
||||
runMigrations()
|
||||
tmpDir.deleteRecursively()
|
||||
@@ -164,7 +164,7 @@ class SimplexApp: Application(), LifecycleEventObserver, Configuration.Provider
|
||||
.addTag(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
|
||||
.build()
|
||||
Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes")
|
||||
getWorkManagerInstance().enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
|
||||
WorkManager.getInstance(context)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
|
||||
}
|
||||
|
||||
fun schedulePeriodicWakeUp() = CoroutineScope(Dispatchers.Default).launch {
|
||||
@@ -260,6 +260,7 @@ class SimplexApp: Application(), LifecycleEventObserver, Configuration.Provider
|
||||
|
||||
override fun androidSetNightModeIfSupported() {
|
||||
if (Build.VERSION.SDK_INT < 31) return
|
||||
|
||||
val light = if (CurrentColors.value.name == DefaultTheme.SYSTEM_THEME_NAME) {
|
||||
null
|
||||
} else {
|
||||
@@ -274,31 +275,6 @@ class SimplexApp: Application(), LifecycleEventObserver, Configuration.Provider
|
||||
uiModeManager.setApplicationNightMode(mode)
|
||||
}
|
||||
|
||||
override fun androidSetDrawerStatusAndNavBarColor(
|
||||
isLight: Boolean,
|
||||
drawerShadingColor: Color,
|
||||
toolbarOnTop: Boolean,
|
||||
navBarColor: Color,
|
||||
) {
|
||||
val window = mainActivity.get()?.window ?: return
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val windowInsetController = ViewCompat.getWindowInsetsController(window.decorView)
|
||||
// Blend status bar color to the animated color
|
||||
val colors = CurrentColors.value.colors
|
||||
val baseBackgroundColor = if (toolbarOnTop) colors.background.mixWith(colors.onBackground, 0.97f) else colors.background
|
||||
window.statusBarColor = baseBackgroundColor.mixWith(drawerShadingColor.copy(1f), 1 - drawerShadingColor.alpha).toArgb()
|
||||
val navBar = navBarColor.toArgb()
|
||||
|
||||
if (window.navigationBarColor != navBar) {
|
||||
window.navigationBarColor = navBar
|
||||
}
|
||||
|
||||
if (windowInsetController?.isAppearanceLightNavigationBars != isLight) {
|
||||
windowInsetController?.isAppearanceLightNavigationBars = isLight
|
||||
}
|
||||
}
|
||||
|
||||
override fun androidSetStatusAndNavBarColors(isLight: Boolean, backgroundColor: Color, hasTop: Boolean, hasBottom: Boolean) {
|
||||
val window = mainActivity.get()?.window ?: return
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -391,9 +367,4 @@ class SimplexApp: Application(), LifecycleEventObserver, Configuration.Provider
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fix for an exception:
|
||||
// WorkManager is not initialized properly. You have explicitly disabled WorkManagerInitializer in your manifest, have not manually called WorkManager#initialize at this point, and your Application does not implement Configuration.Provider.
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder().build()
|
||||
}
|
||||
|
||||
@@ -292,7 +292,7 @@ class SimplexService: Service() {
|
||||
|
||||
fun scheduleStart(context: Context) {
|
||||
Log.d(TAG, "Enqueuing work to start subscriber service")
|
||||
val workManager = context.getWorkManagerInstance()
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val startServiceRequest = OneTimeWorkRequest.Builder(ServiceStartWorker::class.java).build()
|
||||
workManager.enqueueUniqueWork(WORK_NAME_ONCE, ExistingWorkPolicy.KEEP, startServiceRequest) // Unique avoids races!
|
||||
}
|
||||
|
||||
@@ -99,15 +99,10 @@ kotlin {
|
||||
val desktopMain by getting {
|
||||
dependencies {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.0")
|
||||
implementation("com.github.Dansoftowner:jSystemThemeDetector:3.8") {
|
||||
exclude("net.java.dev.jna")
|
||||
}
|
||||
// For jSystemThemeDetector only
|
||||
implementation("net.java.dev.jna:jna-platform:5.14.0")
|
||||
implementation("com.github.Dansoftowner:jSystemThemeDetector:3.8")
|
||||
implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT")
|
||||
implementation("org.slf4j:slf4j-simple:2.0.12")
|
||||
implementation("uk.co.caprica:vlcj:4.8.3")
|
||||
implementation("net.java.dev.jna:jna:5.14.0")
|
||||
implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:efb2ebf85a")
|
||||
implementation("com.github.NanoHttpd.nanohttpd:nanohttpd-websocket:efb2ebf85a")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
|
||||
+15
-27
@@ -6,8 +6,6 @@ import android.net.LocalServerSocket
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import java.io.*
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
@@ -31,19 +29,22 @@ lateinit var androidAppContext: Context
|
||||
var mainActivity: WeakReference<FragmentActivity> = WeakReference(null)
|
||||
var callActivity: WeakReference<ComponentActivity> = WeakReference(null)
|
||||
|
||||
fun initHaskell(packageName: String) {
|
||||
fun initHaskell() {
|
||||
val socketName = "chat.simplex.app.local.socket.address.listen.native.cmd2" + Random.nextLong(100000)
|
||||
val s = Semaphore(0)
|
||||
thread(name="stdout/stderr pipe") {
|
||||
Log.d(TAG, "starting server")
|
||||
val server: LocalServerSocket
|
||||
try {
|
||||
server = LocalServerSocket(packageName)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
Log.e(TAG, "Unable to setup local server socket. Contact developers")
|
||||
s.release()
|
||||
// Will not have logs from backend
|
||||
return@thread
|
||||
var server: LocalServerSocket? = null
|
||||
for (i in 0..100) {
|
||||
try {
|
||||
server = LocalServerSocket(socketName + i)
|
||||
break
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
if (server == null) {
|
||||
throw Error("Unable to setup local server socket. Contact developers")
|
||||
}
|
||||
Log.d(TAG, "started server")
|
||||
s.release()
|
||||
@@ -57,7 +58,7 @@ fun initHaskell(packageName: String) {
|
||||
Log.d(TAG, "starting receiver loop")
|
||||
while (true) {
|
||||
val line = input.readLine() ?: break
|
||||
Log.w(TAG, "(stdout/stderr) $line")
|
||||
Log.w("$TAG (stdout/stderr)", line)
|
||||
logbuffer.add(line)
|
||||
}
|
||||
Log.w(TAG, "exited receiver loop")
|
||||
@@ -67,20 +68,7 @@ fun initHaskell(packageName: String) {
|
||||
System.loadLibrary("app-lib")
|
||||
|
||||
s.acquire()
|
||||
pipeStdOutToSocket(packageName)
|
||||
pipeStdOutToSocket(socketName)
|
||||
|
||||
initHS()
|
||||
}
|
||||
|
||||
fun Context.getWorkManagerInstance(): WorkManager {
|
||||
// https://github.com/OneSignal/OneSignal-Android-SDK/pull/2052/files
|
||||
// https://github.com/OneSignal/OneSignal-Android-SDK/issues/1672
|
||||
if (!WorkManager.isInitialized()) {
|
||||
try {
|
||||
WorkManager.initialize(this, Configuration.Builder().build())
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.e(TAG, "Error initializing WorkManager: ${e.stackTraceToString()}")
|
||||
}
|
||||
}
|
||||
return WorkManager.getInstance(this)
|
||||
}
|
||||
|
||||
-222
@@ -1,222 +0,0 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
|
||||
import SectionItemView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.DrawerDefaults.ScrimOpacity
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.User
|
||||
import chat.simplex.common.model.UserInfo
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@Composable
|
||||
actual fun UserPickerInactiveUsersSection(
|
||||
users: List<UserInfo>,
|
||||
stopped: Boolean,
|
||||
onShowAllProfilesClicked: () -> Unit,
|
||||
onUserClicked: (user: User) -> Unit,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
if (users.isNotEmpty()) {
|
||||
SectionItemView(
|
||||
padding = PaddingValues(
|
||||
start = 16.dp,
|
||||
top = if (windowOrientation() == WindowOrientation.PORTRAIT) DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL else DEFAULT_PADDING_HALF,
|
||||
bottom = DEFAULT_PADDING_HALF),
|
||||
disabled = stopped
|
||||
) {
|
||||
Box {
|
||||
Row(
|
||||
modifier = Modifier.padding(end = DEFAULT_PADDING + 30.dp).horizontalScroll(scrollState)
|
||||
) {
|
||||
users.forEach { u ->
|
||||
UserPickerInactiveUserBadge(u, stopped) {
|
||||
onUserClicked(it)
|
||||
withBGApi {
|
||||
delay(500)
|
||||
scrollState.scrollTo(0)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(20.dp))
|
||||
}
|
||||
Spacer(Modifier.width(60.dp))
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = DEFAULT_PADDING + 30.dp)
|
||||
.height(60.dp)
|
||||
) {
|
||||
Canvas(modifier = Modifier.size(60.dp)) {
|
||||
drawRect(
|
||||
brush = Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
CurrentColors.value.colors.surface,
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.height(60.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(end = DEFAULT_PADDING)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onShowAllProfilesClicked,
|
||||
enabled = !stopped
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_chevron_right),
|
||||
stringResource(MR.strings.your_chat_profiles),
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier.size(34.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
UserPickerOptionRow(
|
||||
painterResource(MR.images.ic_manage_accounts),
|
||||
stringResource(MR.strings.your_chat_profiles),
|
||||
onShowAllProfilesClicked
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateFraction(pos: Float) =
|
||||
(pos / 1f).coerceIn(0f, 1f)
|
||||
|
||||
@Composable
|
||||
actual fun PlatformUserPicker(modifier: Modifier, pickerState: MutableStateFlow<AnimatedViewState>, content: @Composable () -> Unit) {
|
||||
val pickerIsVisible = pickerState.collectAsState().value.isVisible()
|
||||
val dismissState = rememberDismissState(initialValue = if (pickerIsVisible) DismissValue.Default else DismissValue.DismissedToEnd) {
|
||||
if (it == DismissValue.DismissedToEnd && pickerState.value.isVisible()) {
|
||||
pickerState.value = AnimatedViewState.HIDING
|
||||
}
|
||||
true
|
||||
}
|
||||
val height = remember { mutableIntStateOf(0) }
|
||||
val heightValue = height.intValue
|
||||
val clickableModifier = if (pickerIsVisible) {
|
||||
Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { pickerState.value = AnimatedViewState.HIDING })
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.then(clickableModifier)
|
||||
.drawBehind {
|
||||
val pos = when {
|
||||
dismissState.progress.from == DismissValue.Default && dismissState.progress.to == DismissValue.Default -> 1f
|
||||
dismissState.progress.from == DismissValue.DismissedToEnd && dismissState.progress.to == DismissValue.DismissedToEnd -> 0f
|
||||
dismissState.progress.to == DismissValue.Default -> dismissState.progress.fraction
|
||||
else -> 1 - dismissState.progress.fraction
|
||||
}
|
||||
val colors = CurrentColors.value.colors
|
||||
val resultingColor = if (colors.isLight) colors.onSurface.copy(alpha = ScrimOpacity) else Color.Black.copy(0.64f)
|
||||
val adjustedAlpha = resultingColor.alpha * calculateFraction(pos = pos)
|
||||
val shadingColor = resultingColor.copy(alpha = adjustedAlpha)
|
||||
|
||||
if (pickerState.value.isVisible()) {
|
||||
platform.androidSetDrawerStatusAndNavBarColor(
|
||||
isLight = colors.isLight,
|
||||
drawerShadingColor = shadingColor,
|
||||
toolbarOnTop = !appPrefs.oneHandUI.get(),
|
||||
navBarColor = colors.surface
|
||||
)
|
||||
} else if (ModalManager.start.modalCount.value == 0) {
|
||||
platform.androidSetDrawerStatusAndNavBarColor(
|
||||
isLight = colors.isLight,
|
||||
drawerShadingColor = shadingColor,
|
||||
toolbarOnTop = !appPrefs.oneHandUI.get(),
|
||||
navBarColor = (if (appPrefs.oneHandUI.get() && appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
|
||||
colors.background.mixWith(CurrentColors.value.colors.onBackground, 0.97f)
|
||||
} else {
|
||||
colors.background
|
||||
})
|
||||
)
|
||||
}
|
||||
drawRect(
|
||||
if (pos != 0f) resultingColor else Color.Transparent,
|
||||
alpha = calculateFraction(pos = pos)
|
||||
)
|
||||
}
|
||||
.graphicsLayer {
|
||||
if (heightValue == 0) {
|
||||
alpha = 0f
|
||||
}
|
||||
translationY = dismissState.offset.value
|
||||
},
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Box(
|
||||
Modifier.onSizeChanged { height.intValue = it.height }
|
||||
) {
|
||||
KeyChangeEffect(pickerIsVisible) {
|
||||
if (pickerState.value.isVisible()) {
|
||||
try {
|
||||
dismissState.animateTo(DismissValue.Default, userPickerAnimSpec())
|
||||
} catch (e: CancellationException) {
|
||||
Log.e(TAG, "Cancelled animateTo: ${e.stackTraceToString()}")
|
||||
pickerState.value = AnimatedViewState.GONE
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
dismissState.animateTo(DismissValue.DismissedToEnd, userPickerAnimSpec())
|
||||
} catch (e: CancellationException) {
|
||||
Log.e(TAG, "Cancelled animateTo2: ${e.stackTraceToString()}")
|
||||
pickerState.value = AnimatedViewState.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
val draggableModifier = if (height.intValue != 0)
|
||||
Modifier.draggableBottomDrawerModifier(
|
||||
state = dismissState,
|
||||
swipeDistance = height.intValue.toFloat(),
|
||||
)
|
||||
else Modifier
|
||||
Box(draggableModifier.then(modifier)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.draggableBottomDrawerModifier(
|
||||
state: DismissState,
|
||||
swipeDistance: Float,
|
||||
): Modifier = this.swipeable(
|
||||
state = state,
|
||||
anchors = mapOf(0f to DismissValue.Default, swipeDistance to DismissValue.DismissedToEnd),
|
||||
thresholds = { _, _ -> FractionalThreshold(0.3f) },
|
||||
orientation = Orientation.Vertical,
|
||||
resistance = null
|
||||
)
|
||||
+2
-1
@@ -2,6 +2,7 @@ package chat.simplex.common.views.usersettings
|
||||
|
||||
import SectionView
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.work.WorkManager
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
@@ -32,7 +33,7 @@ fun restartApp() {
|
||||
}
|
||||
|
||||
private fun shutdownApp() {
|
||||
androidAppContext.getWorkManagerInstance().cancelAllWork()
|
||||
WorkManager.getInstance(androidAppContext).cancelAllWork()
|
||||
platform.androidServiceSafeStop()
|
||||
Runtime.getRuntime().exit(0)
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@ import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
@@ -39,6 +42,12 @@ import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlin.math.sqrt
|
||||
|
||||
data class SettingsViewState(
|
||||
val userPickerState: MutableStateFlow<AnimatedViewState>,
|
||||
val scaffoldState: ScaffoldState
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AppScreen() {
|
||||
@@ -136,11 +145,13 @@ fun MainScreen() {
|
||||
userPickerState.value = AnimatedViewState.VISIBLE
|
||||
}
|
||||
}
|
||||
val scaffoldState = rememberScaffoldState()
|
||||
val settingsState = remember { SettingsViewState(userPickerState, scaffoldState) }
|
||||
SetupClipboardListener()
|
||||
if (appPlatform.isAndroid) {
|
||||
AndroidScreen(userPickerState)
|
||||
AndroidScreen(settingsState)
|
||||
} else {
|
||||
DesktopScreen(userPickerState)
|
||||
DesktopScreen(settingsState)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,7 +249,7 @@ fun MainScreen() {
|
||||
val ANDROID_CALL_TOP_PADDING = 40.dp
|
||||
|
||||
@Composable
|
||||
fun AndroidScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
|
||||
fun AndroidScreen(settingsState: SettingsViewState) {
|
||||
BoxWithConstraints {
|
||||
val call = remember { chatModel.activeCall} .value
|
||||
val showCallArea = call != null && call.callState != CallState.WaitCapabilities && call.callState != CallState.InvitationAccepted
|
||||
@@ -251,7 +262,7 @@ fun AndroidScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
|
||||
}
|
||||
.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
|
||||
) {
|
||||
StartPartOfScreen(userPickerState)
|
||||
StartPartOfScreen(settingsState)
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val onComposed: suspend (chatId: String?) -> Unit = { chatId ->
|
||||
@@ -307,15 +318,15 @@ fun AndroidScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StartPartOfScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
|
||||
fun StartPartOfScreen(settingsState: SettingsViewState) {
|
||||
if (chatModel.setDeliveryReceipts.value) {
|
||||
SetDeliveryReceiptsView(chatModel)
|
||||
} else {
|
||||
val stopped = chatModel.chatRunning.value == false
|
||||
if (chatModel.sharedContent.value == null)
|
||||
ChatListView(chatModel, userPickerState, AppLock::setPerformLA, stopped)
|
||||
ChatListView(chatModel, settingsState, AppLock::setPerformLA, stopped)
|
||||
else
|
||||
ShareListView(chatModel, stopped)
|
||||
ShareListView(chatModel, settingsState, stopped)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,41 +367,49 @@ fun EndPartOfScreen() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DesktopScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
|
||||
Box(Modifier.width(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
|
||||
StartPartOfScreen(userPickerState)
|
||||
fun DesktopScreen(settingsState: SettingsViewState) {
|
||||
Box {
|
||||
// 56.dp is a size of unused space of settings drawer
|
||||
Box(Modifier.width(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier + 56.dp)) {
|
||||
StartPartOfScreen(settingsState)
|
||||
}
|
||||
Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
|
||||
ModalManager.start.showInView()
|
||||
SwitchingUsersView()
|
||||
}
|
||||
Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
|
||||
Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) {
|
||||
CenterPartOfScreen()
|
||||
}
|
||||
if (ModalManager.end.hasModalsOpen()) {
|
||||
VerticalDivider()
|
||||
}
|
||||
Box(Modifier.widthIn(max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
|
||||
EndPartOfScreen()
|
||||
}
|
||||
}
|
||||
val (userPickerState, scaffoldState ) = settingsState
|
||||
val scope = rememberCoroutineScope()
|
||||
if (scaffoldState.drawerState.isOpen || (ModalManager.start.hasModalsOpen && !ModalManager.center.hasModalsOpen)) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = {
|
||||
ModalManager.start.closeModals()
|
||||
scope.launch { settingsState.scaffoldState.drawerState.close() }
|
||||
})
|
||||
)
|
||||
}
|
||||
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier))
|
||||
tryOrShowError("UserPicker", error = {}) {
|
||||
UserPicker(chatModel, userPickerState, setPerformLA = AppLock::setPerformLA)
|
||||
UserPicker(chatModel, userPickerState) {
|
||||
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
|
||||
userPickerState.value = AnimatedViewState.GONE
|
||||
}
|
||||
}
|
||||
ModalManager.fullscreen.showInView()
|
||||
}
|
||||
Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
|
||||
ModalManager.start.showInView()
|
||||
SwitchingUsersView()
|
||||
}
|
||||
Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
|
||||
Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) {
|
||||
CenterPartOfScreen()
|
||||
}
|
||||
if (ModalManager.end.hasModalsOpen()) {
|
||||
VerticalDivider()
|
||||
}
|
||||
Box(Modifier.widthIn(max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
|
||||
EndPartOfScreen()
|
||||
}
|
||||
}
|
||||
if (userPickerState.collectAsState().value.isVisible() || (ModalManager.start.hasModalsOpen && !ModalManager.center.hasModalsOpen)) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = {
|
||||
ModalManager.start.closeModals()
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
})
|
||||
)
|
||||
}
|
||||
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier))
|
||||
ModalManager.fullscreen.showInView()
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
-10
@@ -1480,13 +1480,6 @@ object ChatController {
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun apiChatItemsRead(rh: Long?, type: ChatType, id: Long, itemIds: List<Long>): Boolean {
|
||||
val r = sendCmd(rh, CC.ApiChatItemsRead(type, id, itemIds))
|
||||
if (r is CR.CmdOk) return true
|
||||
Log.e(TAG, "apiChatItemsRead bad response: ${r.responseType} ${r.details}")
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun apiChatUnread(rh: Long?, type: ChatType, id: Long, unreadChat: Boolean): Boolean {
|
||||
val r = sendCmd(rh, CC.ApiChatUnread(type, id, unreadChat))
|
||||
if (r is CR.CmdOk) return true
|
||||
@@ -2974,7 +2967,6 @@ sealed class CC {
|
||||
class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC()
|
||||
class ApiRejectContact(val contactReqId: Long): CC()
|
||||
class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC()
|
||||
class ApiChatItemsRead(val type: ChatType, val id: Long, val itemIds: List<Long>): CC()
|
||||
class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC()
|
||||
class ReceiveFile(val fileId: Long, val userApprovedRelays: Boolean, val encrypt: Boolean, val inline: Boolean?): CC()
|
||||
class CancelFile(val fileId: Long): CC()
|
||||
@@ -3131,7 +3123,6 @@ sealed class CC {
|
||||
is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}"
|
||||
is ApiGetNetworkStatuses -> "/_network_statuses"
|
||||
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
|
||||
is ApiChatItemsRead -> "/_read chat items ${chatRef(type, id)} ${itemIds.joinToString(",")}"
|
||||
is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}"
|
||||
is ReceiveFile ->
|
||||
"/freceive $fileId" +
|
||||
@@ -3275,7 +3266,6 @@ sealed class CC {
|
||||
is ApiCallStatus -> "apiCallStatus"
|
||||
is ApiGetNetworkStatuses -> "apiGetNetworkStatuses"
|
||||
is ApiChatRead -> "apiChatRead"
|
||||
is ApiChatItemsRead -> "apiChatItemsRead"
|
||||
is ApiChatUnread -> "apiChatUnread"
|
||||
is ReceiveFile -> "receiveFile"
|
||||
is CancelFile -> "cancelFile"
|
||||
|
||||
+2
-2
@@ -1,6 +1,7 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationVector1D
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.*
|
||||
@@ -21,7 +22,6 @@ interface PlatformInterface {
|
||||
fun androidIsBackgroundCallAllowed(): Boolean = true
|
||||
fun androidSetNightModeIfSupported() {}
|
||||
fun androidSetStatusAndNavBarColors(isLight: Boolean, backgroundColor: Color, hasTop: Boolean, hasBottom: Boolean) {}
|
||||
fun androidSetDrawerStatusAndNavBarColor(isLight: Boolean, drawerShadingColor: Color, toolbarOnTop: Boolean, navBarColor: Color) {}
|
||||
fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {}
|
||||
fun androidPictureInPictureAllowed(): Boolean = true
|
||||
fun androidCallEnded() {}
|
||||
|
||||
+1
@@ -109,6 +109,7 @@ fun TerminalLayout(
|
||||
}
|
||||
},
|
||||
contentColor = LocalContentColor.current,
|
||||
drawerContentColor = LocalContentColor.current,
|
||||
modifier = Modifier.navigationBarsWithImePadding()
|
||||
) { contentPadding ->
|
||||
Surface(
|
||||
|
||||
+2
-7
@@ -105,7 +105,6 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
|
||||
is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> {
|
||||
val perChatTheme = remember(chatInfo, CurrentColors.value.base) { if (chatInfo is ChatInfo.Direct) chatInfo.contact.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else null }
|
||||
val overrides = if (perChatTheme != null) ThemeManager.currentColors(null, perChatTheme, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) else null
|
||||
val fullDeleteAllowed = remember(chatInfo) { chatInfo.featureEnabled(ChatFeature.FullDelete) }
|
||||
SimpleXThemeOverride(overrides ?: CurrentColors.collectAsState().value) {
|
||||
ChatLayout(
|
||||
remoteHostId = remoteHostId,
|
||||
@@ -143,15 +142,10 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
|
||||
chatInfo = chatInfo,
|
||||
deleteItems = { canDeleteForAll ->
|
||||
val itemIds = selectedChatItems.value
|
||||
val questionText =
|
||||
if (!canDeleteForAll || fullDeleteAllowed || chatInfo is ChatInfo.Local)
|
||||
generalGetString(MR.strings.delete_messages_cannot_be_undone_warning)
|
||||
else
|
||||
generalGetString(MR.strings.delete_messages_mark_deleted_warning)
|
||||
if (itemIds != null) {
|
||||
deleteMessagesAlertDialog(
|
||||
itemIds.sorted(),
|
||||
questionText = questionText,
|
||||
generalGetString(if (itemIds.size == 1) MR.strings.delete_message_mark_deleted_warning else MR.strings.delete_messages_mark_deleted_warning),
|
||||
forAll = canDeleteForAll,
|
||||
deleteMessages = { ids, forAll ->
|
||||
deleteMessages(chatRh, chatInfo, ids, forAll, moderate = false) {
|
||||
@@ -660,6 +654,7 @@ fun ChatLayout(
|
||||
modifier = Modifier.navigationBarsWithImePadding(),
|
||||
floatingActionButton = { floatingButton.value() },
|
||||
contentColor = LocalContentColor.current,
|
||||
drawerContentColor = LocalContentColor.current,
|
||||
backgroundColor = Color.Unspecified
|
||||
) { contentPadding ->
|
||||
val wallpaperImage = MaterialTheme.wallpaper.type.image
|
||||
|
||||
+2
-2
@@ -135,7 +135,7 @@ fun ChatItemView(
|
||||
}
|
||||
|
||||
fun deleteMessageQuestionText(): String {
|
||||
return if (!sent || fullDeleteAllowed || cInfo is ChatInfo.Local) {
|
||||
return if (!sent || fullDeleteAllowed) {
|
||||
generalGetString(MR.strings.delete_message_cannot_be_undone_warning)
|
||||
} else {
|
||||
generalGetString(MR.strings.delete_message_mark_deleted_warning)
|
||||
@@ -637,7 +637,7 @@ fun DeleteItemAction(
|
||||
}
|
||||
deleteMessagesAlertDialog(
|
||||
itemIds,
|
||||
generalGetString(MR.strings.delete_messages_cannot_be_undone_warning),
|
||||
generalGetString(if (itemIds.size == 1) MR.strings.delete_message_mark_deleted_warning else MR.strings.delete_messages_mark_deleted_warning),
|
||||
forAll = false,
|
||||
deleteMessages = { ids, _ -> deleteMessages(ids) }
|
||||
)
|
||||
|
||||
+1
-1
@@ -186,7 +186,7 @@ fun ErrorChatListItem() {
|
||||
|
||||
suspend fun directChatAction(rhId: Long?, contact: Contact, chatModel: ChatModel) {
|
||||
when {
|
||||
contact.activeConn == null && contact.profile.contactLink != null && contact.active -> askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close = null, openChat = true)
|
||||
contact.activeConn == null && contact.profile.contactLink != null -> askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close = null, openChat = true)
|
||||
else -> openChat(rhId, ChatInfo.Direct(contact), chatModel)
|
||||
}
|
||||
}
|
||||
|
||||
+40
-17
@@ -23,7 +23,7 @@ import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.AppLock
|
||||
import chat.simplex.common.SettingsViewState
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
|
||||
@@ -135,7 +135,7 @@ fun ToggleChatListCard() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
|
||||
fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
LaunchedEffect(Unit) {
|
||||
if (shouldShowWhatsNew(chatModel)) {
|
||||
@@ -153,15 +153,18 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
|
||||
VideoPlayerHolder.stopAll()
|
||||
}
|
||||
}
|
||||
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
|
||||
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val (userPickerState, scaffoldState ) = settingsState
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (!oneHandUI.value) {
|
||||
Column {
|
||||
Column(Modifier.padding(end = endPadding)) {
|
||||
ChatListToolbar(
|
||||
scaffoldState.drawerState,
|
||||
userPickerState,
|
||||
stopped,
|
||||
setPerformLA,
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
@@ -169,17 +172,33 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
|
||||
},
|
||||
bottomBar = {
|
||||
if (oneHandUI.value) {
|
||||
Column {
|
||||
Column(Modifier.padding(end = endPadding)) {
|
||||
Divider()
|
||||
ChatListToolbar(
|
||||
scaffoldState.drawerState,
|
||||
userPickerState,
|
||||
stopped,
|
||||
setPerformLA,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
scaffoldState = scaffoldState,
|
||||
drawerContent = {
|
||||
tryOrShowError("Settings", error = { ErrorSettingsView() }) {
|
||||
val handler = remember { AppBarHandler() }
|
||||
CompositionLocalProvider(
|
||||
LocalAppBarHandler provides handler
|
||||
) {
|
||||
ModalView(showClose = appPlatform.isDesktop, close = { scope.launch { scaffoldState.drawerState.close() } }) {
|
||||
SettingsView(chatModel, setPerformLA, scaffoldState.drawerState)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
contentColor = LocalContentColor.current,
|
||||
drawerContentColor = LocalContentColor.current,
|
||||
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
|
||||
drawerGesturesEnabled = appPlatform.isAndroid,
|
||||
floatingActionButton = {
|
||||
if (!oneHandUI.value && searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
|
||||
FloatingActionButton(
|
||||
@@ -189,7 +208,7 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
|
||||
}
|
||||
},
|
||||
Modifier
|
||||
.padding(end = DEFAULT_PADDING - 16.dp, bottom = DEFAULT_PADDING - 16.dp)
|
||||
.padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = DEFAULT_PADDING - 16.dp)
|
||||
.size(AppBarHeight * fontSizeSqrtMultiplier),
|
||||
elevation = FloatingActionButtonDefaults.elevation(
|
||||
defaultElevation = 0.dp,
|
||||
@@ -205,7 +224,7 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(Modifier.padding(it)) {
|
||||
Box(Modifier.padding(it).padding(end = endPadding)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -233,8 +252,11 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
|
||||
UserPicker(
|
||||
chatModel = chatModel,
|
||||
userPickerState = userPickerState,
|
||||
setPerformLA = AppLock::setPerformLA
|
||||
)
|
||||
contentAlignment = if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart
|
||||
) {
|
||||
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
|
||||
userPickerState.value = AnimatedViewState.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,7 +278,7 @@ private fun ConnectButton(text: String, onClick: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean, setPerformLA: (Boolean) -> Unit) {
|
||||
private fun ChatListToolbar(drawerState: DrawerState, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean) {
|
||||
val serversSummary: MutableState<PresentedServersSummary?> = remember { mutableStateOf(null) }
|
||||
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
||||
val updatingProgress = remember { chatModel.updatingProgress }.value
|
||||
@@ -322,22 +344,23 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>
|
||||
}
|
||||
}
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val clipboard = LocalClipboardManager.current
|
||||
DefaultTopAppBar(
|
||||
navigationButton = {
|
||||
if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) {
|
||||
NavigationButtonMenu {
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
SettingsView(chatModel, setPerformLA, close)
|
||||
}
|
||||
}
|
||||
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
|
||||
} else {
|
||||
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
|
||||
val allRead = users
|
||||
.filter { u -> !u.user.activeUser && !u.user.hidden }
|
||||
.all { u -> u.unreadCount == 0 }
|
||||
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
|
||||
if (users.size == 1 && chatModel.remoteHosts.isEmpty()) {
|
||||
scope.launch { drawerState.open() }
|
||||
} else {
|
||||
userPickerState.value = AnimatedViewState.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+28
-23
@@ -11,24 +11,31 @@ import androidx.compose.ui.graphics.Color
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.SettingsViewState
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.newchat.ActiveProfilePicker
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@Composable
|
||||
fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
|
||||
fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) {
|
||||
var searchInList by rememberSaveable { mutableStateOf("") }
|
||||
val (userPickerState, scaffoldState) = settingsState
|
||||
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
|
||||
Scaffold(
|
||||
Modifier.padding(end = endPadding),
|
||||
contentColor = LocalContentColor.current,
|
||||
drawerContentColor = LocalContentColor.current,
|
||||
scaffoldState = scaffoldState,
|
||||
topBar = {
|
||||
if (!oneHandUI.value) {
|
||||
Column {
|
||||
ShareListToolbar(chatModel, stopped) { searchInList = it.trim() }
|
||||
ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() }
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
@@ -37,7 +44,7 @@ fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
|
||||
if (oneHandUI.value) {
|
||||
Column {
|
||||
Divider()
|
||||
ShareListToolbar(chatModel, stopped) { searchInList = it.trim() }
|
||||
ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +92,21 @@ fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (appPlatform.isAndroid) {
|
||||
tryOrShowError("UserPicker", error = {}) {
|
||||
UserPicker(
|
||||
chatModel,
|
||||
userPickerState,
|
||||
showSettings = false,
|
||||
showCancel = true,
|
||||
contentAlignment = if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart,
|
||||
cancelClicked = {
|
||||
chatModel.sharedContent.value = null
|
||||
userPickerState.value = AnimatedViewState.GONE
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasSimplexLink(msg: String): Boolean {
|
||||
@@ -100,7 +122,7 @@ private fun EmptyList() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
|
||||
private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
|
||||
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
|
||||
if (showSearch) {
|
||||
@@ -116,24 +138,7 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
|
||||
.filter { u -> !u.user.activeUser && !u.user.hidden }
|
||||
.all { u -> u.unreadCount == 0 }
|
||||
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
|
||||
ModalManager.start.showCustomModal { close ->
|
||||
val search = rememberSaveable { mutableStateOf("") }
|
||||
ModalView(
|
||||
{ close() },
|
||||
endButtons = {
|
||||
SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
|
||||
},
|
||||
content = {
|
||||
ActiveProfilePicker(
|
||||
search = search,
|
||||
rhId = chatModel.remoteHostId,
|
||||
close = close,
|
||||
contactConnection = null,
|
||||
showIncognito = false
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
userPickerState.value = AnimatedViewState.VISIBLE
|
||||
}
|
||||
}
|
||||
else -> NavigationButtonBack(onButtonClicked = {
|
||||
|
||||
+234
-384
@@ -1,51 +1,53 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
|
||||
import SectionItemView
|
||||
import SectionView
|
||||
import TextIconSpaced
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.draw.*
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
|
||||
import chat.simplex.common.model.ChatModel.controller
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.CreateProfile
|
||||
import chat.simplex.common.views.localauth.VerticalDivider
|
||||
import chat.simplex.common.views.newchat.*
|
||||
import chat.simplex.common.views.remote.*
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.common.views.usersettings.AppearanceScope.ColorModeSwitcher
|
||||
import chat.simplex.common.views.usersettings.doWithAuth
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun UserPicker(
|
||||
chatModel: ChatModel,
|
||||
userPickerState: MutableStateFlow<AnimatedViewState>,
|
||||
setPerformLA: (Boolean) -> Unit,
|
||||
showSettings: Boolean = true,
|
||||
contentAlignment: Alignment = Alignment.TopStart,
|
||||
showCancel: Boolean = false,
|
||||
cancelClicked: () -> Unit = {},
|
||||
useFromDesktopClicked: () -> Unit = {},
|
||||
settingsClicked: () -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var newChat by remember { mutableStateOf(userPickerState.value) }
|
||||
if (newChat.isVisible()) {
|
||||
BackHandler {
|
||||
@@ -65,32 +67,18 @@ fun UserPicker(
|
||||
.sortedBy { it.hostDeviceName }
|
||||
}
|
||||
}
|
||||
|
||||
val view = LocalMultiplatformView()
|
||||
val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) }
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
userPickerState.collect {
|
||||
newChat = it
|
||||
if (it.isVisible()) {
|
||||
hideKeyboard(view)
|
||||
}
|
||||
launch {
|
||||
animatedFloat.animateTo(if (newChat.isVisible()) 1f else 0f, newChatSheetAnimSpec())
|
||||
if (newChat.isHiding()) userPickerState.value = AnimatedViewState.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
snapshotFlow { ModalManager.start.modalCount.value }
|
||||
.filter { it > 0 }
|
||||
.collect {
|
||||
closePicker(userPickerState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { newChat.isVisible() }
|
||||
.distinctUntilChanged()
|
||||
@@ -136,143 +124,110 @@ fun UserPicker(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PlatformUserPicker(
|
||||
modifier = Modifier
|
||||
.height(IntrinsicSize.Min)
|
||||
.fillMaxWidth()
|
||||
.then(if (newChat.isVisible()) Modifier.shadow(8.dp, clip = true) else Modifier)
|
||||
.background(MaterialTheme.colors.surface)
|
||||
.padding(vertical = DEFAULT_PADDING),
|
||||
pickerState = userPickerState
|
||||
val UsersView: @Composable ColumnScope.() -> Unit = {
|
||||
users.forEach { u ->
|
||||
UserProfilePickerItem(u.user, u.unreadCount, openSettings = settingsClicked) {
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
if (!u.user.activeUser) {
|
||||
withBGApi {
|
||||
controller.showProgressIfNeeded {
|
||||
ModalManager.closeAllModalsEverywhere()
|
||||
chatModel.controller.changeActiveUser(u.user.remoteHostId, u.user.userId, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider(Modifier.requiredHeight(1.dp))
|
||||
if (u.user.activeUser) Divider(Modifier.requiredHeight(0.5.dp))
|
||||
}
|
||||
}
|
||||
val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() }
|
||||
val maxWidth = with(LocalDensity.current) { windowWidth() * density }
|
||||
Box(Modifier
|
||||
.fillMaxSize()
|
||||
.offset { IntOffset(if (newChat.isGone()) -maxWidth.value.roundToInt() else xOffset, 0) }
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { userPickerState.value = AnimatedViewState.HIDING })
|
||||
.padding(bottom = 10.dp, top = 10.dp)
|
||||
.graphicsLayer {
|
||||
alpha = animatedFloat.value
|
||||
translationY = (if (appPrefs.oneHandUI.state.value) -1 else 1) * (animatedFloat.value - 1) * xOffset
|
||||
},
|
||||
contentAlignment = contentAlignment
|
||||
) {
|
||||
@Composable
|
||||
fun FirstSection() {
|
||||
if (remoteHosts.isNotEmpty()) {
|
||||
val currentRemoteHost = remember { chatModel.currentRemoteHost }.value
|
||||
val localDeviceActive = currentRemoteHost == null && chatModel.localUserCreated.value == true
|
||||
Column(
|
||||
Modifier
|
||||
.widthIn(min = 260.dp)
|
||||
.width(IntrinsicSize.Min)
|
||||
.height(IntrinsicSize.Min)
|
||||
.shadow(8.dp, RoundedCornerShape(corner = CornerSize(25.dp)), clip = true)
|
||||
.background(MaterialTheme.colors.surface, RoundedCornerShape(corner = CornerSize(25.dp)))
|
||||
.clip(RoundedCornerShape(corner = CornerSize(25.dp)))
|
||||
) {
|
||||
val currentRemoteHost = remember { chatModel.currentRemoteHost }.value
|
||||
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
|
||||
if (remoteHosts.isNotEmpty()) {
|
||||
if (currentRemoteHost == null && chatModel.localUserCreated.value == true) {
|
||||
LocalDevicePickerItem(true) {
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
switchToLocalDevice()
|
||||
}
|
||||
Divider(Modifier.requiredHeight(1.dp))
|
||||
} else if (currentRemoteHost != null) {
|
||||
val connecting = rememberSaveable { mutableStateOf(false) }
|
||||
RemoteHostPickerItem(currentRemoteHost,
|
||||
actionButtonClick = {
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
stopRemoteHostAndReloadHosts(currentRemoteHost, true)
|
||||
}) {
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
switchToRemoteHost(currentRemoteHost, connecting)
|
||||
}
|
||||
Divider(Modifier.requiredHeight(1.dp))
|
||||
}
|
||||
}
|
||||
|
||||
DevicePickerRow(
|
||||
localDeviceActive = localDeviceActive,
|
||||
remoteHosts = remoteHosts,
|
||||
onRemoteHostClick = { h, connecting ->
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
switchToRemoteHost(h, connecting)
|
||||
},
|
||||
onLocalDeviceClick = {
|
||||
UsersView()
|
||||
|
||||
if (remoteHosts.isNotEmpty() && currentRemoteHost != null && chatModel.localUserCreated.value == true) {
|
||||
LocalDevicePickerItem(false) {
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
switchToLocalDevice()
|
||||
},
|
||||
onRemoteHostActionButtonClick = { h ->
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
stopRemoteHostAndReloadHosts(h, true)
|
||||
}
|
||||
)
|
||||
Divider(Modifier.requiredHeight(1.dp))
|
||||
}
|
||||
remoteHosts.filter { !it.activeHost }.forEach { h ->
|
||||
val connecting = rememberSaveable { mutableStateOf(false) }
|
||||
RemoteHostPickerItem(h,
|
||||
actionButtonClick = {
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
stopRemoteHostAndReloadHosts(h, false)
|
||||
}) {
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
switchToRemoteHost(h, connecting)
|
||||
}
|
||||
Divider(Modifier.requiredHeight(1.dp))
|
||||
}
|
||||
}
|
||||
ActiveUserSection(
|
||||
chatModel = chatModel,
|
||||
userPickerState = userPickerState,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SecondSection() {
|
||||
GlobalSettingsSection(
|
||||
chatModel = chatModel,
|
||||
userPickerState = userPickerState,
|
||||
setPerformLA = setPerformLA,
|
||||
onUserClicked = { user ->
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
if (!user.activeUser) {
|
||||
withBGApi {
|
||||
controller.showProgressIfNeeded {
|
||||
ModalManager.closeAllModalsEverywhere()
|
||||
chatModel.controller.changeActiveUser(user.remoteHostId, user.userId, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onShowAllProfilesClicked = {
|
||||
doWithAuth(
|
||||
generalGetString(MR.strings.auth_open_chat_profiles),
|
||||
generalGetString(MR.strings.auth_log_in_using_credential)
|
||||
) {
|
||||
ModalManager.start.showCustomModal { close ->
|
||||
val search = rememberSaveable { mutableStateOf("") }
|
||||
val profileHidden = rememberSaveable { mutableStateOf(false) }
|
||||
ModalView(
|
||||
{ close() },
|
||||
endButtons = {
|
||||
SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
|
||||
},
|
||||
content = { UserProfilesView(chatModel, search, profileHidden) })
|
||||
if (appPlatform.isAndroid) {
|
||||
UseFromDesktopPickerItem {
|
||||
ModalManager.start.showCustomModal { close ->
|
||||
ConnectDesktopView(close)
|
||||
}
|
||||
userPickerState.value = AnimatedViewState.GONE
|
||||
}
|
||||
Divider(Modifier.requiredHeight(1.dp))
|
||||
} else {
|
||||
if (remoteHosts.isEmpty()) {
|
||||
LinkAMobilePickerItem {
|
||||
ModalManager.start.showModal {
|
||||
ConnectMobileView()
|
||||
}
|
||||
userPickerState.value = AnimatedViewState.GONE
|
||||
}
|
||||
Divider(Modifier.requiredHeight(1.dp))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (appPlatform.isDesktop || windowOrientation() == WindowOrientation.PORTRAIT) {
|
||||
Column {
|
||||
FirstSection()
|
||||
Divider(Modifier.padding(DEFAULT_PADDING))
|
||||
SecondSection()
|
||||
}
|
||||
} else {
|
||||
Row {
|
||||
Box(Modifier.weight(1f)) {
|
||||
FirstSection()
|
||||
}
|
||||
VerticalDivider()
|
||||
Box(Modifier.weight(1f)) {
|
||||
SecondSection()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveUserSection(
|
||||
chatModel: ChatModel,
|
||||
userPickerState: MutableStateFlow<AnimatedViewState>,
|
||||
) {
|
||||
val showCustomModal: (@Composable() (ModalData.(ChatModel, () -> Unit) -> Unit)) -> () -> Unit = { modalView ->
|
||||
{
|
||||
ModalManager.start.showCustomModal { close -> modalView(chatModel, close) }
|
||||
}
|
||||
}
|
||||
val currentUser = remember { chatModel.currentUser }.value
|
||||
val stopped = chatModel.chatRunning.value == false
|
||||
|
||||
if (currentUser != null) {
|
||||
SectionView {
|
||||
SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
|
||||
ProfilePreview(currentUser.profile, stopped = stopped)
|
||||
}
|
||||
UserPickerOptionRow(
|
||||
painterResource(MR.images.ic_qr_code),
|
||||
if (chatModel.userAddress.value != null) generalGetString(MR.strings.your_public_contact_address) else generalGetString(MR.strings.create_public_contact_address),
|
||||
showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped
|
||||
)
|
||||
UserPickerOptionRow(
|
||||
painterResource(MR.images.ic_toggle_on),
|
||||
stringResource(MR.strings.chat_preferences),
|
||||
click = if (stopped) null else ({
|
||||
showCustomModal { m, close ->
|
||||
PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close)
|
||||
}()
|
||||
}),
|
||||
disabled = stopped
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SectionView {
|
||||
if (chatModel.desktopNoUserNoRemote) {
|
||||
UserPickerOptionRow(
|
||||
painterResource(MR.images.ic_manage_accounts),
|
||||
generalGetString(MR.strings.create_chat_profile),
|
||||
{
|
||||
if (chatModel.desktopNoUserNoRemote) {
|
||||
CreateInitialProfile {
|
||||
doWithAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) {
|
||||
ModalManager.center.showModalCloseable { close ->
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -282,76 +237,15 @@ private fun ActiveUserSection(
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
Divider(Modifier.requiredHeight(1.dp))
|
||||
}
|
||||
}
|
||||
if (showSettings) {
|
||||
SettingsPickerItem(settingsClicked)
|
||||
}
|
||||
if (showCancel) {
|
||||
CancelPickerItem(cancelClicked)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlobalSettingsSection(
|
||||
chatModel: ChatModel,
|
||||
userPickerState: MutableStateFlow<AnimatedViewState>,
|
||||
setPerformLA: (Boolean) -> Unit,
|
||||
onUserClicked: (user: User) -> Unit,
|
||||
onShowAllProfilesClicked: () -> Unit
|
||||
) {
|
||||
val stopped = chatModel.chatRunning.value == false
|
||||
val users by remember {
|
||||
derivedStateOf {
|
||||
chatModel.users
|
||||
.filter { u -> !u.user.hidden && !u.user.activeUser }
|
||||
}
|
||||
}
|
||||
|
||||
SectionView(headerBottomPadding = if (appPlatform.isDesktop || windowOrientation() == WindowOrientation.PORTRAIT) DEFAULT_PADDING else 0.dp) {
|
||||
UserPickerInactiveUsersSection(
|
||||
users = users,
|
||||
onShowAllProfilesClicked = onShowAllProfilesClicked,
|
||||
onUserClicked = onUserClicked,
|
||||
stopped = stopped
|
||||
)
|
||||
|
||||
if (appPlatform.isAndroid) {
|
||||
val text = generalGetString(MR.strings.settings_section_title_use_from_desktop).lowercase().capitalize(Locale.current)
|
||||
|
||||
UserPickerOptionRow(
|
||||
painterResource(MR.images.ic_desktop),
|
||||
text,
|
||||
click = {
|
||||
ModalManager.start.showCustomModal { close ->
|
||||
ConnectDesktopView(close)
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
UserPickerOptionRow(
|
||||
icon = painterResource(MR.images.ic_smartphone_300),
|
||||
text = stringResource(if (remember { chat.simplex.common.platform.chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles),
|
||||
click = {
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
ModalManager.start.showModal {
|
||||
ConnectMobileView()
|
||||
}
|
||||
},
|
||||
disabled = stopped
|
||||
)
|
||||
}
|
||||
|
||||
SectionItemView(
|
||||
click = {
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
SettingsView(chatModel, setPerformLA, close)
|
||||
}
|
||||
},
|
||||
padding = PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING + 2.dp)
|
||||
) {
|
||||
val text = generalGetString(MR.strings.settings_section_title_settings).lowercase().capitalize(Locale.current)
|
||||
Icon(painterResource(MR.images.ic_settings), text, tint = MaterialTheme.colors.secondary)
|
||||
TextIconSpaced()
|
||||
Text(text, color = Color.Unspecified)
|
||||
Spacer(Modifier.weight(1f))
|
||||
ColorModeSwitcher()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -402,7 +296,7 @@ fun UserProfilePickerItem(
|
||||
}
|
||||
} else if (!u.showNtfs) {
|
||||
Icon(painterResource(MR.images.ic_notifications_off), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
|
||||
} else {
|
||||
} else {
|
||||
Box(Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
@@ -431,157 +325,136 @@ fun UserProfileRow(u: User, enabled: Boolean = chatModel.chatRunning.value == tr
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserPickerOptionRow(icon: Painter, text: String, click: (() -> Unit)? = null, disabled: Boolean = false) {
|
||||
SectionItemView(click, disabled = disabled, extraPadding = true) {
|
||||
Icon(icon, text, tint = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.secondary)
|
||||
TextIconSpaced()
|
||||
Text(text = text, color = if (disabled) MaterialTheme.colors.secondary else Color.Unspecified)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserPickerInactiveUserBadge(userInfo: UserInfo, stopped: Boolean, size: Dp = 60.dp, onClick: (user: User) -> Unit) {
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { onClick(userInfo.user) },
|
||||
enabled = !stopped
|
||||
) {
|
||||
Box {
|
||||
ProfileImage(size = size, image = userInfo.user.profile.image, color = MaterialTheme.colors.secondaryVariant)
|
||||
|
||||
if (userInfo.unreadCount > 0) {
|
||||
unreadBadge(userInfo.unreadCount, userInfo.user.showNtfs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DevicePickerRow(
|
||||
localDeviceActive: Boolean,
|
||||
remoteHosts: List<RemoteHostInfo>,
|
||||
onLocalDeviceClick: () -> Unit,
|
||||
onRemoteHostClick: (rh: RemoteHostInfo, connecting: MutableState<Boolean>) -> Unit,
|
||||
onRemoteHostActionButtonClick: (rh: RemoteHostInfo) -> Unit,
|
||||
) {
|
||||
fun RemoteHostPickerItem(h: RemoteHostInfo, onLongClick: () -> Unit = {}, actionButtonClick: () -> Unit = {}, onClick: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(color = if (h.activeHost) MaterialTheme.colors.surface.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified)
|
||||
.sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT)
|
||||
.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING, top = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
.onRightClick { onLongClick() }
|
||||
.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val activeHost = remoteHosts.firstOrNull { h -> h.activeHost }
|
||||
|
||||
if (activeHost != null) {
|
||||
val connecting = rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
DevicePill(
|
||||
active = true,
|
||||
icon = painterResource(MR.images.ic_smartphone_300),
|
||||
text = activeHost.hostDeviceName,
|
||||
actionButtonVisible = activeHost.sessionState is RemoteHostSessionState.Connected,
|
||||
onActionButtonClick = { onRemoteHostActionButtonClick(activeHost) }
|
||||
) {
|
||||
onRemoteHostClick(activeHost, connecting)
|
||||
}
|
||||
}
|
||||
|
||||
DevicePill(
|
||||
active = localDeviceActive,
|
||||
icon = painterResource(MR.images.ic_desktop),
|
||||
text = stringResource(MR.strings.this_device),
|
||||
actionButtonVisible = false
|
||||
) {
|
||||
onLocalDeviceClick()
|
||||
}
|
||||
|
||||
remoteHosts.filter { h -> h.sessionState is RemoteHostSessionState.Connected && !h.activeHost }.forEach { h ->
|
||||
val connecting = rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
DevicePill(
|
||||
active = h.activeHost,
|
||||
icon = painterResource(MR.images.ic_smartphone_300),
|
||||
text = h.hostDeviceName,
|
||||
actionButtonVisible = h.sessionState is RemoteHostSessionState.Connected,
|
||||
onActionButtonClick = { onRemoteHostActionButtonClick(h) }
|
||||
) {
|
||||
onRemoteHostClick(h, connecting)
|
||||
}
|
||||
RemoteHostRow(h)
|
||||
if (h.sessionState is RemoteHostSessionState.Connected) {
|
||||
HostDisconnectButton(actionButtonClick)
|
||||
} else {
|
||||
Box(Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun UserPickerInactiveUsersSection(
|
||||
users: List<UserInfo>,
|
||||
stopped: Boolean,
|
||||
onShowAllProfilesClicked: () -> Unit,
|
||||
onUserClicked: (user: User) -> Unit,
|
||||
)
|
||||
|
||||
@Composable
|
||||
expect fun PlatformUserPicker(
|
||||
modifier: Modifier,
|
||||
pickerState: MutableStateFlow<AnimatedViewState>,
|
||||
content: @Composable () -> Unit
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DevicePill(
|
||||
active: Boolean,
|
||||
icon: Painter,
|
||||
text: String,
|
||||
actionButtonVisible: Boolean,
|
||||
onActionButtonClick: (() -> Unit)? = null,
|
||||
onClick: () -> Unit) {
|
||||
fun RemoteHostRow(h: RemoteHostInfo) {
|
||||
Row(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.border(
|
||||
BorderStroke(1.dp, MaterialTheme.colors.secondaryVariant),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
.background(if (active) MaterialTheme.colors.secondaryVariant else Color.Transparent)
|
||||
.clickable(
|
||||
enabled = !active,
|
||||
onClick = onClick,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = LocalIndication.current
|
||||
),
|
||||
.widthIn(max = windowWidth() * 0.7f)
|
||||
.padding(start = 17.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 4.dp)
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
text,
|
||||
Modifier.size(16.dp * fontSizeSqrtMultiplier),
|
||||
tint = MaterialTheme.colors.onSurface
|
||||
Icon(painterResource(MR.images.ic_smartphone_300), h.hostDeviceName, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
|
||||
Text(
|
||||
h.hostDeviceName,
|
||||
modifier = Modifier.padding(start = 26.dp, end = 8.dp),
|
||||
color = if (h.activeHost) MaterialTheme.colors.onBackground else MenuTextColor,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LocalDevicePickerItem(active: Boolean, onLongClick: () -> Unit = {}, onClick: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(color = if (active) MaterialTheme.colors.surface.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified)
|
||||
.sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT)
|
||||
.combinedClickable(
|
||||
onClick = if (active) {{}} else onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = if (!active) LocalIndication.current else null
|
||||
)
|
||||
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON * fontSizeSqrtMultiplier))
|
||||
Text(
|
||||
text,
|
||||
color = MaterialTheme.colors.onSurface,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
if (onActionButtonClick != null && actionButtonVisible) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val hovered = interactionSource.collectIsHoveredAsState().value
|
||||
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON * fontSizeSqrtMultiplier))
|
||||
IconButton(onActionButtonClick, Modifier.requiredSize(16.dp * fontSizeSqrtMultiplier)) {
|
||||
Icon(
|
||||
painterResource(if (hovered) MR.images.ic_wifi_off else MR.images.ic_wifi),
|
||||
null,
|
||||
Modifier.size(16.dp * fontSizeSqrtMultiplier).hoverable(interactionSource),
|
||||
tint = if (hovered) WarningOrange else MaterialTheme.colors.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onRightClick { onLongClick() }
|
||||
.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
LocalDeviceRow(active)
|
||||
Box(Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LocalDeviceRow(active: Boolean) {
|
||||
Row(
|
||||
Modifier
|
||||
.widthIn(max = windowWidth() * 0.7f)
|
||||
.padding(start = 17.dp, end = DEFAULT_PADDING),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(painterResource(MR.images.ic_desktop), stringResource(MR.strings.this_device), Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
|
||||
Text(
|
||||
stringResource(MR.strings.this_device),
|
||||
modifier = Modifier.padding(start = 26.dp, end = 8.dp),
|
||||
color = if (active) MaterialTheme.colors.onBackground else MenuTextColor,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UseFromDesktopPickerItem(onClick: () -> Unit) {
|
||||
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
|
||||
val text = generalGetString(MR.strings.settings_section_title_use_from_desktop).lowercase().capitalize(Locale.current)
|
||||
Icon(painterResource(MR.images.ic_desktop), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
|
||||
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
|
||||
Text(text, color = MenuTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LinkAMobilePickerItem(onClick: () -> Unit) {
|
||||
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
|
||||
val text = generalGetString(MR.strings.link_a_mobile)
|
||||
Icon(painterResource(MR.images.ic_smartphone_300), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
|
||||
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
|
||||
Text(text, color = MenuTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreateInitialProfile(onClick: () -> Unit) {
|
||||
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
|
||||
val text = generalGetString(MR.strings.create_chat_profile)
|
||||
Icon(painterResource(MR.images.ic_manage_accounts), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
|
||||
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
|
||||
Text(text, color = MenuTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsPickerItem(onClick: () -> Unit) {
|
||||
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
|
||||
val text = generalGetString(MR.strings.settings_section_title_settings).lowercase().capitalize(Locale.current)
|
||||
Icon(painterResource(MR.images.ic_settings), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
|
||||
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
|
||||
Text(text, color = MenuTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CancelPickerItem(onClick: () -> Unit) {
|
||||
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
|
||||
val text = generalGetString(MR.strings.cancel_verb)
|
||||
Icon(painterResource(MR.images.ic_close), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
|
||||
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
|
||||
Text(text, color = MenuTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,29 +472,6 @@ fun HostDisconnectButton(onClick: (() -> Unit)?) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.unreadBadge(unreadCount: Int, userMuted: Boolean) {
|
||||
Text(
|
||||
if (unreadCount > 0) unreadCountStr(unreadCount) else "",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
style = TextStyle(textAlign = TextAlign.Center),
|
||||
modifier = Modifier
|
||||
.offset(y = 3.sp.toDp())
|
||||
.background(if (userMuted) MaterialTheme.colors.primaryVariant else MaterialTheme.colors.secondary, shape = CircleShape)
|
||||
.badgeLayout()
|
||||
.padding(horizontal = 2.sp.toDp())
|
||||
.padding(vertical = 2.sp.toDp())
|
||||
.align(Alignment.TopEnd)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private suspend fun closePicker(userPickerState: MutableStateFlow<AnimatedViewState>) {
|
||||
delay(500)
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
}
|
||||
|
||||
private fun switchToLocalDevice() {
|
||||
withBGApi {
|
||||
chatController.switchUIRemoteHost(null)
|
||||
|
||||
-2
@@ -7,5 +7,3 @@ fun <T> chatListAnimationSpec() = tween<T>(durationMillis = 250, easing = FastOu
|
||||
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
|
||||
|
||||
fun <T> audioProgressBarAnimationSpec() = tween<T>(durationMillis = 30, easing = LinearEasing)
|
||||
|
||||
fun <T> userPickerAnimSpec() = tween<T>(256, 0, FastOutSlowInEasing)
|
||||
|
||||
+3
-8
@@ -16,7 +16,6 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.platform.appPlatform
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chatlist.DevicePill
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import kotlin.math.absoluteValue
|
||||
@@ -158,13 +157,9 @@ private fun bottomTitleAlpha(connection: CollapsingAppBarNestedScrollConnection?
|
||||
@Composable
|
||||
private fun HostDeviceTitle(hostDevice: Pair<Long?, String>, extraPadding: Boolean = false) {
|
||||
Row(Modifier.fillMaxWidth().padding(top = 5.dp, bottom = if (extraPadding) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) {
|
||||
DevicePill(
|
||||
active = true,
|
||||
onClick = {},
|
||||
actionButtonVisible = false,
|
||||
icon = painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300),
|
||||
text = hostDevice.second
|
||||
)
|
||||
Icon(painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300), null, Modifier.size(15.dp), tint = MaterialTheme.colors.secondary)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(hostDevice.second, color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-5
@@ -588,11 +588,9 @@ fun <T> KeyChangeEffect(
|
||||
var anyChange by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(key1) {
|
||||
if (anyChange || key1 != prevKey) {
|
||||
val prev = prevKey
|
||||
block(prevKey)
|
||||
prevKey = key1
|
||||
anyChange = true
|
||||
// Call it as the last statement because the coroutine can be cancelled earlier
|
||||
block(prev)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -612,8 +610,8 @@ fun KeyChangeEffect(
|
||||
var anyChange by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(key1, key2) {
|
||||
if (anyChange || key1 != initialKey || key2 != initialKey2) {
|
||||
anyChange = true
|
||||
block()
|
||||
anyChange = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -635,8 +633,8 @@ fun KeyChangeEffect(
|
||||
var anyChange by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(key1, key2, key3) {
|
||||
if (anyChange || key1 != initialKey || key2 != initialKey2 || key3 != initialKey3) {
|
||||
anyChange = true
|
||||
block()
|
||||
anyChange = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-9
@@ -349,15 +349,7 @@ private fun MutableState<MigrationFromState>.LinkShownView(fileId: Long, link: S
|
||||
text = stringResource(MR.strings.migrate_from_device_finalize_migration),
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
click = {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.migrate_from_device_remove_archive_question),
|
||||
text = generalGetString(MR.strings.migrate_from_device_uploaded_archive_will_be_removed),
|
||||
confirmText = generalGetString(MR.strings.continue_to_next_step),
|
||||
destructive = true,
|
||||
onConfirm = {
|
||||
finishMigration(fileId, ctrl)
|
||||
}
|
||||
)
|
||||
finishMigration(fileId, ctrl)
|
||||
}
|
||||
) {}
|
||||
SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_archive_will_be_deleted))
|
||||
|
||||
+5
-3
@@ -199,8 +199,10 @@ private fun MutableState<MigrationToState?>.PasteOrScanLinkView() {
|
||||
SectionSpacer()
|
||||
}
|
||||
|
||||
SectionView(stringResource(if (appPlatform.isAndroid) MR.strings.or_paste_archive_link else MR.strings.paste_archive_link).uppercase()) {
|
||||
PasteLinkView()
|
||||
if (appPlatform.isDesktop || appPreferences.developerTools.get()) {
|
||||
SectionView(stringResource(if (appPlatform.isAndroid) MR.strings.or_paste_archive_link else MR.strings.paste_archive_link).uppercase()) {
|
||||
PasteLinkView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,7 +561,7 @@ private fun MutableState<MigrationToState?>.startDownloading(
|
||||
)
|
||||
state = MigrationToState.DownloadFailed(totalBytes, link, archivePath, netCfg)
|
||||
} else {
|
||||
Log.d(TAG, "unsupported error: ${msg.responseType}, ${json.encodeToString(msg.chatError)}")
|
||||
Log.d(TAG, "unsupported error: ${msg.responseType}")
|
||||
}
|
||||
}
|
||||
else -> Log.d(TAG, "unsupported event: ${msg.responseType}")
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ fun chatContactType(chat: Chat): ContactType {
|
||||
val contact = cInfo.contact
|
||||
|
||||
when {
|
||||
contact.activeConn == null && contact.profile.contactLink != null && contact.active -> ContactType.CARD
|
||||
contact.activeConn == null && contact.profile.contactLink != null -> ContactType.CARD
|
||||
contact.chatDeleted -> ContactType.CHAT_DELETED
|
||||
contact.contactStatus == ContactStatus.Active -> ContactType.RECENT
|
||||
else -> ContactType.UNLISTED
|
||||
|
||||
+72
-73
@@ -2,6 +2,7 @@ package chat.simplex.common.views.newchat
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import TextIconSpaced
|
||||
@@ -266,13 +267,24 @@ private fun ProfilePickerOption(
|
||||
)
|
||||
}
|
||||
|
||||
private fun filteredProfiles(users: List<User>, searchTextOrPassword: String): List<User> {
|
||||
val s = searchTextOrPassword.trim()
|
||||
val lower = s.lowercase()
|
||||
return users.filter { u ->
|
||||
if ((u.activeUser || !u.hidden) && (s == "" || u.anyNameContains(lower))) {
|
||||
true
|
||||
} else {
|
||||
correctPassword(u, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActiveProfilePicker(
|
||||
private fun ActiveProfilePicker(
|
||||
search: MutableState<String>,
|
||||
contactConnection: PendingContactConnection?,
|
||||
close: () -> Unit,
|
||||
rhId: Long?,
|
||||
showIncognito: Boolean = true
|
||||
rhId: Long?
|
||||
) {
|
||||
val switchingProfile = remember { mutableStateOf(false) }
|
||||
val incognito = remember {
|
||||
@@ -280,9 +292,11 @@ fun ActiveProfilePicker(
|
||||
}
|
||||
val selectedProfile by remember { chatModel.currentUser }
|
||||
val searchTextOrPassword = rememberSaveable { search }
|
||||
// Intentionally don't use derivedStateOf in order to NOT change an order after user was selected
|
||||
val filteredProfiles = remember(searchTextOrPassword.value) {
|
||||
filteredProfiles(chatModel.users.map { it.user }.sortedBy { !it.activeUser }, searchTextOrPassword.value)
|
||||
val profiles = remember {
|
||||
chatModel.users.map { it.user }.sortedBy { !it.activeUser }
|
||||
}
|
||||
val filteredProfiles by remember {
|
||||
derivedStateOf { filteredProfiles(profiles, searchTextOrPassword.value) }
|
||||
}
|
||||
|
||||
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -308,38 +322,32 @@ fun ActiveProfilePicker(
|
||||
switchingProfile.value = true
|
||||
withApi {
|
||||
try {
|
||||
var updatedConn: PendingContactConnection? = null;
|
||||
|
||||
if (contactConnection != null) {
|
||||
updatedConn = controller.apiChangeConnectionUser(rhId, contactConnection.pccConnId, user.userId)
|
||||
if (updatedConn != null) {
|
||||
val conn = controller.apiChangeConnectionUser(rhId, contactConnection.pccConnId, user.userId)
|
||||
if (conn != null) {
|
||||
withChats {
|
||||
updateContactConnection(rhId, updatedConn)
|
||||
updateShownConnection(updatedConn)
|
||||
updateContactConnection(rhId, conn)
|
||||
updateShownConnection(conn)
|
||||
}
|
||||
controller.changeActiveUser_(
|
||||
rhId = user.remoteHostId,
|
||||
toUserId = user.userId,
|
||||
viewPwd = if (user.hidden) searchTextOrPassword.value else null
|
||||
)
|
||||
|
||||
if (chatModel.currentUser.value?.userId != user.userId) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(
|
||||
MR.strings.switching_profile_error_title),
|
||||
String.format(generalGetString(MR.strings.switching_profile_error_message), user.chatViewName)
|
||||
)
|
||||
}
|
||||
|
||||
withChats {
|
||||
updateContactConnection(user.remoteHostId, conn)
|
||||
}
|
||||
close.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
controller.changeActiveUser_(
|
||||
rhId = user.remoteHostId,
|
||||
toUserId = user.userId,
|
||||
viewPwd = if (user.hidden) searchTextOrPassword.value else null
|
||||
)
|
||||
|
||||
if (chatModel.currentUser.value?.userId != user.userId) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(
|
||||
MR.strings.switching_profile_error_title),
|
||||
String.format(generalGetString(MR.strings.switching_profile_error_message), user.chatViewName)
|
||||
)
|
||||
}
|
||||
|
||||
if (updatedConn != null) {
|
||||
withChats {
|
||||
updateContactConnection(user.remoteHostId, updatedConn)
|
||||
}
|
||||
}
|
||||
|
||||
close()
|
||||
} finally {
|
||||
switchingProfile.value = false
|
||||
}
|
||||
@@ -356,21 +364,24 @@ fun ActiveProfilePicker(
|
||||
title = stringResource(MR.strings.incognito),
|
||||
selected = incognito,
|
||||
onSelected = {
|
||||
if (incognito || switchingProfile.value || contactConnection == null) return@ProfilePickerOption
|
||||
if (!incognito) {
|
||||
switchingProfile.value = true
|
||||
withApi {
|
||||
try {
|
||||
if (contactConnection != null) {
|
||||
val conn = controller.apiSetConnectionIncognito(rhId, contactConnection.pccConnId, true)
|
||||
|
||||
switchingProfile.value = true
|
||||
withApi {
|
||||
try {
|
||||
val conn = controller.apiSetConnectionIncognito(rhId, contactConnection.pccConnId, true)
|
||||
if (conn != null) {
|
||||
withChats {
|
||||
updateContactConnection(rhId, conn)
|
||||
updateShownConnection(conn)
|
||||
if (conn != null) {
|
||||
withChats {
|
||||
updateContactConnection(rhId, conn)
|
||||
updateShownConnection(conn)
|
||||
}
|
||||
close.invoke()
|
||||
}
|
||||
}
|
||||
close()
|
||||
} finally {
|
||||
switchingProfile.value = false
|
||||
}
|
||||
} finally {
|
||||
switchingProfile.value = false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -402,18 +413,20 @@ fun ActiveProfilePicker(
|
||||
|
||||
if (activeProfile != null) {
|
||||
val otherProfiles = filteredProfiles.filter { it.userId != activeProfile.userId }
|
||||
item {
|
||||
when {
|
||||
!showIncognito ->
|
||||
ProfilePickerUserOption(activeProfile)
|
||||
incognito -> {
|
||||
IncognitoUserOption()
|
||||
ProfilePickerUserOption(activeProfile)
|
||||
}
|
||||
else -> {
|
||||
ProfilePickerUserOption(activeProfile)
|
||||
IncognitoUserOption()
|
||||
}
|
||||
|
||||
if (incognito) {
|
||||
item {
|
||||
IncognitoUserOption()
|
||||
}
|
||||
item {
|
||||
ProfilePickerUserOption(activeProfile)
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
ProfilePickerUserOption(activeProfile)
|
||||
}
|
||||
item {
|
||||
IncognitoUserOption()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,10 +434,8 @@ fun ActiveProfilePicker(
|
||||
ProfilePickerUserOption(p)
|
||||
}
|
||||
} else {
|
||||
if (showIncognito) {
|
||||
item {
|
||||
IncognitoUserOption()
|
||||
}
|
||||
item {
|
||||
IncognitoUserOption()
|
||||
}
|
||||
itemsIndexed(filteredProfiles) { _, p ->
|
||||
ProfilePickerUserOption(p)
|
||||
@@ -630,18 +641,6 @@ fun LinkTextView(link: String, share: Boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun filteredProfiles(users: List<User>, searchTextOrPassword: String): List<User> {
|
||||
val s = searchTextOrPassword.trim()
|
||||
val lower = s.lowercase()
|
||||
return users.filter { u ->
|
||||
if ((u.activeUser || !u.hidden) && (s == "" || u.anyNameContains(lower))) {
|
||||
true
|
||||
} else {
|
||||
correctPassword(u, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun verify(rhId: Long?, text: String?, close: () -> Unit): Boolean {
|
||||
if (text != null && strIsSimplexLink(text)) {
|
||||
connect(rhId, text, close)
|
||||
|
||||
-34
@@ -9,7 +9,6 @@ import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.MaterialTheme.colors
|
||||
@@ -607,39 +606,6 @@ object AppearanceScope {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColorModeSwitcher() {
|
||||
val currentTheme by CurrentColors.collectAsState()
|
||||
val themeMode = if (remember { appPrefs.currentTheme.state }.value == DefaultTheme.SYSTEM_THEME_NAME) {
|
||||
if (systemInDarkThemeCurrently) DefaultThemeMode.DARK else DefaultThemeMode.LIGHT
|
||||
} else {
|
||||
currentTheme.base.mode
|
||||
}
|
||||
|
||||
val onLongClick = {
|
||||
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
|
||||
showToast(generalGetString(MR.strings.system_mode_toast))
|
||||
|
||||
saveThemeToDatabase(null)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
ThemeManager.applyTheme(if (themeMode == DefaultThemeMode.LIGHT) appPrefs.systemDarkTheme.get()!! else DefaultTheme.LIGHT.themeName)
|
||||
saveThemeToDatabase(null)
|
||||
},
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
.onRightClick(onLongClick)
|
||||
.size(44.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(painterResource(if (themeMode == DefaultThemeMode.LIGHT) MR.images.ic_light_mode else MR.images.ic_bedtime_moon), stringResource(MR.strings.color_mode_light), tint = MaterialTheme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var updateBackendJob: Job = Job()
|
||||
private fun saveThemeToDatabase(themeUserDestination: Pair<Long, ThemeModeOverrides?>?) {
|
||||
val remoteHostId = chatModel.remoteHostId()
|
||||
|
||||
+4
-1
@@ -73,7 +73,10 @@ private fun SetDeliveryReceiptsLayout(
|
||||
skip: () -> Unit,
|
||||
userCount: Int,
|
||||
) {
|
||||
Box(Modifier.padding(top = DEFAULT_PADDING)) {
|
||||
// This view located in the left panel which means it has to have a padding from right side in order
|
||||
// to see scroll bar. And this padding should be applied to upper element, not scrollable column modifier
|
||||
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
|
||||
Box(Modifier.padding(top = DEFAULT_PADDING, end = endPadding)) {
|
||||
ColumnWithScrollBar(
|
||||
Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
|
||||
+45
-10
@@ -31,11 +31,13 @@ import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.migration.MigrateFromDeviceView
|
||||
import chat.simplex.common.views.onboarding.SimpleXInfo
|
||||
import chat.simplex.common.views.onboarding.WhatsNewView
|
||||
import chat.simplex.common.views.remote.ConnectDesktopView
|
||||
import chat.simplex.common.views.remote.ConnectMobileView
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
@Composable
|
||||
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: () -> Unit) {
|
||||
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerState: DrawerState) {
|
||||
val user = chatModel.currentUser.value
|
||||
val stopped = chatModel.chatRunning.value == false
|
||||
SettingsLayout(
|
||||
@@ -69,9 +71,10 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: (
|
||||
}
|
||||
},
|
||||
withAuth = ::doWithAuth,
|
||||
drawerState = drawerState,
|
||||
)
|
||||
KeyChangeEffect(chatModel.updatingProgress.value != null) {
|
||||
close()
|
||||
drawerState.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,11 +96,18 @@ fun SettingsLayout(
|
||||
showCustomModal: (@Composable ModalData.(ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||
showVersion: () -> Unit,
|
||||
withAuth: (title: String, desc: String, block: () -> Unit) -> Unit,
|
||||
drawerState: DrawerState,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val closeSettings: () -> Unit = { scope.launch { drawerState.close() } }
|
||||
val view = LocalMultiplatformView()
|
||||
LaunchedEffect(Unit) {
|
||||
hideKeyboard(view)
|
||||
if (drawerState.isOpen) {
|
||||
BackHandler {
|
||||
closeSettings()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
hideKeyboard(view)
|
||||
}
|
||||
}
|
||||
val theme = CurrentColors.collectAsState()
|
||||
val uriHandler = LocalUriHandler.current
|
||||
@@ -108,20 +118,44 @@ fun SettingsLayout(
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.your_settings))
|
||||
|
||||
SectionView(stringResource(MR.strings.settings_section_title_you)) {
|
||||
val profileHidden = rememberSaveable { mutableStateOf(false) }
|
||||
if (profile != null) {
|
||||
SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
|
||||
ProfilePreview(profile, stopped = stopped)
|
||||
}
|
||||
SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden, drawerState) } } }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped)
|
||||
ChatPreferencesItem(showCustomModal, stopped = stopped)
|
||||
} else if (chatModel.localUserCreated.value == false) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.create_chat_profile), {
|
||||
withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) {
|
||||
ModalManager.center.showModalCloseable { close ->
|
||||
LaunchedEffect(Unit) {
|
||||
closeSettings()
|
||||
}
|
||||
CreateProfile(chatModel, close)
|
||||
}
|
||||
}
|
||||
}, disabled = stopped)
|
||||
}
|
||||
if (appPlatform.isDesktop) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView() }, disabled = stopped)
|
||||
} else {
|
||||
SettingsActionItem(painterResource(MR.images.ic_desktop), stringResource(MR.strings.settings_section_title_use_from_desktop), showCustomModal { it, close -> ConnectDesktopView(close) }, disabled = stopped)
|
||||
}
|
||||
SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_from_device_to_another_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateFromDeviceView(close) } } }, disabled = stopped)
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
|
||||
SectionView(stringResource(MR.strings.settings_section_title_settings)) {
|
||||
SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView() }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it) })
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
|
||||
SectionView(stringResource(MR.strings.settings_section_title_chat_database)) {
|
||||
DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_from_device_to_another_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateFromDeviceView(close) } } }, disabled = stopped)
|
||||
}
|
||||
|
||||
SectionDividerSpaced()
|
||||
|
||||
SectionView(stringResource(MR.strings.settings_section_title_help)) {
|
||||
@@ -501,6 +535,7 @@ fun PreviewSettingsLayout() {
|
||||
showCustomModal = { {} },
|
||||
showVersion = {},
|
||||
withAuth = { _, _, _ -> },
|
||||
drawerState = DrawerState(DrawerValue.Closed),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -174,7 +174,7 @@ private fun UserAddressLayout(
|
||||
saveAas: (AutoAcceptState, MutableState<AutoAcceptState>) -> Unit,
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(stringResource(MR.strings.public_address), hostDevice(user?.remoteHostId))
|
||||
AppBarTitle(stringResource(MR.strings.simplex_address), hostDevice(user?.remoteHostId))
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -230,7 +230,7 @@ private fun UserAddressLayout(
|
||||
private fun CreateAddressButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_qr_code),
|
||||
stringResource(MR.strings.create_public_address),
|
||||
stringResource(MR.strings.create_simplex_address),
|
||||
onClick,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
|
||||
-1
@@ -34,7 +34,6 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
|
||||
KeyChangeEffect(u.value?.remoteHostId, u.value?.userId) {
|
||||
close()
|
||||
}
|
||||
|
||||
if (user != null) {
|
||||
var profile by remember { mutableStateOf(user.profile.toProfile()) }
|
||||
UserProfileLayout(
|
||||
|
||||
+8
-1
@@ -36,10 +36,11 @@ import dev.icerock.moko.resources.StringResource
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
@Composable
|
||||
fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden: MutableState<Boolean>) {
|
||||
fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden: MutableState<Boolean>, drawerState: DrawerState) {
|
||||
val searchTextOrPassword = rememberSaveable { search }
|
||||
val users by remember { derivedStateOf { m.users.map { it.user } } }
|
||||
val filteredUsers by remember { derivedStateOf { filteredUsers(m, searchTextOrPassword.value) } }
|
||||
val scope = rememberCoroutineScope()
|
||||
UserProfilesLayout(
|
||||
users = users,
|
||||
filteredUsers = filteredUsers,
|
||||
@@ -50,6 +51,12 @@ fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden:
|
||||
addUser = {
|
||||
ModalManager.center.showModalCloseable { close ->
|
||||
CreateProfile(m, close)
|
||||
if (appPlatform.isDesktop) {
|
||||
// Hide settings to allow clicks to pass through to CreateProfile view
|
||||
DisposableEffectOnGone(always = { scope.launch { drawerState.close() } }) {
|
||||
// Show settings again to allow intercept clicks to close modals after profile creation finishes
|
||||
scope.launch(NonCancellable) { drawerState.open() } }
|
||||
}
|
||||
}
|
||||
},
|
||||
activateUser = { user ->
|
||||
|
||||
@@ -318,7 +318,6 @@
|
||||
<string name="delete_message__question">Delete message?</string>
|
||||
<string name="delete_messages__question">Delete %d messages?</string>
|
||||
<string name="delete_message_cannot_be_undone_warning">Message will be deleted - this cannot be undone!</string>
|
||||
<string name="delete_messages_cannot_be_undone_warning">Messages will be deleted - this cannot be undone!</string>
|
||||
<string name="delete_message_mark_deleted_warning">Message will be marked for deletion. The recipient(s) will be able to reveal this message.</string>
|
||||
<string name="delete_messages_mark_deleted_warning">Messages will be marked for deletion. The recipient(s) will be able to reveal these messages.</string>
|
||||
<string name="delete_member_message__question">Delete member message?</string>
|
||||
@@ -701,10 +700,6 @@
|
||||
<string name="is_verified">%s is verified</string>
|
||||
<string name="is_not_verified">%s is not verified</string>
|
||||
|
||||
<!-- user picker - UserPicker.kt -->
|
||||
<string name="create_public_contact_address">Create public address</string>
|
||||
<string name="your_public_contact_address">Your public address</string>
|
||||
|
||||
<!-- settings - SettingsView.kt -->
|
||||
<string name="your_settings">Your settings</string>
|
||||
<string name="your_simplex_contact_address">Your SimpleX address</string>
|
||||
@@ -853,8 +848,6 @@
|
||||
<string name="shutdown_alert_desc">Notifications will stop working until you re-launch the app</string>
|
||||
|
||||
<!-- Address Items - UserAddressView.kt -->
|
||||
<string name="public_address">Public address</string>
|
||||
<string name="create_public_address">Create public address</string>
|
||||
<string name="create_address">Create address</string>
|
||||
<string name="delete_address__question">Delete address?</string>
|
||||
<string name="your_contacts_will_remain_connected">Your contacts will remain connected.</string>
|
||||
@@ -1156,7 +1149,6 @@
|
||||
<!-- Settings sections -->
|
||||
<string name="settings_section_title_you">YOU</string>
|
||||
<string name="settings_section_title_settings">SETTINGS</string>
|
||||
<string name="settings_section_title_chat_database">CHAT DATABASE</string>
|
||||
<string name="settings_section_title_help">HELP</string>
|
||||
<string name="settings_section_title_support">SUPPORT SIMPLEX CHAT</string>
|
||||
<string name="settings_section_title_app">APP</string>
|
||||
@@ -1721,7 +1713,6 @@
|
||||
<string name="theme_remove_image">Remove image</string>
|
||||
<string name="appearance_font_size">Font size</string>
|
||||
<string name="appearance_zoom">Zoom</string>
|
||||
<string name="system_mode_toast">System mode</string>
|
||||
|
||||
<!-- Wallpapers -->
|
||||
<string name="wallpaper_preview_hello_alice">Good afternoon!</string>
|
||||
@@ -2187,8 +2178,6 @@
|
||||
<string name="migrate_from_device_creating_archive_link">Creating archive link</string>
|
||||
<string name="migrate_from_device_cancel_migration">Cancel migration</string>
|
||||
<string name="migrate_from_device_finalize_migration">Finalize migration</string>
|
||||
<string name="migrate_from_device_remove_archive_question">Remove archive?</string>
|
||||
<string name="migrate_from_device_uploaded_archive_will_be_removed">The uploaded database archive will be permanently removed from the servers.</string>
|
||||
<string name="migrate_from_device_choose_migrate_from_another_device"><![CDATA[Choose <i>Migrate from another device</i> on the new device and scan QR code.]]></string>
|
||||
<string name="migrate_from_device_or_share_this_file_link">Or securely share this file link</string>
|
||||
<string name="migrate_from_device_delete_database_from_device">Delete database from this device</string>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="M497-481q28-30.5 43.25-70.5T555.5-634q0-42.5-15-82.5T497-787q57.5 8 95.5 51.75t38 101.25q0 57.5-38 101.25T497-481Zm199 308q10-17 15.25-36t5.25-39.09v-38.27q0-34.45-15-65.54-15-31.1-40-55.1 48.23 17.4 89.36 44.95Q792-334.5 792-286.5v38.5q0 30.94-22.03 52.97Q747.94-173 717-173h-21Zm96-348.5h-41.5q-15.5 0-26.5-11T713-559q0-15.5 11-26.5t26.5-11H792V-638q0-15.5 11-26.5t26.5-11q15.5 0 26.5 11t11 26.5v41.5h41.5q15.5 0 26.5 11t11 26.5q0 15.5-11 26.5t-26.5 11H867v41.5q0 15.5-11 26.5t-26.5 11q-15.5 0-26.5-11T792-480v-41.5ZM325.5-479q-64.5 0-109.75-45.25T170.5-634q0-64.5 45.25-109.75T325.5-789q64.5 0 109.75 45.25T480.5-634q0 64.5-45.25 109.75T325.5-479Zm-311 231v-31.03q0-32.97 16.75-60.22t45.27-41.76Q137.5-411 199.75-426.25 262-441.5 325.5-441.5t125.75 15.25Q513.5-411 574.48-381.01q28.52 14.51 45.27 41.76Q636.5-312 636.5-279.03V-248q0 30.94-22.03 52.97Q592.44-173 561.5-173h-472q-30.94 0-52.97-22.03Q14.5-217.06 14.5-248Zm311-306q33 0 56.5-23.5t23.5-56.5q0-33-23.5-56.5T325.5-714q-33 0-56.5 23.5T245.5-634q0 33 23.5 56.5t56.5 23.5Zm-236 306h472v-31q0-11.19-5.5-20.34-5.5-9.16-15-14.16-53.5-26.5-107.17-39.75-53.68-13.25-108.33-13.25-55 0-108.5 13.25T110-313.5q-9.5 5-15 14.16-5.5 9.15-5.5 20.34v31Zm236-386Zm0 386Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="M483.82-81.5q-83.12 0-156.43-31.82-73.32-31.81-127.85-86.25Q145-254 113.25-327.25 81.5-400.5 81.5-484q0-131.24 77-236.62t202.35-146.46q16.05-4.92 29.85 4.83t11.8 25.75q-6.5 88.5 24 170.4t94 145.35Q583.5-458 665.75-427t170.75 24q15-1.5 24.75 12.25t5.25 29.25q-40 125.5-145.35 202.75Q615.81-81.5 483.82-81.5ZM484-139q100.95 0 183.22-57.5Q749.5-254 800-343q-90.29-7.95-173.55-41.22Q543.2-417.5 479.5-481q-63.96-62.86-96.73-145.68Q350-709.5 342.5-798.5q-89 48.5-146.25 131.03Q139-584.95 139-484q0 144.38 100.31 244.69T484-139Zm-5-342Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 636 B |
-86
@@ -1,86 +0,0 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.User
|
||||
import chat.simplex.common.model.UserInfo
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@Composable
|
||||
actual fun UserPickerInactiveUsersSection(
|
||||
users: List<UserInfo>,
|
||||
stopped: Boolean,
|
||||
onShowAllProfilesClicked: () -> Unit,
|
||||
onUserClicked: (user: User) -> Unit,
|
||||
) {
|
||||
if (users.isNotEmpty()) {
|
||||
val userRows = users.chunked(5)
|
||||
val rowsToDisplay = if (userRows.size > 2) 2 else userRows.size
|
||||
val horizontalPadding = DEFAULT_PADDING_HALF + 8.dp
|
||||
|
||||
Column(Modifier
|
||||
.padding(horizontal = horizontalPadding, vertical = DEFAULT_PADDING_HALF)
|
||||
.height(55.dp * rowsToDisplay + (if (rowsToDisplay > 1) DEFAULT_PADDING else 0.dp))
|
||||
) {
|
||||
ColumnWithScrollBar(
|
||||
verticalArrangement = Arrangement.spacedBy(DEFAULT_PADDING)
|
||||
) {
|
||||
val spaceBetween = (((DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier) - (horizontalPadding)) - (55.dp * 5)) / 5
|
||||
|
||||
userRows.forEach { row ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||
) {
|
||||
row.forEach { u ->
|
||||
UserPickerInactiveUserBadge(u, stopped, size = 55.dp) {
|
||||
onUserClicked(u.user)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UserPickerOptionRow(
|
||||
painterResource(MR.images.ic_manage_accounts),
|
||||
stringResource(MR.strings.your_chat_profiles),
|
||||
onShowAllProfilesClicked
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun PlatformUserPicker(modifier: Modifier, pickerState: MutableStateFlow<AnimatedViewState>, content: @Composable () -> Unit) {
|
||||
AnimatedVisibility(
|
||||
visible = pickerState.value.isVisible(),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { pickerState.value = AnimatedViewState.HIDING }),
|
||||
contentAlignment = Alignment.TopStart
|
||||
) {
|
||||
ColumnWithScrollBar(modifier) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import Simplex.Chat
|
||||
import Simplex.Chat.Controller
|
||||
import Simplex.Chat.Core
|
||||
import Simplex.Chat.Options
|
||||
import Simplex.Messaging.Transport.Server (runLocalTCPServer)
|
||||
import Simplex.Messaging.Transport.Server (runTCPServer)
|
||||
import Simplex.Messaging.Util (raceAny_)
|
||||
import UnliftIO.Exception
|
||||
import UnliftIO.STM
|
||||
@@ -68,7 +68,7 @@ newChatServerClient qSize = do
|
||||
runChatServer :: ChatServerConfig -> ChatController -> IO ()
|
||||
runChatServer ChatServerConfig {chatPort, clientQSize} cc = do
|
||||
started <- newEmptyTMVarIO
|
||||
runLocalTCPServer started chatPort $ \sock -> do
|
||||
runTCPServer started chatPort $ \sock -> do
|
||||
ws <- liftIO $ getConnection sock
|
||||
c <- atomically $ newChatServerClient clientQSize
|
||||
putStrLn "client connected"
|
||||
|
||||
@@ -66,7 +66,7 @@ This service continues running when the app is switched off, and it is restarted
|
||||
|
||||
So, for Android we can now deliver instant message notifications without compromising users' privacy in any way. The app version 1.5 that includes private instant notifications is now available on [Play Store](https://play.google.com/store/apps/details?id=chat.simplex.app), in our [F-Droid repo](https://app.simplex.chat/) and via direct [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) downloads!
|
||||
|
||||
Please let us know what needs to be improved - it's only the first version of instant notifications for Android!
|
||||
Please let us what needs to be improved - it's only the first version of instant notifications for Android!
|
||||
|
||||
## Our iOS approach has one trade-off
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/simplexmq.git
|
||||
tag: 6302a32922884c4ac296a6ea0203da7fbc6b48fd
|
||||
tag: d559a66145cf7b4cd367c09974ed1ce8393940b2
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
||||
+4
-2
@@ -1,12 +1,14 @@
|
||||
---
|
||||
title: Download SimpleX apps
|
||||
permalink: /downloads/index.html
|
||||
revision: 09.09.2024
|
||||
revision: 03.07.2024
|
||||
---
|
||||
|
||||
| Updated 09.09.2024 | Languages: EN |
|
||||
| Updated 03.07.2024 | Languages: EN |
|
||||
# Download SimpleX apps
|
||||
|
||||
The latest stable version is v5.8.
|
||||
|
||||
You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases).
|
||||
|
||||
- [desktop](#desktop-app)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"https://github.com/simplex-chat/simplexmq.git"."6302a32922884c4ac296a6ea0203da7fbc6b48fd" = "1y6sbqizrias40grzwir14xh61n3wxcxi61y3qgblhjmyqaxql87";
|
||||
"https://github.com/simplex-chat/simplexmq.git"."d559a66145cf7b4cd367c09974ed1ce8393940b2" = "1jav7jmriims6vlkxg8gmal03f9mbgrwc8v6g0rp95ivkx8gfjyw";
|
||||
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
|
||||
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
|
||||
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
|
||||
|
||||
+89
-157
@@ -55,9 +55,9 @@ import Data.Text.Encoding (decodeLatin1, encodeUtf8)
|
||||
import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime)
|
||||
import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay, nominalDiffTimeToSeconds)
|
||||
import Data.Time.Clock.System (systemToUTCTime)
|
||||
import Data.Word (Word32)
|
||||
import qualified Data.UUID as UUID
|
||||
import qualified Data.UUID.V4 as V4
|
||||
import Data.Word (Word32)
|
||||
import qualified Database.SQLite.Simple as SQL
|
||||
import Simplex.Chat.Archive
|
||||
import Simplex.Chat.Call
|
||||
@@ -115,7 +115,7 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR
|
||||
import Simplex.Messaging.Encoding
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (base64P)
|
||||
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, XFTPServer, userProtocol)
|
||||
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), EntityId, ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, XFTPServer, userProtocol)
|
||||
import qualified Simplex.Messaging.Protocol as SMP
|
||||
import Simplex.Messaging.ServiceScheme (ServiceScheme (..))
|
||||
import qualified Simplex.Messaging.TMap as TM
|
||||
@@ -843,7 +843,9 @@ processChatCommand' vr = \case
|
||||
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
|
||||
APIDeleteChatItem (ChatRef cType chatId) itemIds mode -> withUser $ \user -> case cType of
|
||||
CTDirect -> withContactLock "deleteChatItem" chatId $ do
|
||||
(ct, items) <- getCommandDirectChatItems user chatId itemIds
|
||||
ct <- withStore $ \db -> getContact db vr user chatId
|
||||
(errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getDirectCI db) (L.toList itemIds))
|
||||
unless (null errs) $ toView $ CRChatErrors (Just user) errs
|
||||
case mode of
|
||||
CIDMInternal -> deleteDirectCIs user ct items True False
|
||||
CIDMBroadcast -> do
|
||||
@@ -856,9 +858,13 @@ processChatCommand' vr = \case
|
||||
if featureAllowed SCFFullDelete forUser ct
|
||||
then deleteDirectCIs user ct items True False
|
||||
else markDirectCIsDeleted user ct items True =<< liftIO getCurrentTime
|
||||
where
|
||||
getDirectCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTDirect))
|
||||
getDirectCI db itemId = runExceptT . withExceptT ChatErrorStore $ getDirectChatItem db user chatId itemId
|
||||
CTGroup -> withGroupLock "deleteChatItem" chatId $ do
|
||||
(gInfo, items) <- getCommandGroupChatItems user chatId itemIds
|
||||
ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo
|
||||
Group gInfo ms <- withStore $ \db -> getGroup db vr user chatId
|
||||
(errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db) (L.toList itemIds))
|
||||
unless (null errs) $ toView $ CRChatErrors (Just user) errs
|
||||
case mode of
|
||||
CIDMInternal -> deleteGroupCIs user gInfo items True False Nothing =<< liftIO getCurrentTime
|
||||
CIDMBroadcast -> do
|
||||
@@ -868,9 +874,17 @@ processChatCommand' vr = \case
|
||||
events = L.nonEmpty $ map (`XMsgDel` Nothing) msgIds
|
||||
mapM_ (sendGroupMessages user gInfo ms) events
|
||||
delGroupChatItems user gInfo items Nothing
|
||||
where
|
||||
getGroupCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup))
|
||||
getGroupCI db itemId = runExceptT . withExceptT ChatErrorStore $ getGroupChatItem db user chatId itemId
|
||||
CTLocal -> do
|
||||
(nf, items) <- getCommandLocalChatItems user chatId itemIds
|
||||
nf <- withStore $ \db -> getNoteFolder db user chatId
|
||||
(errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getLocalCI db) (L.toList itemIds))
|
||||
unless (null errs) $ toView $ CRChatErrors (Just user) errs
|
||||
deleteLocalCIs user nf items True False
|
||||
where
|
||||
getLocalCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTLocal))
|
||||
getLocalCI db itemId = runExceptT . withExceptT ChatErrorStore $ getLocalChatItem db user chatId itemId
|
||||
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
|
||||
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
|
||||
where
|
||||
@@ -888,8 +902,9 @@ processChatCommand' vr = \case
|
||||
itemsMsgIds :: [CChatItem c] -> [SharedMsgId]
|
||||
itemsMsgIds = mapMaybe (\(CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId}}) -> itemSharedMsgId)
|
||||
APIDeleteMemberChatItem gId itemIds -> withUser $ \user -> withGroupLock "deleteChatItem" gId $ do
|
||||
(gInfo@GroupInfo {membership}, items) <- getCommandGroupChatItems user gId itemIds
|
||||
ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo
|
||||
Group gInfo@GroupInfo {membership} ms <- withStore $ \db -> getGroup db vr user gId
|
||||
(errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db user) (L.toList itemIds))
|
||||
unless (null errs) $ toView $ CRChatErrors (Just user) errs
|
||||
assertDeletable gInfo items
|
||||
assertUserGroupRole gInfo GRAdmin
|
||||
let msgMemIds = itemsMsgMemIds gInfo items
|
||||
@@ -897,6 +912,8 @@ processChatCommand' vr = \case
|
||||
mapM_ (sendGroupMessages user gInfo ms) events
|
||||
delGroupChatItems user gInfo items (Just membership)
|
||||
where
|
||||
getGroupCI :: DB.Connection -> User -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup))
|
||||
getGroupCI db user itemId = runExceptT . withExceptT ChatErrorStore $ getGroupChatItem db user gId itemId
|
||||
assertDeletable :: GroupInfo -> [CChatItem 'CTGroup] -> CM ()
|
||||
assertDeletable GroupInfo {membership = GroupMember {memberRole = membershipMemRole}} items =
|
||||
unless (all itemDeletable items) $ throwChatError CEInvalidChatItemDelete
|
||||
@@ -963,51 +980,6 @@ processChatCommand' vr = \case
|
||||
throwChatError (CECommandError $ "reaction already " <> if add then "added" else "removed")
|
||||
when (add && length rs >= maxMsgReactions) $
|
||||
throwChatError (CECommandError "too many reactions")
|
||||
APIPlanForwardChatItems (ChatRef fromCType fromChatId) itemIds -> withUser $ \user -> case fromCType of
|
||||
CTDirect -> planForward user . snd =<< getCommandDirectChatItems user fromChatId itemIds
|
||||
CTGroup -> planForward user . snd =<< getCommandGroupChatItems user fromChatId itemIds
|
||||
CTLocal -> planForward user . snd =<< getCommandLocalChatItems user fromChatId itemIds
|
||||
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
|
||||
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
|
||||
where
|
||||
planForward :: User -> [CChatItem c] -> CM ChatResponse
|
||||
planForward user items = do
|
||||
(itemIds', forwardErrors) <- unzip <$> mapM planItemForward items
|
||||
let forwardConfirmation = case catMaybes forwardErrors of
|
||||
[] -> Nothing
|
||||
errs -> Just $ case mainErr of
|
||||
FFENotAccepted _ -> FCFilesNotAccepted fileIds
|
||||
FFEInProgress -> FCFilesInProgress filesCount
|
||||
FFEMissing -> FCFilesMissing filesCount
|
||||
FFEFailed -> FCFilesFailed filesCount
|
||||
where
|
||||
mainErr = minimum errs
|
||||
fileIds = catMaybes $ map (\case FFENotAccepted ftId -> Just ftId; _ -> Nothing) errs
|
||||
filesCount = length $ filter (mainErr ==) errs
|
||||
pure CRForwardPlan {user, itemsCount = length itemIds, chatItemIds = catMaybes itemIds', forwardConfirmation}
|
||||
where
|
||||
planItemForward :: CChatItem c -> CM (Maybe ChatItemId, Maybe ForwardFileError)
|
||||
planItemForward (CChatItem _ ci) = forwardMsgContent ci >>= maybe (pure (Nothing, Nothing)) (forwardContentPlan ci)
|
||||
forwardContentPlan :: ChatItem c d -> MsgContent -> CM (Maybe ChatItemId, Maybe ForwardFileError)
|
||||
forwardContentPlan ChatItem {file, meta = CIMeta {itemId}} mc = case file of
|
||||
Nothing -> pure (Just itemId, Nothing)
|
||||
Just CIFile {fileId, fileStatus, fileSource} -> case ciFileForwardError fileId fileStatus of
|
||||
Just err -> pure $ itemIdWithoutFile err
|
||||
Nothing -> case fileSource of
|
||||
Just CryptoFile {filePath} -> do
|
||||
exists <- doesFileExist . maybe filePath (</> filePath) =<< chatReadVar filesFolder
|
||||
pure $ if exists then (Just itemId, Nothing) else itemIdWithoutFile FFEMissing
|
||||
Nothing -> pure $ itemIdWithoutFile FFEMissing
|
||||
where
|
||||
itemIdWithoutFile err = (if hasContent then Just itemId else Nothing, Just err)
|
||||
hasContent = case mc of
|
||||
MCText _ -> True
|
||||
MCLink {} -> True
|
||||
MCImage {} -> True
|
||||
MCVideo {text} -> text /= ""
|
||||
MCVoice {text} -> text /= ""
|
||||
MCFile t -> t /= ""
|
||||
MCUnknown {} -> True
|
||||
APIForwardChatItems (ChatRef toCType toChatId) (ChatRef fromCType fromChatId) itemIds itemTTL -> withUser $ \user -> case toCType of
|
||||
CTDirect -> do
|
||||
cmrs <- prepareForward user
|
||||
@@ -1015,76 +987,96 @@ processChatCommand' vr = \case
|
||||
Just cmrs' ->
|
||||
withContactLock "forwardChatItem, to contact" toChatId $
|
||||
sendContactContentMessages user toChatId False itemTTL cmrs'
|
||||
Nothing -> pure $ CRNewChatItems user []
|
||||
Nothing -> throwChatError $ CEInternalError "no chat items to forward"
|
||||
CTGroup -> do
|
||||
cmrs <- prepareForward user
|
||||
case L.nonEmpty cmrs of
|
||||
Just cmrs' ->
|
||||
withGroupLock "forwardChatItem, to group" toChatId $
|
||||
sendGroupContentMessages user toChatId False itemTTL cmrs'
|
||||
Nothing -> pure $ CRNewChatItems user []
|
||||
Nothing -> throwChatError $ CEInternalError "no chat items to forward"
|
||||
CTLocal -> do
|
||||
cmrs <- prepareForward user
|
||||
case L.nonEmpty cmrs of
|
||||
Just cmrs' ->
|
||||
createNoteFolderContentItems user toChatId cmrs'
|
||||
Nothing -> pure $ CRNewChatItems user []
|
||||
Nothing -> throwChatError $ CEInternalError "no chat items to forward"
|
||||
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
|
||||
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
|
||||
where
|
||||
prepareForward :: User -> CM [ComposeMessageReq]
|
||||
prepareForward user = case fromCType of
|
||||
CTDirect -> withContactLock "forwardChatItem, from contact" fromChatId $ do
|
||||
(ct, items) <- getCommandDirectChatItems user fromChatId itemIds
|
||||
catMaybes <$> mapM (\ci -> ciComposeMsgReq ct ci <$$> prepareMsgReq ci) items
|
||||
ct <- withFastStore $ \db -> getContact db vr user fromChatId
|
||||
(errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getDirectCI db) (L.toList itemIds))
|
||||
unless (null errs) $ toView $ CRChatErrors (Just user) errs
|
||||
mapM (ciComposeMsgReq ct) items
|
||||
where
|
||||
ciComposeMsgReq :: Contact -> CChatItem 'CTDirect -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq
|
||||
ciComposeMsgReq ct (CChatItem md ci) (mc', file) =
|
||||
getDirectCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTDirect))
|
||||
getDirectCI db itemId = runExceptT . withExceptT ChatErrorStore $ getDirectChatItem db user fromChatId itemId
|
||||
ciComposeMsgReq :: Contact -> CChatItem 'CTDirect -> CM ComposeMessageReq
|
||||
ciComposeMsgReq ct (CChatItem _ ci) = do
|
||||
(mc, mDir) <- forwardMC ci
|
||||
file <- forwardCryptoFile ci
|
||||
let itemId = chatItemId' ci
|
||||
ciff = forwardCIFF ci $ Just (CIFFContact (forwardName ct) (toMsgDirection md) (Just fromChatId) (Just itemId))
|
||||
in (ComposedMessage file Nothing mc', ciff)
|
||||
ciff = forwardCIFF ci $ Just (CIFFContact (forwardName ct) mDir (Just fromChatId) (Just itemId))
|
||||
pure (ComposedMessage file Nothing mc, ciff)
|
||||
where
|
||||
forwardName :: Contact -> ContactName
|
||||
forwardName Contact {profile = LocalProfile {displayName, localAlias}}
|
||||
| localAlias /= "" = localAlias
|
||||
| otherwise = displayName
|
||||
CTGroup -> withGroupLock "forwardChatItem, from group" fromChatId $ do
|
||||
(gInfo, items) <- getCommandGroupChatItems user fromChatId itemIds
|
||||
catMaybes <$> mapM (\ci -> ciComposeMsgReq gInfo ci <$$> prepareMsgReq ci) items
|
||||
gInfo <- withFastStore $ \db -> getGroupInfo db vr user fromChatId
|
||||
(errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db) (L.toList itemIds))
|
||||
unless (null errs) $ toView $ CRChatErrors (Just user) errs
|
||||
mapM (ciComposeMsgReq gInfo) items
|
||||
where
|
||||
ciComposeMsgReq :: GroupInfo -> CChatItem 'CTGroup -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq
|
||||
ciComposeMsgReq gInfo (CChatItem md ci) (mc', file) = do
|
||||
getGroupCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup))
|
||||
getGroupCI db itemId = runExceptT . withExceptT ChatErrorStore $ getGroupChatItem db user fromChatId itemId
|
||||
ciComposeMsgReq :: GroupInfo -> CChatItem 'CTGroup -> CM ComposeMessageReq
|
||||
ciComposeMsgReq gInfo (CChatItem _ ci) = do
|
||||
(mc, mDir) <- forwardMC ci
|
||||
file <- forwardCryptoFile ci
|
||||
let itemId = chatItemId' ci
|
||||
ciff = forwardCIFF ci $ Just (CIFFGroup (forwardName gInfo) (toMsgDirection md) (Just fromChatId) (Just itemId))
|
||||
in (ComposedMessage file Nothing mc', ciff)
|
||||
ciff = forwardCIFF ci $ Just (CIFFGroup (forwardName gInfo) mDir (Just fromChatId) (Just itemId))
|
||||
pure (ComposedMessage file Nothing mc, ciff)
|
||||
where
|
||||
forwardName :: GroupInfo -> ContactName
|
||||
forwardName GroupInfo {groupProfile = GroupProfile {displayName}} = displayName
|
||||
CTLocal -> do
|
||||
(_, items) <- getCommandLocalChatItems user fromChatId itemIds
|
||||
catMaybes <$> mapM (\ci -> ciComposeMsgReq ci <$$> prepareMsgReq ci) items
|
||||
(errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getLocalCI db) (L.toList itemIds))
|
||||
unless (null errs) $ toView $ CRChatErrors (Just user) errs
|
||||
mapM ciComposeMsgReq items
|
||||
where
|
||||
ciComposeMsgReq :: CChatItem 'CTLocal -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq
|
||||
ciComposeMsgReq (CChatItem _ ci) (mc', file) =
|
||||
getLocalCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTLocal))
|
||||
getLocalCI db itemId = runExceptT . withExceptT ChatErrorStore $ getLocalChatItem db user fromChatId itemId
|
||||
ciComposeMsgReq :: CChatItem 'CTLocal -> CM ComposeMessageReq
|
||||
ciComposeMsgReq (CChatItem _ ci) = do
|
||||
(mc, _) <- forwardMC ci
|
||||
file <- forwardCryptoFile ci
|
||||
let ciff = forwardCIFF ci Nothing
|
||||
in (ComposedMessage file Nothing mc', ciff)
|
||||
pure (ComposedMessage file Nothing mc, ciff)
|
||||
CTContactRequest -> throwChatError $ CECommandError "not supported"
|
||||
CTContactConnection -> throwChatError $ CECommandError "not supported"
|
||||
where
|
||||
prepareMsgReq :: CChatItem c -> CM (Maybe (MsgContent, Maybe CryptoFile))
|
||||
prepareMsgReq (CChatItem _ ci) = forwardMsgContent ci $>>= forwardContent ci
|
||||
forwardMC :: ChatItem c d -> CM (MsgContent, MsgDirection)
|
||||
forwardMC ChatItem {meta = CIMeta {itemDeleted = Just _}} = throwChatError CEInvalidForward
|
||||
forwardMC ChatItem {content = CISndMsgContent fmc} = pure (fmc, MDSnd)
|
||||
forwardMC ChatItem {content = CIRcvMsgContent fmc} = pure (fmc, MDRcv)
|
||||
forwardMC _ = throwChatError CEInvalidForward
|
||||
forwardCIFF :: ChatItem c d -> Maybe CIForwardedFrom -> Maybe CIForwardedFrom
|
||||
forwardCIFF ChatItem {meta = CIMeta {itemForwarded}} ciff = case itemForwarded of
|
||||
Nothing -> ciff
|
||||
Just CIFFUnknown -> ciff
|
||||
Just prevCIFF -> Just prevCIFF
|
||||
forwardContent :: ChatItem c d -> MsgContent -> CM (Maybe (MsgContent, Maybe CryptoFile))
|
||||
forwardContent ChatItem {file = Nothing} mc = pure $ Just (mc, Nothing)
|
||||
forwardContent ChatItem {file = Just ciFile} mc = case ciFile of
|
||||
forwardCryptoFile :: ChatItem c d -> CM (Maybe CryptoFile)
|
||||
forwardCryptoFile ChatItem {file = Nothing} = pure Nothing
|
||||
forwardCryptoFile ChatItem {file = Just ciFile} = case ciFile of
|
||||
CIFile {fileName, fileSource = Just fromCF@CryptoFile {filePath}} ->
|
||||
chatReadVar filesFolder >>= \case
|
||||
Nothing ->
|
||||
ifM (doesFileExist filePath) (pure $ Just (mc, Just fromCF)) (pure contentWithoutFile)
|
||||
ifM (doesFileExist filePath) (pure $ Just fromCF) (pure Nothing)
|
||||
Just filesFolder -> do
|
||||
let fsFromPath = filesFolder </> filePath
|
||||
ifM
|
||||
@@ -1097,17 +1089,10 @@ processChatCommand' vr = \case
|
||||
let toCF = CryptoFile fsNewPath cfArgs
|
||||
-- to keep forwarded file in case original is deleted
|
||||
liftIOEither $ runExceptT $ withExceptT (ChatError . CEInternalError . show) $ copyCryptoFile (fromCF {filePath = fsFromPath} :: CryptoFile) toCF
|
||||
pure $ Just (mc, Just (toCF {filePath = takeFileName fsNewPath} :: CryptoFile))
|
||||
pure $ Just (toCF {filePath = takeFileName fsNewPath} :: CryptoFile)
|
||||
)
|
||||
(pure contentWithoutFile)
|
||||
_ -> pure contentWithoutFile
|
||||
where
|
||||
contentWithoutFile = case mc of
|
||||
MCImage {} -> Just (mc, Nothing)
|
||||
MCLink {} -> Just (mc, Nothing)
|
||||
_ | contentText /= "" -> Just (MCText contentText, Nothing)
|
||||
_ -> Nothing
|
||||
contentText = msgContentText mc
|
||||
(pure Nothing)
|
||||
_ -> pure Nothing
|
||||
copyCryptoFile :: CryptoFile -> CryptoFile -> ExceptT CF.FTCryptoError IO ()
|
||||
copyCryptoFile fromCF@CryptoFile {filePath = fsFromPath, cryptoArgs = fromArgs} toCF@CryptoFile {cryptoArgs = toArgs} = do
|
||||
fromSizeFull <- getFileSize fsFromPath
|
||||
@@ -1129,24 +1114,26 @@ processChatCommand' vr = \case
|
||||
when (size' > 0) $ copyChunks r w size'
|
||||
APIUserRead userId -> withUserId userId $ \user -> withFastStore' (`setUserChatsRead` user) >> ok user
|
||||
UserRead -> withUser $ \User {userId} -> processChatCommand $ APIUserRead userId
|
||||
APIChatRead chatRef@(ChatRef cType chatId) fromToIds -> withUser $ \_ -> case cType of
|
||||
APIChatRead (ChatRef cType chatId) fromToIds -> withUser $ \_ -> case cType of
|
||||
CTDirect -> do
|
||||
user <- withFastStore $ \db -> getUserByContactId db chatId
|
||||
timedItems <- withFastStore' $ \db -> getDirectUnreadTimedItems db user chatId fromToIds
|
||||
ts <- liftIO getCurrentTime
|
||||
timedItems <- withFastStore' $ \db -> do
|
||||
timedItems <- getDirectUnreadTimedItems db user chatId fromToIds
|
||||
updateDirectChatItemsRead db user chatId fromToIds
|
||||
setDirectChatItemsDeleteAt db user chatId timedItems ts
|
||||
forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt
|
||||
forM_ timedItems $ \(itemId, ttl) -> do
|
||||
let deleteAt = addUTCTime (realToFrac ttl) ts
|
||||
withFastStore' $ \db -> setDirectChatItemDeleteAt db user chatId itemId deleteAt
|
||||
startProximateTimedItemThread user (ChatRef CTDirect chatId, itemId) deleteAt
|
||||
withFastStore' $ \db -> updateDirectChatItemsRead db user chatId fromToIds
|
||||
ok user
|
||||
CTGroup -> do
|
||||
user <- withFastStore $ \db -> getUserByGroupId db chatId
|
||||
user@User {userId} <- withFastStore $ \db -> getUserByGroupId db chatId
|
||||
timedItems <- withFastStore' $ \db -> getGroupUnreadTimedItems db user chatId fromToIds
|
||||
ts <- liftIO getCurrentTime
|
||||
timedItems <- withFastStore' $ \db -> do
|
||||
timedItems <- getGroupUnreadTimedItems db user chatId fromToIds
|
||||
updateGroupChatItemsRead db user chatId fromToIds
|
||||
setGroupChatItemsDeleteAt db user chatId timedItems ts
|
||||
forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt
|
||||
forM_ timedItems $ \(itemId, ttl) -> do
|
||||
let deleteAt = addUTCTime (realToFrac ttl) ts
|
||||
withFastStore' $ \db -> setGroupChatItemDeleteAt db user chatId itemId deleteAt
|
||||
startProximateTimedItemThread user (ChatRef CTGroup chatId, itemId) deleteAt
|
||||
withFastStore' $ \db -> updateGroupChatItemsRead db userId chatId fromToIds
|
||||
ok user
|
||||
CTLocal -> do
|
||||
user <- withFastStore $ \db -> getUserByNoteFolderId db chatId
|
||||
@@ -1154,24 +1141,6 @@ processChatCommand' vr = \case
|
||||
ok user
|
||||
CTContactRequest -> pure $ chatCmdError Nothing "not supported"
|
||||
CTContactConnection -> pure $ chatCmdError Nothing "not supported"
|
||||
APIChatItemsRead chatRef@(ChatRef cType chatId) itemIds -> withUser $ \_ -> case cType of
|
||||
CTDirect -> do
|
||||
user <- withFastStore $ \db -> getUserByContactId db chatId
|
||||
timedItems <- withFastStore' $ \db -> do
|
||||
timedItems <- updateDirectChatItemsReadList db user chatId itemIds
|
||||
setDirectChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime
|
||||
forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt
|
||||
ok user
|
||||
CTGroup -> do
|
||||
user <- withFastStore $ \db -> getUserByGroupId db chatId
|
||||
timedItems <- withFastStore' $ \db -> do
|
||||
timedItems <- updateGroupChatItemsReadList db user chatId itemIds
|
||||
setGroupChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime
|
||||
forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt
|
||||
ok user
|
||||
CTLocal -> pure $ chatCmdError Nothing "not supported"
|
||||
CTContactRequest -> pure $ chatCmdError Nothing "not supported"
|
||||
CTContactConnection -> pure $ chatCmdError Nothing "not supported"
|
||||
APIChatUnread (ChatRef cType chatId) unreadChat -> withUser $ \user -> case cType of
|
||||
CTDirect -> do
|
||||
withFastStore $ \db -> do
|
||||
@@ -2822,10 +2791,7 @@ processChatCommand' vr = \case
|
||||
filesInfo <- withFastStore' (`getUserFileInfo` user)
|
||||
cancelFilesInProgress user filesInfo
|
||||
deleteFilesLocally filesInfo
|
||||
withAgent (\a -> deleteUser a (aUserId user) delSMPQueues)
|
||||
`catchChatError` \case
|
||||
e@(ChatErrorAgent NO_USER _) -> toView $ CRChatError (Just user) e
|
||||
e -> throwError e
|
||||
withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues
|
||||
withFastStore' (`deleteUserRecord` user)
|
||||
when (activeUser user) $ chatWriteVar currentUser Nothing
|
||||
ok_
|
||||
@@ -3102,38 +3068,6 @@ processChatCommand' vr = \case
|
||||
| (msg_, (ComposedMessage {msgContent}, itemForwarded), f, q) <-
|
||||
zipWith4 (,,,) msgs_ (L.toList cmrs') (L.toList ciFiles_) (L.toList quotedItems_)
|
||||
]
|
||||
getCommandDirectChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (Contact, [CChatItem 'CTDirect])
|
||||
getCommandDirectChatItems user ctId itemIds = do
|
||||
ct <- withFastStore $ \db -> getContact db vr user ctId
|
||||
(errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getDirectCI db) (L.toList itemIds))
|
||||
unless (null errs) $ toView $ CRChatErrors (Just user) errs
|
||||
pure (ct, items)
|
||||
where
|
||||
getDirectCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTDirect))
|
||||
getDirectCI db itemId = runExceptT . withExceptT ChatErrorStore $ getDirectChatItem db user ctId itemId
|
||||
getCommandGroupChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (GroupInfo, [CChatItem 'CTGroup])
|
||||
getCommandGroupChatItems user gId itemIds = do
|
||||
gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId
|
||||
(errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db) (L.toList itemIds))
|
||||
unless (null errs) $ toView $ CRChatErrors (Just user) errs
|
||||
pure (gInfo, items)
|
||||
where
|
||||
getGroupCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup))
|
||||
getGroupCI db itemId = runExceptT . withExceptT ChatErrorStore $ getGroupChatItem db user gId itemId
|
||||
getCommandLocalChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (NoteFolder, [CChatItem 'CTLocal])
|
||||
getCommandLocalChatItems user nfId itemIds = do
|
||||
nf <- withStore $ \db -> getNoteFolder db user nfId
|
||||
(errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getLocalCI db) (L.toList itemIds))
|
||||
unless (null errs) $ toView $ CRChatErrors (Just user) errs
|
||||
pure (nf, items)
|
||||
where
|
||||
getLocalCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTLocal))
|
||||
getLocalCI db itemId = runExceptT . withExceptT ChatErrorStore $ getLocalChatItem db user nfId itemId
|
||||
forwardMsgContent :: ChatItem c d -> CM (Maybe MsgContent)
|
||||
forwardMsgContent ChatItem {meta = CIMeta {itemDeleted = Just _}} = pure Nothing -- this can be deleted after selection
|
||||
forwardMsgContent ChatItem {content = CISndMsgContent fmc} = pure $ Just fmc
|
||||
forwardMsgContent ChatItem {content = CIRcvMsgContent fmc} = pure $ Just fmc
|
||||
forwardMsgContent _ = throwChatError CEInvalidForward
|
||||
createNoteFolderContentItems :: User -> NoteFolderId -> NonEmpty ComposeMessageReq -> CM ChatResponse
|
||||
createNoteFolderContentItems user folderId cmrs = do
|
||||
assertNoQuotes
|
||||
@@ -7935,12 +7869,10 @@ chatCommandP =
|
||||
"/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <* A.space <*> ciDeleteMode),
|
||||
"/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <*> _strP),
|
||||
"/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP),
|
||||
"/_forward plan " *> (APIPlanForwardChatItems <$> chatRefP <*> _strP),
|
||||
"/_forward " *> (APIForwardChatItems <$> chatRefP <* A.space <*> chatRefP <*> _strP <*> sendMessageTTLP),
|
||||
"/_read user " *> (APIUserRead <$> A.decimal),
|
||||
"/read user" $> UserRead,
|
||||
"/_read chat " *> (APIChatRead <$> chatRefP <*> optional (A.space *> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal)))),
|
||||
"/_read chat items " *> (APIChatItemsRead <$> chatRefP <*> _strP),
|
||||
"/_unread chat " *> (APIChatUnread <$> chatRefP <* A.space <*> onOffP),
|
||||
"/_delete " *> (APIDeleteChat <$> chatRefP <*> chatDeleteMode),
|
||||
"/_clear chat " *> (APIClearChat <$> chatRefP),
|
||||
|
||||
@@ -298,12 +298,10 @@ data ChatCommand
|
||||
| APIDeleteChatItem ChatRef (NonEmpty ChatItemId) CIDeleteMode
|
||||
| APIDeleteMemberChatItem GroupId (NonEmpty ChatItemId)
|
||||
| APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, add :: Bool, reaction :: MsgReaction}
|
||||
| APIPlanForwardChatItems {fromChatRef :: ChatRef, chatItemIds :: NonEmpty ChatItemId}
|
||||
| APIForwardChatItems {toChatRef :: ChatRef, fromChatRef :: ChatRef, chatItemIds :: NonEmpty ChatItemId, ttl :: Maybe Int}
|
||||
| APIUserRead UserId
|
||||
| UserRead
|
||||
| APIChatRead ChatRef (Maybe (ChatItemId, ChatItemId))
|
||||
| APIChatItemsRead ChatRef (NonEmpty ChatItemId)
|
||||
| APIChatUnread ChatRef Bool
|
||||
| APIDeleteChat ChatRef ChatDeleteMode -- currently delete mode settings are only applied to direct chats
|
||||
| APIClearChat ChatRef
|
||||
@@ -650,7 +648,6 @@ data ChatResponse
|
||||
| CRContactRequestAlreadyAccepted {user :: User, contact :: Contact}
|
||||
| CRLeftMemberUser {user :: User, groupInfo :: GroupInfo}
|
||||
| CRGroupDeletedUser {user :: User, groupInfo :: GroupInfo}
|
||||
| CRForwardPlan {user :: User, itemsCount :: Int, chatItemIds :: [ChatItemId], forwardConfirmation :: Maybe ForwardConfirmation}
|
||||
| CRRcvFileDescrReady {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer, rcvFileDescr :: RcvFileDescr}
|
||||
| CRRcvFileAccepted {user :: User, chatItem :: AChatItem}
|
||||
| CRRcvFileAcceptedSndCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer}
|
||||
@@ -907,13 +904,6 @@ connectionPlanProceed = \case
|
||||
GLPConnectingConfirmReconnect -> True
|
||||
_ -> False
|
||||
|
||||
data ForwardConfirmation
|
||||
= FCFilesNotAccepted {fileIds :: [FileTransferId]}
|
||||
| FCFilesInProgress {filesCount :: Int}
|
||||
| FCFilesMissing {filesCount :: Int}
|
||||
| FCFilesFailed {filesCount :: Int}
|
||||
deriving (Show)
|
||||
|
||||
newtype UserPwd = UserPwd {unUserPwd :: Text}
|
||||
deriving (Eq, Show)
|
||||
|
||||
@@ -1472,8 +1462,6 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GLP") ''GroupLinkPlan)
|
||||
|
||||
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CP") ''ConnectionPlan)
|
||||
|
||||
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "FC") ''ForwardConfirmation)
|
||||
|
||||
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CE") ''ChatErrorType)
|
||||
|
||||
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHE") ''RemoteHostError)
|
||||
|
||||
@@ -595,27 +595,6 @@ ciFileLoaded = \case
|
||||
CIFSRcvWarning {} -> False
|
||||
CIFSInvalid {} -> False
|
||||
|
||||
data ForwardFileError = FFENotAccepted FileTransferId | FFEInProgress | FFEFailed | FFEMissing
|
||||
deriving (Eq, Ord)
|
||||
|
||||
ciFileForwardError :: FileTransferId -> CIFileStatus d -> Maybe ForwardFileError
|
||||
ciFileForwardError fId = \case
|
||||
CIFSSndStored -> Nothing
|
||||
CIFSSndTransfer {} -> Nothing
|
||||
CIFSSndComplete -> Nothing
|
||||
CIFSSndCancelled -> Nothing
|
||||
CIFSSndError {} -> Nothing
|
||||
CIFSSndWarning {} -> Nothing
|
||||
CIFSRcvInvitation -> Just $ FFENotAccepted fId
|
||||
CIFSRcvAccepted -> Just FFEInProgress
|
||||
CIFSRcvTransfer {} -> Just FFEInProgress
|
||||
CIFSRcvAborted -> Just $ FFENotAccepted fId
|
||||
CIFSRcvCancelled -> Just FFEFailed
|
||||
CIFSRcvComplete -> Nothing
|
||||
CIFSRcvError {} -> Just FFEFailed
|
||||
CIFSRcvWarning {} -> Just FFEFailed
|
||||
CIFSInvalid {} -> Just FFEFailed
|
||||
|
||||
data ACIFileStatus = forall d. MsgDirectionI d => AFS (SMsgDirection d) (CIFileStatus d)
|
||||
|
||||
deriving instance Show ACIFileStatus
|
||||
|
||||
@@ -60,12 +60,10 @@ module Simplex.Chat.Store.Messages
|
||||
deleteLocalChatItem,
|
||||
updateDirectChatItemsRead,
|
||||
getDirectUnreadTimedItems,
|
||||
updateDirectChatItemsReadList,
|
||||
setDirectChatItemsDeleteAt,
|
||||
setDirectChatItemDeleteAt,
|
||||
updateGroupChatItemsRead,
|
||||
getGroupUnreadTimedItems,
|
||||
updateGroupChatItemsReadList,
|
||||
setGroupChatItemsDeleteAt,
|
||||
setGroupChatItemDeleteAt,
|
||||
updateLocalChatItemsRead,
|
||||
getChatRefViaItemId,
|
||||
getChatItemVersions,
|
||||
@@ -128,9 +126,7 @@ import Data.ByteString.Char8 (ByteString)
|
||||
import Data.Either (fromRight, rights)
|
||||
import Data.Int (Int64)
|
||||
import Data.List (sortBy)
|
||||
import Data.List.NonEmpty (NonEmpty)
|
||||
import qualified Data.List.NonEmpty as L
|
||||
import Data.Maybe (catMaybes, fromMaybe, isJust, mapMaybe)
|
||||
import Data.Maybe (fromMaybe, isJust, mapMaybe)
|
||||
import Data.Ord (Down (..), comparing)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
@@ -1343,27 +1339,15 @@ getDirectUnreadTimedItems db User {userId} contactId itemsRange_ = case itemsRan
|
||||
|]
|
||||
(userId, contactId, CISRcvNew)
|
||||
|
||||
updateDirectChatItemsReadList :: DB.Connection -> User -> ContactId -> NonEmpty ChatItemId -> IO [(ChatItemId, Int)]
|
||||
updateDirectChatItemsReadList db user contactId itemIds = do
|
||||
catMaybes . L.toList <$> mapM getUpdateDirectItem itemIds
|
||||
where
|
||||
getUpdateDirectItem chatItemId = do
|
||||
let itemsRange = Just (chatItemId, chatItemId)
|
||||
timedItem <- maybeFirstRow id $ getDirectUnreadTimedItems db user contactId itemsRange
|
||||
updateDirectChatItemsRead db user contactId itemsRange
|
||||
pure timedItem
|
||||
|
||||
setDirectChatItemsDeleteAt :: DB.Connection -> User -> ContactId -> [(ChatItemId, Int)] -> UTCTime -> IO [(ChatItemId, UTCTime)]
|
||||
setDirectChatItemsDeleteAt db User {userId} contactId itemIds currentTs = forM itemIds $ \(chatItemId, ttl) -> do
|
||||
let deleteAt = addUTCTime (realToFrac ttl) currentTs
|
||||
setDirectChatItemDeleteAt :: DB.Connection -> User -> ContactId -> ChatItemId -> UTCTime -> IO ()
|
||||
setDirectChatItemDeleteAt db User {userId} contactId chatItemId deleteAt =
|
||||
DB.execute
|
||||
db
|
||||
"UPDATE chat_items SET timed_delete_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?"
|
||||
(deleteAt, userId, contactId, chatItemId)
|
||||
pure (chatItemId, deleteAt)
|
||||
|
||||
updateGroupChatItemsRead :: DB.Connection -> User -> GroupId -> Maybe (ChatItemId, ChatItemId) -> IO ()
|
||||
updateGroupChatItemsRead db User {userId} groupId itemsRange_ = do
|
||||
updateGroupChatItemsRead :: DB.Connection -> UserId -> GroupId -> Maybe (ChatItemId, ChatItemId) -> IO ()
|
||||
updateGroupChatItemsRead db userId groupId itemsRange_ = do
|
||||
currentTs <- getCurrentTime
|
||||
case itemsRange_ of
|
||||
Just (fromItemId, toItemId) ->
|
||||
@@ -1408,24 +1392,12 @@ getGroupUnreadTimedItems db User {userId} groupId itemsRange_ = case itemsRange_
|
||||
|]
|
||||
(userId, groupId, CISRcvNew)
|
||||
|
||||
updateGroupChatItemsReadList :: DB.Connection -> User -> GroupId -> NonEmpty ChatItemId -> IO [(ChatItemId, Int)]
|
||||
updateGroupChatItemsReadList db user groupId itemIds = do
|
||||
catMaybes . L.toList <$> mapM getUpdateGroupItem itemIds
|
||||
where
|
||||
getUpdateGroupItem chatItemId = do
|
||||
let itemsRange = Just (chatItemId, chatItemId)
|
||||
timedItem <- maybeFirstRow id $ getGroupUnreadTimedItems db user groupId itemsRange
|
||||
updateGroupChatItemsRead db user groupId itemsRange
|
||||
pure timedItem
|
||||
|
||||
setGroupChatItemsDeleteAt :: DB.Connection -> User -> GroupId -> [(ChatItemId, Int)] -> UTCTime -> IO [(ChatItemId, UTCTime)]
|
||||
setGroupChatItemsDeleteAt db User {userId} groupId itemIds currentTs = forM itemIds $ \(chatItemId, ttl) -> do
|
||||
let deleteAt = addUTCTime (realToFrac ttl) currentTs
|
||||
setGroupChatItemDeleteAt :: DB.Connection -> User -> GroupId -> ChatItemId -> UTCTime -> IO ()
|
||||
setGroupChatItemDeleteAt db User {userId} groupId chatItemId deleteAt =
|
||||
DB.execute
|
||||
db
|
||||
"UPDATE chat_items SET timed_delete_at = ? WHERE user_id = ? AND group_id = ? AND chat_item_id = ?"
|
||||
(deleteAt, userId, groupId, chatItemId)
|
||||
pure (chatItemId, deleteAt)
|
||||
|
||||
updateLocalChatItemsRead :: DB.Connection -> User -> NoteFolderId -> Maybe (ChatItemId, ChatItemId) -> IO ()
|
||||
updateLocalChatItemsRead db User {userId} noteFolderId itemsRange_ = do
|
||||
|
||||
@@ -383,6 +383,7 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId =
|
||||
|]
|
||||
[":user_id" := userId, ":profile_id" := profileId]
|
||||
|
||||
|
||||
type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool, ContactStatus) :. (Maybe MsgFilter, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. (Maybe GroupMemberId, Bool, Maybe UIThemeEntityOverrides, Bool, Maybe CustomData)
|
||||
|
||||
type ContactRow = Only ContactId :. ContactRow'
|
||||
|
||||
@@ -203,7 +203,6 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
|
||||
CRUnknownMemberBlocked u g byM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember byM <> " blocked an unknown member, creating unknown member record " <> ttyMember um]
|
||||
CRUnknownMemberAnnounced u g _ um m -> ttyUser u [ttyGroup' g <> ": unknown member " <> ttyMember um <> " updated to " <> ttyMember m]
|
||||
CRGroupDeletedUser u g -> ttyUser u [ttyGroup' g <> ": you deleted the group"]
|
||||
CRForwardPlan u count itemIds fc -> ttyUser u $ viewForwardPlan count itemIds fc
|
||||
CRRcvFileDescrReady _ _ _ _ -> []
|
||||
CRRcvFileProgressXFTP {} -> []
|
||||
CRRcvFileAccepted u ci -> ttyUser u $ savingFile' ci
|
||||
@@ -931,20 +930,6 @@ viewUserContactLinkDeleted =
|
||||
"To create a new chat address use " <> highlight' "/ad"
|
||||
]
|
||||
|
||||
viewForwardPlan :: Int -> [ChatItemId] -> Maybe ForwardConfirmation -> [StyledString]
|
||||
viewForwardPlan count itemIds = maybe [forwardCount] $ \fc -> [confirmation fc, forwardCount]
|
||||
where
|
||||
confirmation = \case
|
||||
FCFilesNotAccepted fileIds -> plain $ "Files can be received: " <> intercalate ", " (map show fileIds)
|
||||
FCFilesInProgress cnt -> plain $ "Still receiving " <> show cnt <> " file(s)"
|
||||
FCFilesMissing cnt -> plain $ show cnt <> " file(s) are missing"
|
||||
FCFilesFailed cnt -> plain $ "Receiving " <> show cnt <> " file(s) failed"
|
||||
forwardCount
|
||||
| count == len = "all messages can be forwarded"
|
||||
| len == 0 = "nothing to forward"
|
||||
| otherwise = plain $ show len <> " message(s) out of " <> show count <> " can be forwarded"
|
||||
len = length itemIds
|
||||
|
||||
connReqContact_ :: StyledString -> ConnReqContact -> [StyledString]
|
||||
connReqContact_ intro cReq =
|
||||
[ intro,
|
||||
@@ -2049,7 +2034,7 @@ viewChatError isCmd logLevel testView = \case
|
||||
CEFallbackToSMPProhibited fileId -> ["recipient tried to accept file " <> sShow fileId <> " via old protocol, prohibited"]
|
||||
CEInlineFileProhibited _ -> ["A small file sent without acceptance - you can enable receiving such files with -f option."]
|
||||
CEInvalidQuote -> ["cannot reply to this message"]
|
||||
CEInvalidForward -> ["cannot forward message(s)"]
|
||||
CEInvalidForward -> ["cannot forward this message"]
|
||||
CEInvalidChatItemUpdate -> ["cannot update this item"]
|
||||
CEInvalidChatItemDelete -> ["cannot delete this item"]
|
||||
CEHasCurrentCall -> ["call already in progress"]
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
{-# LANGUAGE PatternSynonyms #-}
|
||||
{-# LANGUAGE PostfixOperators #-}
|
||||
{-# LANGUAGE RankNTypes #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
|
||||
module ChatTests.Direct where
|
||||
|
||||
@@ -23,7 +22,6 @@ import Simplex.Chat.AppSettings (defaultAppSettings)
|
||||
import qualified Simplex.Chat.AppSettings as AS
|
||||
import Simplex.Chat.Call
|
||||
import Simplex.Chat.Controller (ChatConfig (..))
|
||||
import Simplex.Chat.Messages (ChatItemId)
|
||||
import Simplex.Chat.Options (ChatOpts (..))
|
||||
import Simplex.Chat.Protocol (supportedChatVRange)
|
||||
import Simplex.Chat.Store (agentStoreFile, chatStoreFile)
|
||||
@@ -40,7 +38,6 @@ chatDirectTests :: SpecWith FilePath
|
||||
chatDirectTests = do
|
||||
describe "direct messages" $ do
|
||||
describe "add contact and send/receive messages" testAddContact
|
||||
it "mark multiple messages as read" testMarkReadDirect
|
||||
it "clear chat with contact" testContactClear
|
||||
it "deleting contact deletes profile" testDeleteContactDeletesProfile
|
||||
it "delete contact keeping conversation" testDeleteContactKeepConversation
|
||||
@@ -215,22 +212,6 @@ testAddContact = versionTestMatrix2 runTestAddContact
|
||||
then chatFeatures
|
||||
else (0, e2eeInfoNoPQStr) : tail chatFeatures
|
||||
|
||||
testMarkReadDirect :: HasCallStack => FilePath -> IO ()
|
||||
testMarkReadDirect = testChat2 aliceProfile bobProfile $ \alice bob -> do
|
||||
connectUsers alice bob
|
||||
alice #> "@bob 1"
|
||||
alice #> "@bob 2"
|
||||
alice #> "@bob 3"
|
||||
alice #> "@bob 4"
|
||||
bob <# "alice> 1"
|
||||
bob <# "alice> 2"
|
||||
bob <# "alice> 3"
|
||||
bob <# "alice> 4"
|
||||
bob ##> "/last_item_id"
|
||||
i :: ChatItemId <- read <$> getTermLine bob
|
||||
let itemIds = intercalate "," $ map show [i - 3 .. i]
|
||||
bob #$> ("/_read chat items @2 " <> itemIds, id, "ok")
|
||||
|
||||
testDuplicateContactsSeparate :: HasCallStack => FilePath -> IO ()
|
||||
testDuplicateContactsSeparate =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
|
||||
+18
-73
@@ -7,11 +7,7 @@ import ChatClient
|
||||
import ChatTests.Utils
|
||||
import Control.Concurrent (threadDelay)
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import Data.List (intercalate)
|
||||
import qualified Data.Text as T
|
||||
import System.Directory (copyFile, doesFileExist, removeFile)
|
||||
import Simplex.Chat (fixedImagePreview)
|
||||
import Simplex.Chat.Types (ImageData (..))
|
||||
import System.Directory (copyFile, doesFileExist)
|
||||
import Test.Hspec hiding (it)
|
||||
|
||||
chatForwardTests :: SpecWith FilePath
|
||||
@@ -617,8 +613,6 @@ testForwardContactToContactMulti =
|
||||
alice <# "bob> hey"
|
||||
msgId2 <- lastItemId alice
|
||||
|
||||
alice ##> ("/_forward plan @2 " <> msgId1 <> "," <> msgId2)
|
||||
alice <## "all messages can be forwarded"
|
||||
alice ##> ("/_forward @3 @2 " <> msgId1 <> "," <> msgId2)
|
||||
alice <# "@cath <- you @bob"
|
||||
alice <## " hi"
|
||||
@@ -648,8 +642,6 @@ testForwardGroupToGroupMulti =
|
||||
alice <# "#team bob> hey"
|
||||
msgId2 <- lastItemId alice
|
||||
|
||||
alice ##> ("/_forward plan #1 " <> msgId1 <> "," <> msgId2)
|
||||
alice <## "all messages can be forwarded"
|
||||
alice ##> ("/_forward #2 #1 " <> msgId1 <> "," <> msgId2)
|
||||
alice <# "#club <- you #team"
|
||||
alice <## " hi"
|
||||
@@ -680,7 +672,6 @@ testMultiForwardFiles =
|
||||
setRelativePaths alice "./tests/tmp/alice_app_files" "./tests/tmp/alice_xftp"
|
||||
copyFile "./tests/fixtures/test.jpg" "./tests/tmp/alice_app_files/test.jpg"
|
||||
copyFile "./tests/fixtures/test.pdf" "./tests/tmp/alice_app_files/test.pdf"
|
||||
copyFile "./tests/fixtures/test_1MB.pdf" "./tests/tmp/alice_app_files/test_1MB.pdf"
|
||||
setRelativePaths bob "./tests/tmp/bob_app_files" "./tests/tmp/bob_xftp"
|
||||
setRelativePaths cath "./tests/tmp/cath_app_files" "./tests/tmp/cath_xftp"
|
||||
connectUsers alice bob
|
||||
@@ -695,46 +686,32 @@ testMultiForwardFiles =
|
||||
|
||||
-- send original files
|
||||
let cm1 = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message without file\"}}"
|
||||
ImageData img = fixedImagePreview
|
||||
cm2 = "{\"filePath\": \"test.jpg\", \"msgContent\": {\"type\": \"image\", \"image\":\"" <> T.unpack img <> "\", \"text\": \"\"}}"
|
||||
cm3 = "{\"filePath\": \"test.pdf\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}"
|
||||
cm4 = "{\"filePath\": \"test_1MB.pdf\", \"msgContent\": {\"type\": \"file\", \"text\": \"message with large file\"}}"
|
||||
alice ##> ("/_send @2 json [" <> cm1 <> "," <> cm2 <> "," <> cm3 <> "," <> cm4 <> "]")
|
||||
cm2 = "{\"filePath\": \"test.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"sending file 1\"}}"
|
||||
cm3 = "{\"filePath\": \"test.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"sending file 2\"}}"
|
||||
alice ##> ("/_send @2 json [" <> cm1 <> "," <> cm2 <> "," <> cm3 <> "]")
|
||||
|
||||
alice <# "@bob message without file"
|
||||
|
||||
alice <# "@bob sending file 1"
|
||||
alice <# "/f @bob test.jpg"
|
||||
alice <## "use /fc 1 to cancel sending"
|
||||
|
||||
alice <# "@bob sending file 2"
|
||||
alice <# "/f @bob test.pdf"
|
||||
alice <## "use /fc 2 to cancel sending"
|
||||
|
||||
alice <# "@bob message with large file"
|
||||
alice <# "/f @bob test_1MB.pdf"
|
||||
alice <## "use /fc 3 to cancel sending"
|
||||
|
||||
bob <# "alice> message without file"
|
||||
|
||||
bob <# "alice> sending file 1"
|
||||
bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)"
|
||||
bob <## "use /fr 1 [<dir>/ | <path>] to receive it"
|
||||
|
||||
bob <# "alice> sending file 2"
|
||||
bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)"
|
||||
bob <## "use /fr 2 [<dir>/ | <path>] to receive it"
|
||||
|
||||
bob <# "alice> message with large file"
|
||||
bob <# "alice> sends file test_1MB.pdf (1017.7 KiB / 1042157 bytes)"
|
||||
bob <## "use /fr 3 [<dir>/ | <path>] to receive it"
|
||||
|
||||
alice <## "completed uploading file 1 (test.jpg) for bob"
|
||||
alice <## "completed uploading file 2 (test.pdf) for bob"
|
||||
alice <## "completed uploading file 3 (test_1MB.pdf) for bob"
|
||||
|
||||
-- IDs to forward
|
||||
let msgId1 = (read msgIdZero :: Int) + 1
|
||||
msgIds = intercalate "," $ map show [msgId1, msgId1 + 1, msgId1 + 2, msgId1 + 3, msgId1 + 4]
|
||||
bob ##> ("/_forward plan @2 " <> msgIds)
|
||||
bob <## "Files can be received: 1, 2, 3"
|
||||
bob <## "4 message(s) out of 5 can be forwarded"
|
||||
|
||||
bob ##> "/fr 1"
|
||||
bob
|
||||
@@ -743,10 +720,6 @@ testMultiForwardFiles =
|
||||
]
|
||||
bob <## "completed receiving file 1 (test.jpg) from alice"
|
||||
|
||||
bob ##> ("/_forward plan @2 " <> msgIds)
|
||||
bob <## "Files can be received: 2, 3"
|
||||
bob <## "4 message(s) out of 5 can be forwarded"
|
||||
|
||||
bob ##> "/fr 2"
|
||||
bob
|
||||
<### [ "saving file 2 from alice to test.pdf",
|
||||
@@ -763,10 +736,8 @@ testMultiForwardFiles =
|
||||
dest2 `shouldBe` src2
|
||||
|
||||
-- forward file
|
||||
bob ##> ("/_forward plan @2 " <> msgIds)
|
||||
bob <## "Files can be received: 3"
|
||||
bob <## "all messages can be forwarded"
|
||||
bob ##> ("/_forward @3 @2 " <> msgIds)
|
||||
let msgId1 = (read msgIdZero :: Int) + 1
|
||||
bob ##> ("/_forward @3 @2 " <> show msgId1 <> "," <> show (msgId1 + 1) <> "," <> show (msgId1 + 2) <> "," <> show (msgId1 + 3))
|
||||
|
||||
-- messages printed for bob
|
||||
bob <# "@cath <- you @alice"
|
||||
@@ -776,17 +747,14 @@ testMultiForwardFiles =
|
||||
bob <## " message without file"
|
||||
|
||||
bob <# "@cath <- @alice"
|
||||
bob <## " test_1.jpg"
|
||||
bob <## " sending file 1"
|
||||
bob <# "/f @cath test_1.jpg"
|
||||
bob <## "use /fc 4 to cancel sending"
|
||||
bob <## "use /fc 3 to cancel sending"
|
||||
|
||||
bob <# "@cath <- @alice"
|
||||
bob <## " test_1.pdf"
|
||||
bob <## " sending file 2"
|
||||
bob <# "/f @cath test_1.pdf"
|
||||
bob <## "use /fc 5 to cancel sending"
|
||||
|
||||
bob <# "@cath <- @alice"
|
||||
bob <## " message with large file"
|
||||
bob <## "use /fc 4 to cancel sending"
|
||||
|
||||
-- messages printed for cath
|
||||
cath <# "bob> -> forwarded"
|
||||
@@ -796,21 +764,18 @@ testMultiForwardFiles =
|
||||
cath <## " message without file"
|
||||
|
||||
cath <# "bob> -> forwarded"
|
||||
cath <## " test_1.jpg"
|
||||
cath <## " sending file 1"
|
||||
cath <# "bob> sends file test_1.jpg (136.5 KiB / 139737 bytes)"
|
||||
cath <## "use /fr 1 [<dir>/ | <path>] to receive it"
|
||||
|
||||
cath <# "bob> -> forwarded"
|
||||
cath <## " test_1.pdf"
|
||||
cath <## " sending file 2"
|
||||
cath <# "bob> sends file test_1.pdf (266.0 KiB / 272376 bytes)"
|
||||
cath <## "use /fr 2 [<dir>/ | <path>] to receive it"
|
||||
|
||||
cath <# "bob> -> forwarded"
|
||||
cath <## " message with large file"
|
||||
|
||||
-- file transfer
|
||||
bob <## "completed uploading file 4 (test_1.jpg) for cath"
|
||||
bob <## "completed uploading file 5 (test_1.pdf) for cath"
|
||||
bob <## "completed uploading file 3 (test_1.jpg) for cath"
|
||||
bob <## "completed uploading file 4 (test_1.pdf) for cath"
|
||||
|
||||
cath ##> "/fr 1"
|
||||
cath
|
||||
@@ -836,26 +801,6 @@ testMultiForwardFiles =
|
||||
dest2C <- B.readFile "./tests/tmp/cath_app_files/test_1.pdf"
|
||||
dest2C `shouldBe` src2B
|
||||
|
||||
bob ##> "/fr 3"
|
||||
bob
|
||||
<### [ "saving file 3 from alice to test_1MB.pdf",
|
||||
"started receiving file 3 (test_1MB.pdf) from alice"
|
||||
]
|
||||
bob <## "completed receiving file 3 (test_1MB.pdf) from alice"
|
||||
|
||||
bob ##> ("/_forward plan @2 " <> msgIds)
|
||||
bob <## "all messages can be forwarded"
|
||||
|
||||
removeFile "./tests/tmp/bob_app_files/test_1MB.pdf"
|
||||
bob ##> ("/_forward plan @2 " <> msgIds)
|
||||
bob <## "1 file(s) are missing"
|
||||
bob <## "all messages can be forwarded"
|
||||
|
||||
removeFile "./tests/tmp/bob_app_files/test.pdf"
|
||||
bob ##> ("/_forward plan @2 " <> msgIds)
|
||||
bob <## "2 file(s) are missing"
|
||||
bob <## "4 message(s) out of 5 can be forwarded"
|
||||
|
||||
-- deleting original file doesn't delete forwarded file
|
||||
checkActionDeletesFile "./tests/tmp/bob_app_files/test.jpg" $ do
|
||||
bob ##> "/clear alice"
|
||||
|
||||
@@ -16,7 +16,6 @@ import Data.List (intercalate, isInfixOf)
|
||||
import qualified Data.Text as T
|
||||
import Database.SQLite.Simple (Only (..))
|
||||
import Simplex.Chat.Controller (ChatConfig (..))
|
||||
import Simplex.Chat.Messages (ChatItemId)
|
||||
import Simplex.Chat.Options
|
||||
import Simplex.Chat.Protocol (supportedChatVRange)
|
||||
import Simplex.Chat.Store (agentStoreFile, chatStoreFile)
|
||||
@@ -35,7 +34,6 @@ chatGroupTests :: SpecWith FilePath
|
||||
chatGroupTests = do
|
||||
describe "chat groups" $ do
|
||||
describe "add contacts, create group and send/receive messages" testGroupMatrix
|
||||
it "mark multiple messages as read" testMarkReadGroup
|
||||
it "v1: add contacts, create group and send/receive messages" testGroup
|
||||
it "v1: add contacts, create group and send/receive messages, check messages" testGroupCheckMessages
|
||||
it "send large message" testGroupLargeMessage
|
||||
@@ -357,22 +355,6 @@ testGroupShared alice bob cath checkMessages directConnections = do
|
||||
alice #$> ("/_unread chat #1 on", id, "ok")
|
||||
alice #$> ("/_unread chat #1 off", id, "ok")
|
||||
|
||||
testMarkReadGroup :: HasCallStack => FilePath -> IO ()
|
||||
testMarkReadGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do
|
||||
createGroup2 "team" alice bob
|
||||
alice #> "#team 1"
|
||||
alice #> "#team 2"
|
||||
alice #> "#team 3"
|
||||
alice #> "#team 4"
|
||||
bob <# "#team alice> 1"
|
||||
bob <# "#team alice> 2"
|
||||
bob <# "#team alice> 3"
|
||||
bob <# "#team alice> 4"
|
||||
bob ##> "/last_item_id"
|
||||
i :: ChatItemId <- read <$> getTermLine bob
|
||||
let itemIds = intercalate "," $ map show [i - 3 .. i]
|
||||
bob #$> ("/_read chat items #1 " <> itemIds, id, "ok")
|
||||
|
||||
testGroupLargeMessage :: HasCallStack => FilePath -> IO ()
|
||||
testGroupLargeMessage =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
|
||||
Reference in New Issue
Block a user