mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 11:05:04 +03:00
macOS desktop notifications
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -2,132 +2,24 @@ package ru.fromchat
|
||||
|
||||
import android.app.Application
|
||||
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||
import com.pr0gramm3r101.utils.settings.settings
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.WebSocketManager
|
||||
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
|
||||
import ru.fromchat.notifications.NotificationHelper
|
||||
|
||||
class App: Application() {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun fetchAndNotify(
|
||||
includeDmMessages: Boolean = false,
|
||||
dmMessageId: Int? = null
|
||||
) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
NotificationHelper.fetchAndNotify(
|
||||
applicationContext,
|
||||
includeDmMessages = includeDmMessages,
|
||||
dmMessageId = dmMessageId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
import ru.fromchat.notifications.MessageNotificationCoordinator
|
||||
|
||||
class App : Application() {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
UtilsLibrary.init(this)
|
||||
ru.fromchat.notifications.ChatNotificationDismissals.install(this)
|
||||
|
||||
WebSocketManager.addGlobalMessageHandler { msg ->
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val currentUserId = settings.getInt("current_user_id", -1)
|
||||
|
||||
fun isOwnPublicMessage(data: JsonObject?) =
|
||||
data?.get("user_id")?.jsonPrimitive?.content?.toIntOrNull() == currentUserId
|
||||
|
||||
fun isOwnDmMessage(data: JsonObject?) =
|
||||
data?.get("senderId")?.jsonPrimitive?.content?.toIntOrNull() == currentUserId
|
||||
|
||||
when (msg.type) {
|
||||
"newMessage" -> {
|
||||
if (!isOwnPublicMessage(msg.data?.jsonObject)) {
|
||||
fetchAndNotify()
|
||||
}
|
||||
}
|
||||
|
||||
"dmNew" -> {
|
||||
if (!isOwnDmMessage(msg.data?.jsonObject)) {
|
||||
fetchAndNotify(
|
||||
includeDmMessages = true,
|
||||
dmMessageId = msg
|
||||
.data
|
||||
?.jsonObject
|
||||
?.get("id")
|
||||
?.jsonPrimitive
|
||||
?.content
|
||||
?.toIntOrNull()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"updates" -> {
|
||||
msg.data?.jsonObject?.get("updates")?.jsonArray?.let { updates ->
|
||||
var shouldFetchPublic = false
|
||||
var shouldFetchDm = false
|
||||
var latestDmMessageId: Int? = null
|
||||
|
||||
for (item in updates) {
|
||||
val (type, data) = item.jsonObject.let {
|
||||
Pair(
|
||||
it["type"]?.jsonPrimitive?.content,
|
||||
it["data"]?.jsonObject
|
||||
)
|
||||
}
|
||||
|
||||
when (type) {
|
||||
"newMessage" -> {
|
||||
if (!isOwnPublicMessage(data)) {
|
||||
shouldFetchPublic = true
|
||||
}
|
||||
}
|
||||
|
||||
"dmNew" -> {
|
||||
if (!isOwnDmMessage(data)) {
|
||||
shouldFetchDm = true
|
||||
|
||||
data
|
||||
?.get("id")
|
||||
?.jsonPrimitive
|
||||
?.content
|
||||
?.toIntOrNull()
|
||||
?.let {
|
||||
latestDmMessageId = it.coerceAtLeast(
|
||||
latestDmMessageId ?: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldFetchPublic || shouldFetchDm) {
|
||||
fetchAndNotify(
|
||||
includeDmMessages = shouldFetchDm,
|
||||
dmMessageId = latestDmMessageId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MessageNotificationCoordinator.install()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
runCatching { ApiClient.loadPersistedData() }
|
||||
AttachmentTransferBootstrap.launchOnApplicationStart()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,18 +15,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.schema.messages.MessagesResponse
|
||||
import ru.fromchat.config.ServerConfig
|
||||
import ru.fromchat.notifications.NotificationLaunchCoordinator
|
||||
import ru.fromchat.notifications.NotificationLaunchTarget
|
||||
import ru.fromchat.ui.App
|
||||
@@ -34,7 +23,6 @@ import ru.fromchat.ui.chat.panels.publicchat.isPublicChatVisible
|
||||
|
||||
private const val EXTRA_NOTIFICATION_CHAT_TYPE = "notification_chat_type"
|
||||
private const val EXTRA_OPEN_DM_USER_ID = "open_dm_user_id"
|
||||
private const val EXTRA_MARK_MESSAGE_READ = "mark_message_read"
|
||||
private const val EXTRA_MESSAGE_ID = "scroll_to_message_id"
|
||||
private const val CHAT_TYPE_PUBLIC = "public"
|
||||
private const val CHAT_TYPE_DM = "dm"
|
||||
@@ -81,10 +69,6 @@ class MainActivity : ComponentActivity() {
|
||||
"handleIntent parsedProfileTarget: userId=${profileTarget?.userId}, username=${profileTarget?.username}, parseError=${profileTarget?.parseError}"
|
||||
)
|
||||
|
||||
if (intent?.getBooleanExtra(EXTRA_MARK_MESSAGE_READ, false) == true) {
|
||||
markMessagesAsRead()
|
||||
}
|
||||
|
||||
val baseState = ProfileDeepLinkResolution(
|
||||
scrollToMessageId = if (messageId != -1) messageId else null,
|
||||
startAtPublicChat = messageId != -1 && chatType != CHAT_TYPE_DM,
|
||||
@@ -183,30 +167,6 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun markMessagesAsRead() {
|
||||
GlobalScope.launch {
|
||||
try {
|
||||
// Get all unread messages and mark them as read
|
||||
val messageIds = ApiClient.http
|
||||
.get("${ServerConfig.apiBaseUrl}/messages/new")
|
||||
.body<MessagesResponse>()
|
||||
.messages
|
||||
.map { it.id }
|
||||
|
||||
if (messageIds.isNotEmpty()) {
|
||||
ApiClient.http.post("${ServerConfig.apiBaseUrl}/messages/read") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(mapOf("messageIds" to messageIds))
|
||||
}
|
||||
Logger.i("MainActivity", "Marked ${messageIds.size} messages as read")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("MainActivity", "Failed to mark messages as read", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkGooglePlayServices(): Boolean {
|
||||
with (GoogleApiAvailability.getInstance()) {
|
||||
val resultCode = isGooglePlayServicesAvailable(this@MainActivity)
|
||||
|
||||
@@ -10,7 +10,7 @@ import kotlinx.coroutines.launch
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.uploadPendingFcmTokenIfAvailable
|
||||
import ru.fromchat.notifications.NotificationHelper
|
||||
import ru.fromchat.notifications.MessageNotificationCoordinator
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
||||
@@ -27,20 +27,11 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
||||
val fallbackMessageId = pushData["message_id"]?.toIntOrNull()
|
||||
?: pushData["dm_id"]?.toIntOrNull()
|
||||
val senderId = pushData["sender_id"]?.toIntOrNull()
|
||||
val sender = pushData["sender_display_name"]
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: pushData["sender_username"]
|
||||
?: pushData["senderUsername"]
|
||||
?: pushData["senderDisplayName"]
|
||||
val messageType = pushData["type"] ?: "public_message"
|
||||
val isDirectMessage = messageType.equals("dm", ignoreCase = true)
|
||||
if (ApiClient.token.isNullOrBlank()) {
|
||||
Logger.w("FromChatFCM", "No auth token in memory; loading persisted data before handling push")
|
||||
ApiClient.loadPersistedData()
|
||||
Logger.d(
|
||||
"FromChatFCM",
|
||||
"Token loaded from storage for push sync: hasToken=${ApiClient.token?.isNotBlank() ?: false}",
|
||||
)
|
||||
}
|
||||
val currentUserId = settings.getInt("current_user_id", -1)
|
||||
if (senderId != null && senderId == currentUserId) {
|
||||
@@ -48,16 +39,12 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
||||
return@launch
|
||||
}
|
||||
if (isDirectMessage) {
|
||||
NotificationHelper.fetchAndNotify(
|
||||
applicationContext,
|
||||
MessageNotificationCoordinator.fetchAndNotify(
|
||||
includeDmMessages = true,
|
||||
dmMessageId = fallbackMessageId,
|
||||
dmSenderName = sender,
|
||||
)
|
||||
} else {
|
||||
// Public: one debounced /messages/new → MessagingStyle. Never post a
|
||||
// per-message fallback (that duplicated FCM tray entries with different labels).
|
||||
NotificationHelper.schedulePublicFetchAndNotify(applicationContext)
|
||||
MessageNotificationCoordinator.schedulePublicFetchAndNotify()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
|
||||
|
||||
@@ -1,657 +0,0 @@
|
||||
package ru.fromchat.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.Person
|
||||
import androidx.core.app.RemoteInput
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.pr0gramm3r101.utils.settings.settings
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
import io.ktor.client.request.get
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import ru.fromchat.MainActivity
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.R
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.api.local.db.store.ProfileCache
|
||||
import ru.fromchat.api.local.db.store.PublicChatProfileCache
|
||||
import ru.fromchat.api.local.db.store.visibleDisplayName
|
||||
import ru.fromchat.api.local.messages.ChatListPreviewStrings
|
||||
import ru.fromchat.api.local.messages.buildChatListPreview
|
||||
import ru.fromchat.api.local.messages.buildChatListPreviewFromEnvelope
|
||||
import ru.fromchat.api.schema.messages.Message
|
||||
import ru.fromchat.api.schema.messages.MessagesResponse
|
||||
import ru.fromchat.api.schema.messages.dm.DmHistoryResponse
|
||||
import ru.fromchat.config.ServerConfig
|
||||
import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder
|
||||
import ru.fromchat.api.crypto.DmCiphertextCorruptedException
|
||||
import ru.fromchat.api.crypto.decryptEnvelope
|
||||
import ru.fromchat.ui.chat.panels.publicchat.isPublicChatVisible
|
||||
import kotlin.time.Instant
|
||||
|
||||
object NotificationHelper {
|
||||
private const val EXTRA_NOTIFICATION_CHAT_TYPE = "notification_chat_type"
|
||||
private const val EXTRA_OPEN_DM_USER_ID = "open_dm_user_id"
|
||||
private const val EXTRA_REPLY_CHAT_TYPE = "notification_reply_chat_type"
|
||||
private const val EXTRA_REPLY_DM_USER_ID = "notification_reply_dm_user_id"
|
||||
private const val EXTRA_REPLY_PARENT_MESSAGE_ID = "notification_reply_parent_message_id"
|
||||
private const val EXTRA_MESSAGE_ID = "scroll_to_message_id"
|
||||
private const val EXTRA_MARK_MESSAGE_READ = "mark_message_read"
|
||||
private const val CHAT_TYPE_PUBLIC = "public"
|
||||
private const val CHAT_TYPE_DM = "dm"
|
||||
private const val CHANNEL_ID = "fromchat_messages"
|
||||
private const val GROUP_PUBLIC = "ru.fromchat.notifications.public"
|
||||
private const val GROUP_DM_PREFIX = "ru.fromchat.notifications.dm."
|
||||
private const val SUMMARY_NOTIFICATION_ID = 1000000
|
||||
private const val PREF_SHOWN_KEY = "shown_message_ids"
|
||||
private const val PREF_SHOWN_DM_KEY = "shown_dm_message_ids"
|
||||
private const val PREF_LAST_DM_MESSAGE_ID = "last_dm_message_id"
|
||||
private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time"
|
||||
private const val PUBLIC_FETCH_DEBOUNCE_MS = 450L
|
||||
const val KEY_TEXT_REPLY = "key_text_reply"
|
||||
|
||||
private val helperScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val publicFetchMutex = Mutex()
|
||||
private var publicFetchJob: Job? = null
|
||||
|
||||
private fun listPreviewStrings(context: Context): ChatListPreviewStrings {
|
||||
val emoji = context.getString(R.string.chat_preview_image_emoji)
|
||||
return ChatListPreviewStrings(
|
||||
imageEmoji = emoji,
|
||||
imageOnly = context.getString(R.string.chat_preview_image, emoji),
|
||||
attachmentOnly = context.getString(R.string.chat_preview_attachment),
|
||||
)
|
||||
}
|
||||
|
||||
private fun notificationBodyForMessage(message: Message, strings: ChatListPreviewStrings): String =
|
||||
buildChatListPreview(message, strings)?.takeIf { it.isNotBlank() } ?: message.content
|
||||
|
||||
private fun publicConversationTitle(context: Context): String =
|
||||
PublicChatProfileCache.profile?.title?.takeIf { it.isNotBlank() }
|
||||
?: runCatching {
|
||||
PublicChatProfileCache.hydrateFromDiskImmediate(
|
||||
CacheContext.activeInstanceId.value.trim()
|
||||
)?.title?.takeIf { it.isNotBlank() }
|
||||
}.getOrNull()
|
||||
?: context.getString(R.string.public_chat)
|
||||
|
||||
private fun senderDisplayLabel(message: Message, currentUserId: Int): String {
|
||||
ProfileCache.get(message.user_id)
|
||||
?.visibleDisplayName(currentUserId)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { return it }
|
||||
message.displayName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
|
||||
return message.username.trim().ifBlank { "FromChat" }
|
||||
}
|
||||
|
||||
fun summaryNotificationId(): Int = SUMMARY_NOTIFICATION_ID
|
||||
|
||||
private fun createMessageIntent(
|
||||
context: Context,
|
||||
messageId: Int,
|
||||
targetDmUserId: Int? = null,
|
||||
markMessageRead: Boolean = true
|
||||
) = PendingIntent.getActivity(
|
||||
context,
|
||||
if (targetDmUserId != null) -messageId else messageId,
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(EXTRA_MESSAGE_ID, messageId)
|
||||
putExtra(
|
||||
EXTRA_NOTIFICATION_CHAT_TYPE,
|
||||
if (targetDmUserId != null) CHAT_TYPE_DM else CHAT_TYPE_PUBLIC
|
||||
)
|
||||
putExtra(EXTRA_OPEN_DM_USER_ID, targetDmUserId ?: -1)
|
||||
putExtra(EXTRA_MARK_MESSAGE_READ, markMessageRead)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
private fun notificationReplyAction(context: Context) =
|
||||
"${context.packageName}.NOTIFICATION_REPLY"
|
||||
|
||||
private fun createReplyIntent(
|
||||
context: Context,
|
||||
isDirectMessage: Boolean = false,
|
||||
targetDmUserId: Int? = null,
|
||||
parentMessageId: Int? = null
|
||||
) = PendingIntent.getBroadcast(
|
||||
context,
|
||||
if (isDirectMessage && targetDmUserId != null) {
|
||||
-targetDmUserId
|
||||
} else {
|
||||
parentMessageId ?: SUMMARY_NOTIFICATION_ID
|
||||
},
|
||||
Intent(context, NotificationReplyReceiver::class.java).apply {
|
||||
action = notificationReplyAction(context)
|
||||
putExtra("notification_id", SUMMARY_NOTIFICATION_ID)
|
||||
putExtra(EXTRA_REPLY_CHAT_TYPE, if (isDirectMessage) CHAT_TYPE_DM else CHAT_TYPE_PUBLIC)
|
||||
putExtra(EXTRA_REPLY_DM_USER_ID, targetDmUserId ?: -1)
|
||||
if (parentMessageId != null) {
|
||||
putExtra(EXTRA_REPLY_PARENT_MESSAGE_ID, parentMessageId)
|
||||
}
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||
)
|
||||
|
||||
fun createChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
(context
|
||||
.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
).createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Messages",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = "FromChat message notifications"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Coalesce rapid public FCM wakes into one /messages/new → MessagingStyle refresh. */
|
||||
fun schedulePublicFetchAndNotify(context: Context) {
|
||||
publicFetchJob?.cancel()
|
||||
publicFetchJob = helperScope.launch {
|
||||
delay(PUBLIC_FETCH_DEBOUNCE_MS)
|
||||
publicFetchMutex.withLock {
|
||||
fetchAndNotify(context.applicationContext, includeDmMessages = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchAndNotify(
|
||||
context: Context,
|
||||
includeDmMessages: Boolean = false,
|
||||
dmMessageId: Int? = null,
|
||||
dmSenderName: String? = null
|
||||
) {
|
||||
Logger.i("NotificationHelper", "fetchAndNotify: starting fetch")
|
||||
|
||||
try {
|
||||
val currentUserId = settings.getInt("current_user_id", -1)
|
||||
Logger.d(
|
||||
"NotificationHelper",
|
||||
"fetchAndNotify: currentUserId=$currentUserId hasToken=${ApiClient.token?.isNotBlank() ?: false}"
|
||||
)
|
||||
if (currentUserId == -1) {
|
||||
Logger.w("NotificationHelper", "fetchAndNotify: missing currentUserId, skipping push sync")
|
||||
return
|
||||
}
|
||||
|
||||
val messages = ApiClient.http
|
||||
.get("${ServerConfig.apiBaseUrl}/messages/new")
|
||||
.body<MessagesResponse>()
|
||||
.messages
|
||||
.filter { it.user_id != currentUserId }
|
||||
Logger.i("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)")
|
||||
if (messages.isNotEmpty()) {
|
||||
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
|
||||
displayNotifications(context, messages)
|
||||
} else {
|
||||
Logger.d("NotificationHelper", "fetchAndNotify: no public messages returned")
|
||||
}
|
||||
|
||||
if (includeDmMessages) {
|
||||
fetchAndNotifyDirectMessages(context, currentUserId, dmMessageId, dmSenderName)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is ClientRequestException && e.response.status.value == 401) {
|
||||
try {
|
||||
Logger.w("NotificationHelper", "fetchAndNotify: received 401; reloading token and retrying")
|
||||
ApiClient.loadPersistedData()
|
||||
val retryMessages = ApiClient.http
|
||||
.get("${ServerConfig.apiBaseUrl}/messages/new")
|
||||
.body<MessagesResponse>()
|
||||
.messages
|
||||
.filter { it.user_id != settings.getInt("current_user_id", -1) }
|
||||
Logger.i(
|
||||
"NotificationHelper",
|
||||
"fetchAndNotify retry: fetched ${retryMessages.size} public messages"
|
||||
)
|
||||
if (retryMessages.isNotEmpty()) {
|
||||
displayNotifications(context, retryMessages)
|
||||
}
|
||||
if (includeDmMessages) {
|
||||
fetchAndNotifyDirectMessages(
|
||||
context,
|
||||
settings.getInt("current_user_id", -1),
|
||||
dmMessageId,
|
||||
dmSenderName
|
||||
)
|
||||
}
|
||||
return
|
||||
} catch (_: Exception) {
|
||||
Logger.e("NotificationHelper", "fetchAndNotify retry failed", e)
|
||||
}
|
||||
}
|
||||
Logger.e("NotificationHelper", "fetchAndNotify: error ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchAndNotifyDirectMessages(
|
||||
context: Context,
|
||||
currentUserId: Int,
|
||||
dmMessageId: Int? = null,
|
||||
dmSenderName: String? = null
|
||||
) {
|
||||
val storedLastDmMessageId = settings.getInt(PREF_LAST_DM_MESSAGE_ID, 0)
|
||||
val sinceId = when {
|
||||
dmMessageId != null && dmMessageId > storedLastDmMessageId -> dmMessageId - 1
|
||||
storedLastDmMessageId > 0 -> storedLastDmMessageId
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (sinceId == null || sinceId < 0) {
|
||||
Logger.d("NotificationHelper", "fetchAndNotifyDirectMessages: no dm watermark yet, skipping broad dm sync")
|
||||
return
|
||||
}
|
||||
|
||||
val response = runCatching {
|
||||
ApiClient.getDmFetch(sinceId)
|
||||
}.getOrElse { throwable ->
|
||||
if (throwable is ClientRequestException && throwable.response.status.value == 401) {
|
||||
throw throwable
|
||||
}
|
||||
Logger.e(
|
||||
"NotificationHelper",
|
||||
"fetchAndNotifyDirectMessages: failed to fetch dm messages for since=$sinceId: ${throwable.message}",
|
||||
throwable
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
processDirectMessages(context, response, currentUserId, dmMessageId, dmSenderName)
|
||||
}
|
||||
|
||||
private suspend fun processDirectMessages(
|
||||
context: Context,
|
||||
response: DmHistoryResponse,
|
||||
currentUserId: Int,
|
||||
dmMessageId: Int?,
|
||||
dmSenderName: String?
|
||||
) {
|
||||
val dmMessages = response.messages
|
||||
Logger.i("NotificationHelper", "fetchAndNotifyDirectMessages: fetched ${dmMessages.size} dm messages")
|
||||
if (dmMessages.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val shownDm = settings.getStringSet(PREF_SHOWN_DM_KEY, emptySet()).toMutableSet()
|
||||
val latestMessageId = settings.getInt(PREF_LAST_DM_MESSAGE_ID, 0)
|
||||
|
||||
val previewStrings = listPreviewStrings(context)
|
||||
|
||||
dmMessages
|
||||
.filter { envelope ->
|
||||
envelope.id > 0 && envelope.senderId != currentUserId
|
||||
}
|
||||
.forEach { envelope ->
|
||||
val envelopeId = envelope.id
|
||||
val shownDmKey = "dm:$envelopeId"
|
||||
|
||||
if (shownDm.contains(shownDmKey) || envelopeId <= latestMessageId) {
|
||||
Logger.d(
|
||||
"NotificationHelper",
|
||||
"Direct notification skipped: already shown envelopeId=$envelopeId"
|
||||
)
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val plaintext = runCatching {
|
||||
decryptEnvelope(envelope, currentUserId)
|
||||
}.getOrElse { throwable ->
|
||||
when (throwable) {
|
||||
is DmCiphertextCorruptedException -> {
|
||||
Logger.w(
|
||||
"NotificationHelper",
|
||||
"DM decrypt failed for envelopeId=$envelopeId"
|
||||
)
|
||||
CorruptedDmMessagePlaceholder
|
||||
}
|
||||
|
||||
else -> {
|
||||
Logger.w(
|
||||
"NotificationHelper",
|
||||
"DM decrypt failed for envelopeId=$envelopeId: ${throwable.message}",
|
||||
throwable
|
||||
)
|
||||
"Encrypted message"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val senderName = when {
|
||||
envelopeId == dmMessageId && !dmSenderName.isNullOrBlank() -> dmSenderName
|
||||
!envelope.senderDisplayName.isNullOrBlank() -> envelope.senderDisplayName
|
||||
else -> ProfileCache.get(envelope.senderId)
|
||||
?.visibleDisplayName(currentUserId)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: envelope.senderUsername
|
||||
}.orEmpty()
|
||||
val dmConversationUserId = envelope.senderId
|
||||
val notificationBody = buildChatListPreviewFromEnvelope(
|
||||
envelope = envelope,
|
||||
decryptedPlaintext = plaintext,
|
||||
strings = previewStrings,
|
||||
)?.takeIf { it.isNotBlank() } ?: plaintext
|
||||
|
||||
showFallbackPushNotification(
|
||||
context = context,
|
||||
title = if (senderName.isNotBlank()) {
|
||||
context.getString(R.string.notification_direct_message_from, senderName)
|
||||
} else {
|
||||
context.getString(R.string.notification_direct_message)
|
||||
},
|
||||
body = notificationBody,
|
||||
sender = senderName,
|
||||
messageId = envelopeId,
|
||||
allowWhenPublicChatVisible = true,
|
||||
isDirectMessage = true,
|
||||
targetDmUserId = dmConversationUserId,
|
||||
conversationTitle = context.getString(R.string.notification_direct_messages_title)
|
||||
)
|
||||
shownDm.add(shownDmKey)
|
||||
}
|
||||
|
||||
val newMaxDmId = dmMessages.maxOfOrNull { it.id } ?: 0
|
||||
if (newMaxDmId > latestMessageId) {
|
||||
settings.putInt(PREF_LAST_DM_MESSAGE_ID, newMaxDmId)
|
||||
}
|
||||
settings.putStringSet(PREF_SHOWN_DM_KEY, shownDm)
|
||||
}
|
||||
|
||||
fun showFallbackPushNotification(
|
||||
context: Context,
|
||||
title: String,
|
||||
body: String,
|
||||
sender: String? = null,
|
||||
messageId: Int? = null,
|
||||
allowWhenPublicChatVisible: Boolean = false,
|
||||
isDirectMessage: Boolean = false,
|
||||
targetDmUserId: Int? = null,
|
||||
conversationTitle: String = context.getString(R.string.public_chat),
|
||||
senderId: Int? = null,
|
||||
) {
|
||||
helperScope.launch(Dispatchers.Main) {
|
||||
createChannel(context)
|
||||
|
||||
val currentUserId = settings.getInt("current_user_id", -1)
|
||||
if (!isDirectMessage && senderId != null && senderId == currentUserId) {
|
||||
Logger.d("NotificationHelper", "Fallback push skipped: own public message senderId=$senderId")
|
||||
return@launch
|
||||
}
|
||||
if (isDirectMessage && targetDmUserId != null && targetDmUserId == currentUserId) {
|
||||
Logger.d("NotificationHelper", "Fallback push skipped: own DM targetDmUserId=$targetDmUserId")
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (isPublicChatVisible && !allowWhenPublicChatVisible) {
|
||||
Logger.d("NotificationHelper", "Fallback push notification skipped: public chat is visible")
|
||||
return@launch
|
||||
}
|
||||
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
if (
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
Logger.w(
|
||||
"NotificationHelper",
|
||||
"Fallback push notification skipped: POST_NOTIFICATIONS permission missing"
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
|
||||
val shownKey = if (isDirectMessage) "dm:${messageId}" else messageId?.toString()
|
||||
if (messageId != null && shown.contains(shownKey)) {
|
||||
Logger.d(
|
||||
"NotificationHelper",
|
||||
"Fallback push notification skipped: already shown messageId=$messageId"
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
if (messageId != null) {
|
||||
shownKey?.let { shown.add(it) }
|
||||
}
|
||||
|
||||
val senderName = sender?.ifBlank { "FromChat" } ?: "FromChat"
|
||||
val groupKey = if (isDirectMessage && targetDmUserId != null) {
|
||||
GROUP_DM_PREFIX + targetDmUserId
|
||||
} else {
|
||||
GROUP_PUBLIC
|
||||
}
|
||||
val notificationId = if (isDirectMessage && targetDmUserId != null) {
|
||||
SUMMARY_NOTIFICATION_ID + targetDmUserId
|
||||
} else {
|
||||
SUMMARY_NOTIFICATION_ID
|
||||
}
|
||||
cancelStaleSystemTrayDuplicates(context)
|
||||
notify(
|
||||
notificationId,
|
||||
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(NotificationSmallIcon.resId(context))
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setGroup(groupKey)
|
||||
.setStyle(
|
||||
NotificationCompat.MessagingStyle(
|
||||
Person.Builder().setName("FromChat").build()
|
||||
)
|
||||
.setConversationTitle(conversationTitle)
|
||||
.setGroupConversation(true)
|
||||
.addMessage(
|
||||
NotificationCompat.MessagingStyle.Message(
|
||||
body,
|
||||
System.currentTimeMillis(),
|
||||
Person.Builder().setName(senderName).build()
|
||||
)
|
||||
)
|
||||
)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||
.setAutoCancel(true)
|
||||
.addAction(
|
||||
NotificationCompat.Action.Builder(
|
||||
android.R.drawable.ic_menu_send,
|
||||
context.getString(R.string.notification_reply),
|
||||
createReplyIntent(
|
||||
context = context,
|
||||
isDirectMessage = isDirectMessage,
|
||||
targetDmUserId = targetDmUserId,
|
||||
parentMessageId = messageId
|
||||
)
|
||||
)
|
||||
.addRemoteInput(
|
||||
RemoteInput.Builder(KEY_TEXT_REPLY)
|
||||
.setLabel(context.getString(R.string.notification_reply_hint))
|
||||
.build()
|
||||
)
|
||||
.setAllowGeneratedReplies(true)
|
||||
.build()
|
||||
)
|
||||
.setContentIntent(
|
||||
createMessageIntent(
|
||||
context = context,
|
||||
messageId = messageId ?: 0,
|
||||
targetDmUserId = targetDmUserId,
|
||||
markMessageRead = !isDirectMessage
|
||||
)
|
||||
)
|
||||
.build()
|
||||
)
|
||||
settings.putStringSet(PREF_SHOWN_KEY, shown)
|
||||
Logger.i(
|
||||
"NotificationHelper",
|
||||
"Fallback push notification shown messageId=$messageId"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayNotifications(context: Context, messages: List<Message>) {
|
||||
Logger.i("NotificationHelper", "displayNotifications: ${messages.size} messages")
|
||||
|
||||
if (isPublicChatVisible) {
|
||||
Logger.d("NotificationHelper", "Skipping notifications: user is viewing public chat")
|
||||
return
|
||||
}
|
||||
|
||||
helperScope.launch(Dispatchers.Main.immediate) {
|
||||
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
|
||||
var newMessageCount = 0
|
||||
val previewStrings = listPreviewStrings(context)
|
||||
val conversationTitle = publicConversationTitle(context)
|
||||
val avatar = PublicChatNotificationAvatar.create(conversationTitle)
|
||||
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
if (
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
Logger.w(
|
||||
"NotificationHelper",
|
||||
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val currentUserId = settings.getInt("current_user_id", -1)
|
||||
if (currentUserId == -1) return@launch
|
||||
|
||||
val newMessages = messages
|
||||
.filter { msg ->
|
||||
!shown.contains(msg.id.toString()) && msg.user_id != currentUserId
|
||||
}
|
||||
.sortedBy { it.id }
|
||||
if (newMessages.isEmpty()) {
|
||||
Logger.d(
|
||||
"NotificationHelper",
|
||||
"displayNotifications: no new messages after filters for user=$currentUserId"
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
newMessages.forEach { shown.add(it.id.toString()) }
|
||||
|
||||
newMessageCount = newMessages.size
|
||||
Logger.d(
|
||||
"NotificationHelper",
|
||||
"displayNotifications: user=$currentUserId totalMessages=${messages.size} " +
|
||||
"newMessages=$newMessageCount conversationTitle=$conversationTitle"
|
||||
)
|
||||
|
||||
createChannel(context)
|
||||
cancelStaleSystemTrayDuplicates(context)
|
||||
|
||||
val messagingStyle = NotificationCompat.MessagingStyle(
|
||||
Person.Builder().setName("FromChat").build()
|
||||
)
|
||||
.setConversationTitle(conversationTitle)
|
||||
.setGroupConversation(true)
|
||||
|
||||
for (msg in newMessages.takeLast(10)) {
|
||||
val timestamp = try {
|
||||
Instant.parse(msg.timestamp).toEpochMilliseconds()
|
||||
} catch (_: Exception) {
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
messagingStyle.addMessage(
|
||||
NotificationCompat.MessagingStyle.Message(
|
||||
notificationBodyForMessage(msg, previewStrings),
|
||||
timestamp,
|
||||
Person.Builder()
|
||||
.setName(senderDisplayLabel(msg, currentUserId))
|
||||
.setKey(msg.user_id.toString())
|
||||
.build()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
notify(
|
||||
SUMMARY_NOTIFICATION_ID,
|
||||
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(NotificationSmallIcon.resId(context))
|
||||
.setLargeIcon(avatar)
|
||||
.setContentTitle(conversationTitle)
|
||||
.setStyle(messagingStyle)
|
||||
.setGroup(GROUP_PUBLIC)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||
.setAutoCancel(true)
|
||||
.addAction(
|
||||
NotificationCompat.Action.Builder(
|
||||
android.R.drawable.ic_menu_send,
|
||||
context.getString(R.string.notification_reply),
|
||||
createReplyIntent(
|
||||
context = context,
|
||||
isDirectMessage = false,
|
||||
parentMessageId = newMessages.last().id
|
||||
)
|
||||
)
|
||||
.addRemoteInput(
|
||||
RemoteInput.Builder(KEY_TEXT_REPLY)
|
||||
.setLabel(context.getString(R.string.notification_reply_hint))
|
||||
.build()
|
||||
)
|
||||
.setAllowGeneratedReplies(true)
|
||||
.build()
|
||||
)
|
||||
.setContentIntent(createMessageIntent(context, newMessages.last().id))
|
||||
.setShortcutId(GROUP_PUBLIC)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
settings.putStringSet(PREF_SHOWN_KEY, shown)
|
||||
Logger.i(
|
||||
"NotificationHelper",
|
||||
"displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears FCM auto-posted tray entries (notification payload) that duplicate our MessagingStyle. */
|
||||
private fun cancelStaleSystemTrayDuplicates(context: Context) {
|
||||
runCatching {
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
// Legacy FCM auto notifications used id 0 / fcm_fallback_notification_channel.
|
||||
manager.cancel(0)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
manager.activeNotifications
|
||||
.filter { status ->
|
||||
status.notification.channelId == "fcm_fallback_notification_channel" ||
|
||||
status.id == 0
|
||||
}
|
||||
.forEach { status ->
|
||||
manager.cancel(status.tag, status.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
)
|
||||
|
||||
val replyText = RemoteInput.getResultsFromIntent(intent)?.let { input ->
|
||||
(input.getCharSequence(NotificationHelper.KEY_TEXT_REPLY)
|
||||
(input.getCharSequence(KEY_TEXT_REPLY)
|
||||
?: input.getCharSequence("key_text_reply"))
|
||||
}
|
||||
?.toString()
|
||||
@@ -47,7 +47,11 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
val parentMessageId = intent.getIntExtra(EXTRA_REPLY_PARENT_MESSAGE_ID, -1).takeIf { it > 0 }
|
||||
|
||||
Logger.d("NotificationReply", "Received reply for $chatType (length=${replyText.length})")
|
||||
NotificationManagerCompat.from(context).cancel(NotificationHelper.summaryNotificationId())
|
||||
val notificationId = intent.getIntExtra("notification_id", 0)
|
||||
if (intent.hasExtra("notification_id")) {
|
||||
NotificationManagerCompat.from(context).cancel(notificationId)
|
||||
}
|
||||
MessageNotificationCoordinator.dismissAll()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
|
||||
+202
-26
@@ -54,11 +54,18 @@ compose.desktop {
|
||||
}
|
||||
macOS {
|
||||
bundleID = "ru.fromchat.desktop"
|
||||
dockName = "FromChat"
|
||||
iconFile.set(desktopWindowIconIcns)
|
||||
// Tray icons as NSImage templates (menu bar light/dark tint). Also set in Main.kt.
|
||||
jvmArgs("-Dapple.awt.enableTemplateImages=true")
|
||||
infoPlist {
|
||||
extraKeysRawXml = """
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>FromChat</string>
|
||||
<key>NSUserNotificationAlertStyle</key>
|
||||
<string>banner</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.social-networking</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -81,41 +88,210 @@ kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
val macNotificationsSource = layout.projectDirectory.file("src/nativeDarwin/MacNotificationCenter.m")
|
||||
val macNotificationsDylib = layout.buildDirectory.file("natives/libfromchat_notifications.dylib")
|
||||
val compileMacNotifications = tasks.register<Exec>("compileMacNotifications") {
|
||||
onlyIf {
|
||||
System.getProperty("os.name").orEmpty().lowercase().contains("mac")
|
||||
}
|
||||
inputs.file(macNotificationsSource)
|
||||
outputs.file(macNotificationsDylib)
|
||||
doFirst {
|
||||
macNotificationsDylib.get().asFile.parentFile.mkdirs()
|
||||
}
|
||||
val jniInclude = listOfNotNull(
|
||||
System.getenv("JAVA_HOME")?.let { "$it/include" },
|
||||
"${System.getProperty("java.home")}/include",
|
||||
"/opt/homebrew/opt/openjdk/include",
|
||||
"/opt/homebrew/opt/openjdk@26/include",
|
||||
"/Library/Java/JavaVirtualMachines/openjdk.jdk/Contents/Home/include",
|
||||
).first { file("$it/jni.h").isFile }
|
||||
commandLine(
|
||||
"clang",
|
||||
"-shared",
|
||||
"-fobjc-arc",
|
||||
"-fobjc-exceptions",
|
||||
"-fPIC",
|
||||
"-mmacosx-version-min=11.0",
|
||||
"-arch", "arm64",
|
||||
"-arch", "x86_64",
|
||||
"-framework", "Foundation",
|
||||
"-framework", "AppKit",
|
||||
"-framework", "UserNotifications",
|
||||
"-framework", "CoreGraphics",
|
||||
"-framework", "CoreServices",
|
||||
"-I", jniInclude,
|
||||
"-I", "$jniInclude/darwin",
|
||||
"-o", macNotificationsDylib.get().asFile.absolutePath,
|
||||
macNotificationsSource.asFile.absolutePath,
|
||||
)
|
||||
}
|
||||
|
||||
tasks.named<Copy>("processResources") {
|
||||
dependsOn(compileMacNotifications)
|
||||
from(layout.buildDirectory.dir("natives")) {
|
||||
include("*.dylib")
|
||||
into("natives")
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose Desktop puts the whole runtime classpath on the unnamed module path.
|
||||
* JavaFX 11+ must be loaded as named modules via --module-path / --add-modules,
|
||||
* and those jars must not also sit on the regular classpath.
|
||||
*
|
||||
* On macOS, [UNUserNotificationCenter] aborts a bare `java` process
|
||||
* (`bundleProxyForCurrentProcess is nil`). :run is therefore launched from a
|
||||
* FromChat.app wrapper so Notification Center sees `ru.fromchat.desktop`.
|
||||
*/
|
||||
fun JavaExec.configureFromChatDesktopJvm() {
|
||||
val javafxJars = classpath.files.filter { file ->
|
||||
javafxModules.any { module -> file.name.startsWith("javafx-$module-") }
|
||||
}
|
||||
classpath = project.files(classpath.files.filterNot { it in javafxJars.toSet() })
|
||||
val dockIconArgs = buildList {
|
||||
val os = System.getProperty("os.name").orEmpty().lowercase()
|
||||
if (os.contains("mac")) {
|
||||
val png = desktopWindowIconPng.asFile
|
||||
val icns = desktopWindowIconIcns.asFile
|
||||
when {
|
||||
icns.isFile -> add("-Xdock:icon=${icns.absolutePath}")
|
||||
png.isFile -> add("-Xdock:icon=${png.absolutePath}")
|
||||
}
|
||||
add("-Xdock:name=FromChat")
|
||||
}
|
||||
}
|
||||
jvmArgs(
|
||||
dockIconArgs + listOf(
|
||||
"-Dapple.awt.enableTemplateImages=true",
|
||||
"--module-path",
|
||||
javafxJars.joinToString(File.pathSeparator) { it.absolutePath },
|
||||
"--add-modules",
|
||||
"javafx.controls,javafx.web,javafx.swing,javafx.media,javafx.graphics,javafx.base",
|
||||
"--add-opens",
|
||||
"javafx.graphics/com.sun.javafx.application=ALL-UNNAMED",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun prepareFromChatDevApp(javaHome: File, destApp: File) {
|
||||
val macosDir = destApp.resolve("Contents/MacOS")
|
||||
val resourcesDir = destApp.resolve("Contents/Resources")
|
||||
macosDir.mkdirs()
|
||||
resourcesDir.mkdirs()
|
||||
val destExec = macosDir.resolve("FromChat")
|
||||
val stamp = resourcesDir.resolve("java-home.txt")
|
||||
val javaHomePath = javaHome.absolutePath
|
||||
if (!destExec.isFile || stamp.takeIf { it.isFile }?.readText() != javaHomePath) {
|
||||
javaHome.resolve("bin/java").copyTo(destExec, overwrite = true)
|
||||
destExec.setExecutable(true, false)
|
||||
ProcessBuilder(
|
||||
"install_name_tool",
|
||||
"-add_rpath",
|
||||
javaHome.resolve("lib").absolutePath,
|
||||
destExec.absolutePath,
|
||||
).inheritIO().start().waitFor()
|
||||
stamp.writeText(javaHomePath)
|
||||
}
|
||||
destApp.resolve("Contents/Info.plist").writeText(
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>FromChat</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>ru.fromchat.desktop</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>FromChat</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>FromChat</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>app_window_icon.icns</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>11.0</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.social-networking</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSUserNotificationAlertStyle</key>
|
||||
<string>banner</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>FromChat</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>fromchat</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
""".trimIndent() + "\n",
|
||||
)
|
||||
destApp.resolve("Contents/PkgInfo").writeText("APPL????")
|
||||
val icns = desktopWindowIconIcns.asFile
|
||||
if (icns.isFile) {
|
||||
icns.copyTo(resourcesDir.resolve("app_window_icon.icns"), overwrite = true)
|
||||
}
|
||||
ProcessBuilder(
|
||||
"codesign",
|
||||
"--force",
|
||||
"--sign",
|
||||
"-",
|
||||
"--identifier",
|
||||
"ru.fromchat.desktop",
|
||||
destApp.absolutePath,
|
||||
).inheritIO().start().waitFor()
|
||||
}
|
||||
|
||||
val runningOnMac = System.getProperty("os.name").orEmpty().lowercase().contains("mac")
|
||||
|
||||
tasks.matching { it.name == "run" }.configureEach {
|
||||
if (this !is JavaExec) return@configureEach
|
||||
doFirst {
|
||||
val javafxJars = classpath.files.filter { file ->
|
||||
javafxModules.any { module -> file.name.startsWith("javafx-$module-") }
|
||||
if (runningOnMac) return@configureEach
|
||||
doFirst { configureFromChatDesktopJvm() }
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
if (!runningOnMac) return@afterEvaluate
|
||||
val runTask = tasks.findByName("run") as? JavaExec ?: return@afterEvaluate
|
||||
runTask.actions.clear()
|
||||
runTask.doFirst { runTask.configureFromChatDesktopJvm() }
|
||||
runTask.doLast {
|
||||
val javaHomeFile = runTask.javaLauncher.orNull?.metadata?.installationPath?.asFile
|
||||
?: file(System.getProperty("java.home"))
|
||||
val app = layout.buildDirectory.get().asFile.resolve("macos-dev-bundle/FromChat.app")
|
||||
prepareFromChatDevApp(javaHomeFile, app)
|
||||
val command = buildList {
|
||||
add(app.resolve("Contents/MacOS/FromChat").absolutePath)
|
||||
addAll(runTask.allJvmArgs)
|
||||
add("-classpath")
|
||||
add(runTask.classpath.asPath)
|
||||
add(runTask.mainClass.get())
|
||||
addAll(runTask.args)
|
||||
}
|
||||
classpath = files(classpath.files.filterNot { it in javafxJars.toSet() })
|
||||
val dockIconArgs = buildList {
|
||||
// Compose only adds -Xdock:icon when macOS.iconFile is set; pin PNG for :run too.
|
||||
val os = System.getProperty("os.name").orEmpty().lowercase()
|
||||
if (os.contains("mac")) {
|
||||
val png = desktopWindowIconPng.asFile
|
||||
val icns = desktopWindowIconIcns.asFile
|
||||
when {
|
||||
icns.isFile -> add("-Xdock:icon=${icns.absolutePath}")
|
||||
png.isFile -> add("-Xdock:icon=${png.absolutePath}")
|
||||
}
|
||||
}
|
||||
val process = ProcessBuilder(command)
|
||||
.directory(runTask.workingDir)
|
||||
.redirectInput(ProcessBuilder.Redirect.INHERIT)
|
||||
.redirectOutput(ProcessBuilder.Redirect.INHERIT)
|
||||
.redirectError(ProcessBuilder.Redirect.INHERIT)
|
||||
process.environment().putAll(runTask.environment.mapValues { it.value.toString() })
|
||||
process.environment()["JAVA_HOME"] = javaHomeFile.absolutePath
|
||||
val exit = process.start().waitFor()
|
||||
if (exit != 0) {
|
||||
throw GradleException("FromChat exited with $exit")
|
||||
}
|
||||
jvmArgs(
|
||||
dockIconArgs + listOf(
|
||||
"-Dapple.awt.enableTemplateImages=true",
|
||||
"--module-path",
|
||||
javafxJars.joinToString(File.pathSeparator) { it.absolutePath },
|
||||
"--add-modules",
|
||||
"javafx.controls,javafx.web,javafx.swing,javafx.media,javafx.graphics,javafx.base",
|
||||
"--add-opens",
|
||||
"javafx.graphics/com.sun.javafx.application=ALL-UNNAMED",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
@@ -30,6 +32,7 @@ import androidx.compose.ui.window.MenuBar
|
||||
import androidx.compose.ui.window.Notification
|
||||
import androidx.compose.ui.window.Tray
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.WindowExceptionHandler
|
||||
import androidx.compose.ui.window.WindowPlacement
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.window.rememberTrayState
|
||||
@@ -72,6 +75,12 @@ import ru.fromchat.desktop_zoom
|
||||
import ru.fromchat.status_connected
|
||||
import ru.fromchat.status_connecting
|
||||
import ru.fromchat.status_disconnected
|
||||
import ru.fromchat.desktop.DesktopAppVisibility
|
||||
import ru.fromchat.desktop.DesktopNotificationSettings
|
||||
import ru.fromchat.desktop.DesktopNotifier
|
||||
import ru.fromchat.desktop.DesktopTaskbarBadge
|
||||
import ru.fromchat.desktop.MacNotificationCenter
|
||||
import ru.fromchat.notifications.MessageNotificationCoordinator
|
||||
import ru.fromchat.ui.App
|
||||
import ru.fromchat.ui.LocalExtraStatusBarTop
|
||||
import ru.fromchat.ui.Theme
|
||||
@@ -285,6 +294,9 @@ fun main(args: Array<String>) {
|
||||
|
||||
application {
|
||||
UtilsLibrary.init()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
Logger.e("Desktop", "uncaught on ${thread.name}", throwable)
|
||||
}
|
||||
DesktopApplicationBootstrap.launchOnApplicationStart()
|
||||
|
||||
val windowState = rememberWindowState(
|
||||
@@ -345,20 +357,72 @@ fun main(args: Array<String>) {
|
||||
}
|
||||
|
||||
DisposableEffect(trayState) {
|
||||
DesktopNotifier.sink = { title, body ->
|
||||
trayState.sendNotification(
|
||||
Notification(title = title, message = body),
|
||||
DesktopNotifier.sink = { payload ->
|
||||
val native = mac &&
|
||||
MacNotificationCenter.deliver(
|
||||
title = payload.title,
|
||||
body = payload.body,
|
||||
subtitle = payload.subtitle,
|
||||
)
|
||||
Logger.i(
|
||||
"DesktopNotifier",
|
||||
"sink mac=$mac nativeDelivered=$native titleLen=${payload.title.length} " +
|
||||
"subtitleLen=${payload.subtitle.length}",
|
||||
)
|
||||
if (!native) {
|
||||
Logger.i("DesktopNotifier", "sink fallback trayState.sendNotification")
|
||||
trayState.sendNotification(
|
||||
Notification(title = payload.title, message = payload.displayBody()),
|
||||
)
|
||||
}
|
||||
}
|
||||
onDispose {
|
||||
DesktopNotifier.sink = null
|
||||
MessageNotificationCoordinator.uninstall()
|
||||
DesktopSingleInstance.release()
|
||||
}
|
||||
}
|
||||
|
||||
fun showMainWindow() {
|
||||
LaunchedEffect(isLoggedIn) {
|
||||
if (isLoggedIn) {
|
||||
MessageNotificationCoordinator.install()
|
||||
MessageNotificationCoordinator.refreshChrome()
|
||||
} else {
|
||||
MessageNotificationCoordinator.uninstall()
|
||||
DesktopTaskbarBadge.setUnreadCount(0)
|
||||
}
|
||||
}
|
||||
|
||||
SideEffect {
|
||||
DesktopAppVisibility.isWindowVisible = windowVisible
|
||||
if (!windowVisible) {
|
||||
DesktopAppVisibility.isWindowFocused = false
|
||||
AppForeground.setWindowFocused(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun showMainWindow(notificationIdentifier: String? = null) {
|
||||
windowVisible = true
|
||||
AppForeground.setForeground(true)
|
||||
DesktopNotifier.deliverPendingLaunch(notificationIdentifier)
|
||||
}
|
||||
|
||||
LaunchedEffect(mac, windowVisible) {
|
||||
if (!mac) return@LaunchedEffect
|
||||
MacNotificationCenter.onActivated = { identifier ->
|
||||
SwingUtilities.invokeLater { showMainWindow(identifier) }
|
||||
}
|
||||
if (!windowVisible || !DesktopNotificationSettings.enabled) {
|
||||
Logger.i(
|
||||
"MacNotificationCenter",
|
||||
"startup skip requestAuthorization windowVisible=$windowVisible " +
|
||||
"enabled=${DesktopNotificationSettings.enabled}",
|
||||
)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
delay(400.milliseconds)
|
||||
Logger.i("MacNotificationCenter", "startup requestAuthorization")
|
||||
MacNotificationCenter.registerAndRequestAuthorization()
|
||||
}
|
||||
|
||||
fun openAbout() {
|
||||
@@ -486,9 +550,46 @@ fun main(args: Array<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
DisposableEffect(window) {
|
||||
installLoggedWindowExceptionHandler(window)
|
||||
AppForeground.setForeground(true)
|
||||
onDispose {}
|
||||
fun syncWindowFocus(focused: Boolean) {
|
||||
DesktopAppVisibility.isWindowFocused = focused
|
||||
AppForeground.setWindowFocused(focused)
|
||||
if (!focused) MacNotificationCenter.resignActive()
|
||||
Logger.i(
|
||||
"DesktopAppVisibility",
|
||||
"focus=$focused active=${window.isActive} awtFocused=${window.isFocused}",
|
||||
)
|
||||
}
|
||||
val listener = object : java.awt.event.WindowAdapter() {
|
||||
override fun windowActivated(e: java.awt.event.WindowEvent?) {
|
||||
syncWindowFocus(true)
|
||||
}
|
||||
|
||||
override fun windowDeactivated(e: java.awt.event.WindowEvent?) {
|
||||
syncWindowFocus(false)
|
||||
}
|
||||
|
||||
override fun windowGainedFocus(e: java.awt.event.WindowEvent?) {
|
||||
syncWindowFocus(true)
|
||||
}
|
||||
|
||||
override fun windowLostFocus(e: java.awt.event.WindowEvent?) {
|
||||
syncWindowFocus(false)
|
||||
}
|
||||
|
||||
override fun windowIconified(e: java.awt.event.WindowEvent?) {
|
||||
syncWindowFocus(false)
|
||||
}
|
||||
}
|
||||
syncWindowFocus(window.isActive && window.isFocused)
|
||||
window.addWindowListener(listener)
|
||||
window.addWindowFocusListener(listener)
|
||||
onDispose {
|
||||
window.removeWindowListener(listener)
|
||||
window.removeWindowFocusListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
if (mac) {
|
||||
@@ -913,6 +1014,13 @@ private fun scaleBufferedImage(source: BufferedImage, size: Int): BufferedImage
|
||||
return out
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
private fun installLoggedWindowExceptionHandler(window: androidx.compose.ui.awt.ComposeWindow) {
|
||||
window.exceptionHandler = WindowExceptionHandler { throwable ->
|
||||
Logger.e("DesktopWindow", "uncaught in composition", throwable)
|
||||
}
|
||||
}
|
||||
|
||||
/** Opaque mark so Tray never substitutes the Compose default for a blank painter. */
|
||||
private fun solidFallbackIcon(): BufferedImage {
|
||||
val out = BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB)
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <CoreGraphics/CoreGraphics.h>
|
||||
#import <CoreServices/CoreServices.h>
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UserNotifications/UserNotifications.h>
|
||||
#import <jni.h>
|
||||
|
||||
@class FromChatNotificationDelegate;
|
||||
|
||||
static JavaVM *fromchatJvm = NULL;
|
||||
static FromChatNotificationDelegate *fromchatDelegate = nil;
|
||||
static jclass fromchatMacNotificationCenterClass = NULL;
|
||||
|
||||
@interface FromChatNotificationDelegate : NSObject <UNUserNotificationCenterDelegate>
|
||||
@end
|
||||
|
||||
static void fromchatCallJavaStaticVoid(const char *methodName, const char *utfArg);
|
||||
|
||||
@implementation FromChatNotificationDelegate
|
||||
|
||||
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
|
||||
willPresentNotification:(UNNotification *)notification
|
||||
withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler {
|
||||
UNNotificationPresentationOptions options =
|
||||
UNNotificationPresentationOptionBanner |
|
||||
UNNotificationPresentationOptionList |
|
||||
UNNotificationPresentationOptionSound |
|
||||
UNNotificationPresentationOptionBadge;
|
||||
NSLog(
|
||||
@"FromChat UN willPresent id=%@ nsAppActive=%d options=%lu",
|
||||
notification.request.identifier,
|
||||
[NSApp isActive],
|
||||
(unsigned long)options
|
||||
);
|
||||
completionHandler(options);
|
||||
fromchatCallJavaStaticVoid("onNativeWillPresent", notification.request.identifier.UTF8String);
|
||||
}
|
||||
|
||||
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
|
||||
didReceiveNotificationResponse:(UNNotificationResponse *)response
|
||||
withCompletionHandler:(void (^)(void))completionHandler {
|
||||
NSString *action = response.actionIdentifier;
|
||||
NSString *identifier = response.notification.request.identifier;
|
||||
NSLog(@"FromChat UN response action=%@ id=%@", action, identifier);
|
||||
if ([action isEqualToString:UNNotificationDismissActionIdentifier]) {
|
||||
completionHandler();
|
||||
return;
|
||||
}
|
||||
fromchatCallJavaStaticVoid("onNativeActivated", identifier.UTF8String);
|
||||
completionHandler();
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
static void fromchatCallJavaStaticVoid(const char *methodName, const char *utfArg) {
|
||||
if (fromchatJvm == NULL || fromchatMacNotificationCenterClass == NULL || methodName == NULL) {
|
||||
return;
|
||||
}
|
||||
JNIEnv *env = NULL;
|
||||
jint getEnv = (*fromchatJvm)->GetEnv(fromchatJvm, (void **)&env, JNI_VERSION_1_8);
|
||||
jboolean attachedHere = JNI_FALSE;
|
||||
if (getEnv == JNI_EDETACHED) {
|
||||
if ((*fromchatJvm)->AttachCurrentThread(fromchatJvm, (void **)&env, NULL) != JNI_OK) {
|
||||
return;
|
||||
}
|
||||
attachedHere = JNI_TRUE;
|
||||
}
|
||||
if (env == NULL) return;
|
||||
jmethodID mid = (*env)->GetStaticMethodID(
|
||||
env,
|
||||
fromchatMacNotificationCenterClass,
|
||||
methodName,
|
||||
"(Ljava/lang/String;)V"
|
||||
);
|
||||
if (mid != NULL) {
|
||||
jstring jid = utfArg != NULL ? (*env)->NewStringUTF(env, utfArg) : NULL;
|
||||
(*env)->CallStaticVoidMethod(env, fromchatMacNotificationCenterClass, mid, jid);
|
||||
if ((*env)->ExceptionCheck(env)) {
|
||||
(*env)->ExceptionDescribe(env);
|
||||
(*env)->ExceptionClear(env);
|
||||
}
|
||||
if (jid != NULL) (*env)->DeleteLocalRef(env, jid);
|
||||
}
|
||||
if (attachedHere) {
|
||||
(*fromchatJvm)->DetachCurrentThread(fromchatJvm);
|
||||
}
|
||||
}
|
||||
|
||||
static BOOL fromchatIsBundledApp(void) {
|
||||
NSString *path = [NSBundle mainBundle].bundlePath;
|
||||
return [path.pathExtension isEqualToString:@"app"];
|
||||
}
|
||||
|
||||
static UNUserNotificationCenter *fromchatNotificationCenter(void) {
|
||||
NSBundle *bundle = [NSBundle mainBundle];
|
||||
if (!fromchatIsBundledApp()) {
|
||||
NSLog(@"FromChat UN skip: not an .app (path=%@ id=%@)", bundle.bundlePath, bundle.bundleIdentifier);
|
||||
return nil;
|
||||
}
|
||||
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
|
||||
NSLog(@"FromChat UN center=%p path=%@ id=%@", center, bundle.bundlePath, bundle.bundleIdentifier);
|
||||
return center;
|
||||
}
|
||||
|
||||
static void fromchatRunOnMain(void (^block)(void)) {
|
||||
if ([NSThread isMainThread]) {
|
||||
block();
|
||||
} else {
|
||||
dispatch_sync(dispatch_get_main_queue(), block);
|
||||
}
|
||||
}
|
||||
|
||||
static void fromchatEnsureDelegate(UNUserNotificationCenter *center) {
|
||||
if (center == nil) return;
|
||||
fromchatRunOnMain(^{
|
||||
if (fromchatDelegate == nil) {
|
||||
fromchatDelegate = [FromChatNotificationDelegate new];
|
||||
}
|
||||
center.delegate = fromchatDelegate;
|
||||
NSLog(@"FromChat UN delegate set on %@", center);
|
||||
});
|
||||
}
|
||||
|
||||
static NSRunningApplication *fromchatOtherFrontApp(void) {
|
||||
NSString *ours = [NSBundle mainBundle].bundleIdentifier;
|
||||
pid_t ourPid = [NSRunningApplication currentApplication].processIdentifier;
|
||||
NSRunningApplication *wsFront = [[NSWorkspace sharedWorkspace] frontmostApplication];
|
||||
if (wsFront != nil && wsFront.processIdentifier != ourPid &&
|
||||
(ours == nil || ![wsFront.bundleIdentifier isEqualToString:ours])) {
|
||||
return wsFront;
|
||||
}
|
||||
CFArrayRef info = CGWindowListCopyWindowInfo(
|
||||
kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements,
|
||||
kCGNullWindowID
|
||||
);
|
||||
if (info == NULL) return nil;
|
||||
NSArray *windows = CFBridgingRelease(info);
|
||||
for (NSDictionary *win in windows) {
|
||||
NSNumber *layer = win[(id)kCGWindowLayer];
|
||||
if (layer == nil || layer.intValue != 0) continue;
|
||||
NSNumber *pidNum = win[(id)kCGWindowOwnerPID];
|
||||
if (pidNum == nil) continue;
|
||||
pid_t pid = pidNum.intValue;
|
||||
if (pid == ourPid) continue;
|
||||
NSRunningApplication *app = [NSRunningApplication runningApplicationWithProcessIdentifier:pid];
|
||||
if (app == nil) continue;
|
||||
if (app.activationPolicy != NSApplicationActivationPolicyRegular) continue;
|
||||
return app;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static BOOL fromchatIsAppFrontmost(void) {
|
||||
__block BOOL frontmost = NO;
|
||||
fromchatRunOnMain(^{
|
||||
NSString *frontId = [[NSWorkspace sharedWorkspace] frontmostApplication].bundleIdentifier;
|
||||
NSString *ours = [NSBundle mainBundle].bundleIdentifier;
|
||||
frontmost = frontId != nil && ours != nil && [frontId isEqualToString:ours];
|
||||
NSLog(
|
||||
@"FromChat frontmost=%d front=%@ ours=%@ nsAppActive=%d",
|
||||
frontmost,
|
||||
frontId,
|
||||
ours,
|
||||
[NSApp isActive]
|
||||
);
|
||||
});
|
||||
return frontmost;
|
||||
}
|
||||
|
||||
static void fromchatResignActive(void) {
|
||||
fromchatRunOnMain(^{
|
||||
[NSApp deactivate];
|
||||
NSRunningApplication *other = fromchatOtherFrontApp();
|
||||
if (other != nil) {
|
||||
if (@available(macOS 14.0, *)) {
|
||||
[NSApp yieldActivationToApplication:other];
|
||||
} else {
|
||||
[other activateWithOptions:NSApplicationActivateIgnoringOtherApps];
|
||||
}
|
||||
}
|
||||
NSLog(
|
||||
@"FromChat resignActive nsAppActive=%d front=%@",
|
||||
[NSApp isActive],
|
||||
[[NSWorkspace sharedWorkspace] frontmostApplication].bundleIdentifier
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static void fromchatYieldIfNotFrontmost(void) {
|
||||
fromchatResignActive();
|
||||
}
|
||||
|
||||
static BOOL fromchatDeliverNotification(
|
||||
UNUserNotificationCenter *center,
|
||||
NSString *title,
|
||||
NSString *body,
|
||||
NSString *subtitle,
|
||||
NSString *identifier,
|
||||
BOOL playSound,
|
||||
BOOL windowFocused
|
||||
) {
|
||||
if (center == nil) return NO;
|
||||
fromchatEnsureDelegate(center);
|
||||
if (windowFocused != YES) {
|
||||
fromchatResignActive();
|
||||
}
|
||||
|
||||
UNMutableNotificationContent *content = [UNMutableNotificationContent new];
|
||||
content.title = title;
|
||||
if (subtitle.length > 0) {
|
||||
content.subtitle = subtitle;
|
||||
}
|
||||
content.body = body;
|
||||
content.sound = playSound ? [UNNotificationSound defaultSound] : nil;
|
||||
if (@available(macOS 12.0, *)) {
|
||||
content.interruptionLevel = UNNotificationInterruptionLevelActive;
|
||||
}
|
||||
|
||||
UNNotificationRequest *request =
|
||||
[UNNotificationRequest requestWithIdentifier:identifier content:content trigger:nil];
|
||||
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
|
||||
__block BOOL ok = NO;
|
||||
[center addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) {
|
||||
ok = error == nil;
|
||||
NSLog(
|
||||
@"FromChat UN add id=%@ ok=%d focused=%d nsAppActive=%d error=%@",
|
||||
identifier,
|
||||
ok,
|
||||
windowFocused == YES,
|
||||
[NSApp isActive],
|
||||
error
|
||||
);
|
||||
dispatch_semaphore_signal(semaphore);
|
||||
}];
|
||||
dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC));
|
||||
return ok;
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
|
||||
fromchatJvm = vm;
|
||||
JNIEnv *env = NULL;
|
||||
if ((*vm)->GetEnv(vm, (void **)&env, JNI_VERSION_1_8) == JNI_OK && env != NULL) {
|
||||
jclass local = (*env)->FindClass(env, "ru/fromchat/desktop/MacNotificationCenter");
|
||||
if (local != NULL) {
|
||||
fromchatMacNotificationCenterClass = (*env)->NewGlobalRef(env, local);
|
||||
(*env)->DeleteLocalRef(env, local);
|
||||
}
|
||||
}
|
||||
return JNI_VERSION_1_8;
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeRequestAuthorization(
|
||||
JNIEnv *env,
|
||||
jclass cls
|
||||
) {
|
||||
UNUserNotificationCenter *center = fromchatNotificationCenter();
|
||||
if (center == nil) return JNI_FALSE;
|
||||
fromchatEnsureDelegate(center);
|
||||
|
||||
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
|
||||
__block BOOL grantedResult = NO;
|
||||
void (^request)(void) = ^{
|
||||
[center requestAuthorizationWithOptions:(UNAuthorizationOptionAlert |
|
||||
UNAuthorizationOptionSound |
|
||||
UNAuthorizationOptionBadge)
|
||||
completionHandler:^(BOOL granted, NSError * _Nullable error) {
|
||||
grantedResult = granted;
|
||||
NSLog(@"FromChat UN auth granted=%d error=%@", granted, error);
|
||||
dispatch_semaphore_signal(semaphore);
|
||||
}];
|
||||
};
|
||||
if ([NSThread isMainThread]) {
|
||||
request();
|
||||
} else {
|
||||
dispatch_async(dispatch_get_main_queue(), request);
|
||||
}
|
||||
dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC));
|
||||
return grantedResult ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeAuthorizationStatus(
|
||||
JNIEnv *env,
|
||||
jclass cls
|
||||
) {
|
||||
UNUserNotificationCenter *center = fromchatNotificationCenter();
|
||||
if (center == nil) return 0;
|
||||
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
|
||||
__block NSInteger status = 0;
|
||||
[center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings *settings) {
|
||||
status = settings.authorizationStatus;
|
||||
if (@available(macOS 12.0, *)) {
|
||||
NSLog(
|
||||
@"FromChat UN status=%ld alert=%ld sound=%ld badge=%ld preview=%ld",
|
||||
(long)settings.authorizationStatus,
|
||||
(long)settings.alertSetting,
|
||||
(long)settings.soundSetting,
|
||||
(long)settings.badgeSetting,
|
||||
(long)settings.showPreviewsSetting
|
||||
);
|
||||
} else {
|
||||
NSLog(@"FromChat UN status=%ld alert=%ld sound=%ld badge=%ld",
|
||||
(long)settings.authorizationStatus,
|
||||
(long)settings.alertSetting,
|
||||
(long)settings.soundSetting,
|
||||
(long)settings.badgeSetting);
|
||||
}
|
||||
dispatch_semaphore_signal(semaphore);
|
||||
}];
|
||||
dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC));
|
||||
return (jint)status;
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeDeliver(
|
||||
JNIEnv *env,
|
||||
jclass cls,
|
||||
jstring jTitle,
|
||||
jstring jBody,
|
||||
jstring jSubtitle,
|
||||
jstring jIdentifier,
|
||||
jboolean playSound,
|
||||
jboolean windowFocused
|
||||
) {
|
||||
UNUserNotificationCenter *center = fromchatNotificationCenter();
|
||||
if (center == nil) return JNI_FALSE;
|
||||
|
||||
const char *titleChars = (*env)->GetStringUTFChars(env, jTitle, NULL);
|
||||
const char *bodyChars = (*env)->GetStringUTFChars(env, jBody, NULL);
|
||||
const char *subtitleChars = jSubtitle ? (*env)->GetStringUTFChars(env, jSubtitle, NULL) : NULL;
|
||||
const char *idChars = (*env)->GetStringUTFChars(env, jIdentifier, NULL);
|
||||
NSString *title = titleChars ? [NSString stringWithUTF8String:titleChars] : @"";
|
||||
NSString *body = bodyChars ? [NSString stringWithUTF8String:bodyChars] : @"";
|
||||
NSString *subtitle = subtitleChars ? [NSString stringWithUTF8String:subtitleChars] : @"";
|
||||
NSString *identifier = idChars ? [NSString stringWithUTF8String:idChars] : [[NSUUID UUID] UUIDString];
|
||||
|
||||
__block BOOL ok = NO;
|
||||
void (^deliver)(void) = ^{
|
||||
ok = fromchatDeliverNotification(
|
||||
center,
|
||||
title,
|
||||
body,
|
||||
subtitle,
|
||||
identifier,
|
||||
playSound == JNI_TRUE,
|
||||
windowFocused
|
||||
);
|
||||
};
|
||||
if ([NSThread isMainThread]) {
|
||||
deliver();
|
||||
} else {
|
||||
dispatch_sync(dispatch_get_main_queue(), deliver);
|
||||
}
|
||||
|
||||
if (titleChars) (*env)->ReleaseStringUTFChars(env, jTitle, titleChars);
|
||||
if (bodyChars) (*env)->ReleaseStringUTFChars(env, jBody, bodyChars);
|
||||
if (subtitleChars) (*env)->ReleaseStringUTFChars(env, jSubtitle, subtitleChars);
|
||||
if (idChars) (*env)->ReleaseStringUTFChars(env, jIdentifier, idChars);
|
||||
return ok ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeRemove(
|
||||
JNIEnv *env,
|
||||
jclass cls,
|
||||
jobjectArray jIdentifiers
|
||||
) {
|
||||
UNUserNotificationCenter *center = fromchatNotificationCenter();
|
||||
if (center == nil || jIdentifiers == NULL) return;
|
||||
jsize count = (*env)->GetArrayLength(env, jIdentifiers);
|
||||
NSMutableArray<NSString *> *ids = [NSMutableArray arrayWithCapacity:(NSUInteger)count];
|
||||
for (jsize i = 0; i < count; i++) {
|
||||
jstring jId = (*env)->GetObjectArrayElement(env, jIdentifiers, i);
|
||||
if (jId == NULL) continue;
|
||||
const char *chars = (*env)->GetStringUTFChars(env, jId, NULL);
|
||||
if (chars) {
|
||||
[ids addObject:[NSString stringWithUTF8String:chars]];
|
||||
(*env)->ReleaseStringUTFChars(env, jId, chars);
|
||||
}
|
||||
(*env)->DeleteLocalRef(env, jId);
|
||||
}
|
||||
NSLog(@"FromChat UN remove count=%lu", (unsigned long)ids.count);
|
||||
[center removeDeliveredNotificationsWithIdentifiers:ids];
|
||||
[center removePendingNotificationRequestsWithIdentifiers:ids];
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeRemoveAll(
|
||||
JNIEnv *env,
|
||||
jclass cls
|
||||
) {
|
||||
UNUserNotificationCenter *center = fromchatNotificationCenter();
|
||||
if (center == nil) return;
|
||||
NSLog(@"FromChat UN removeAll");
|
||||
[center removeAllDeliveredNotifications];
|
||||
[center removeAllPendingNotificationRequests];
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeOpenSettings(
|
||||
JNIEnv *env,
|
||||
jclass cls
|
||||
) {
|
||||
NSURL *url = [NSURL URLWithString:
|
||||
@"x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=ru.fromchat.desktop"];
|
||||
if (url == nil) return JNI_FALSE;
|
||||
BOOL opened = [[NSWorkspace sharedWorkspace] openURL:url];
|
||||
if (!opened) {
|
||||
url = [NSURL URLWithString:@"x-apple.systempreferences:com.apple.preference.notifications"];
|
||||
opened = url != nil && [[NSWorkspace sharedWorkspace] openURL:url];
|
||||
}
|
||||
return opened ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeDebugInfo(
|
||||
JNIEnv *env,
|
||||
jclass cls
|
||||
) {
|
||||
__block NSString *info = @"";
|
||||
fromchatRunOnMain(^{
|
||||
NSBundle *bundle = [NSBundle mainBundle];
|
||||
NSString *frontId = [[NSWorkspace sharedWorkspace] frontmostApplication].bundleIdentifier;
|
||||
NSString *ours = bundle.bundleIdentifier;
|
||||
BOOL frontmost = frontId != nil && ours != nil && [frontId isEqualToString:ours];
|
||||
info = [NSString stringWithFormat:
|
||||
@"bundlePath=%@ bundleId=%@ bundled=%d frontmost=%d front=%@ nsAppActive=%d",
|
||||
bundle.bundlePath,
|
||||
ours ?: @"(null)",
|
||||
fromchatIsBundledApp() ? 1 : 0,
|
||||
frontmost ? 1 : 0,
|
||||
frontId ?: @"(null)",
|
||||
[NSApp isActive] ? 1 : 0];
|
||||
});
|
||||
return (*env)->NewStringUTF(env, info.UTF8String);
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeIsAppFrontmost(
|
||||
JNIEnv *env,
|
||||
jclass cls
|
||||
) {
|
||||
return fromchatIsAppFrontmost() ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeResignActive(
|
||||
JNIEnv *env,
|
||||
jclass cls
|
||||
) {
|
||||
fromchatResignActive();
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeYieldActivation(
|
||||
JNIEnv *env,
|
||||
jclass cls
|
||||
) {
|
||||
fromchatYieldIfNotFrontmost();
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeIsBundled(
|
||||
JNIEnv *env,
|
||||
jclass cls
|
||||
) {
|
||||
return fromchatIsBundledApp() ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeRegisterBundle(
|
||||
JNIEnv *env,
|
||||
jclass cls
|
||||
) {
|
||||
if (!fromchatIsBundledApp()) return;
|
||||
NSURL *url = [NSBundle mainBundle].bundleURL;
|
||||
if (url == nil) return;
|
||||
LSRegisterURL((__bridge CFURLRef)url, true);
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
package ru.fromchat.notifications
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import ru.fromchat.Logger
|
||||
|
||||
actual object ChatNotificationDismissals {
|
||||
/** Must match [ru.fromchat.notifications.NotificationHelper] summary id in app:android. */
|
||||
private const val SUMMARY_NOTIFICATION_ID = 1_000_000
|
||||
|
||||
@Volatile
|
||||
private var appContext: Context? = null
|
||||
|
||||
fun install(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
actual fun dismissAllMessageNotifications() {
|
||||
val context = appContext ?: return
|
||||
runCatching {
|
||||
NotificationManagerCompat.from(context).cancel(SUMMARY_NOTIFICATION_ID)
|
||||
Logger.d("ChatNotificationDismissals", "Cancelled message notifications")
|
||||
}.onFailure {
|
||||
Logger.w("ChatNotificationDismissals", "Failed to cancel notifications: ${it.message}", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package ru.fromchat.notifications
|
||||
|
||||
internal actual fun notifyIncomingCallIfBackground(callerDisplayName: String) = Unit
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
package ru.fromchat.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.Person
|
||||
import androidx.core.app.RemoteInput
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import ru.fromchat.AppForeground
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.api.local.db.store.PublicChatProfileCache
|
||||
import ru.fromchat.api.local.messages.ActiveDmChatTracker
|
||||
import ru.fromchat.notification_direct_message_from
|
||||
import ru.fromchat.notification_reply
|
||||
import ru.fromchat.notification_reply_hint
|
||||
import ru.fromchat.public_chat
|
||||
import ru.fromchat.ui.chat.panels.publicchat.isPublicChatVisible
|
||||
|
||||
const val KEY_TEXT_REPLY = "key_text_reply"
|
||||
|
||||
private const val TAG = "MessageNotificationSink"
|
||||
private const val CHANNEL_ID = "fromchat_messages"
|
||||
private const val GROUP_PUBLIC = "ru.fromchat.notifications.public"
|
||||
private const val GROUP_DM_PREFIX = "ru.fromchat.notifications.dm."
|
||||
private const val EXTRA_NOTIFICATION_CHAT_TYPE = "notification_chat_type"
|
||||
private const val EXTRA_OPEN_DM_USER_ID = "open_dm_user_id"
|
||||
private const val EXTRA_REPLY_CHAT_TYPE = "notification_reply_chat_type"
|
||||
private const val EXTRA_REPLY_DM_USER_ID = "notification_reply_dm_user_id"
|
||||
private const val EXTRA_REPLY_PARENT_MESSAGE_ID = "notification_reply_parent_message_id"
|
||||
private const val EXTRA_MESSAGE_ID = "scroll_to_message_id"
|
||||
private const val CHAT_TYPE_PUBLIC = "public"
|
||||
private const val CHAT_TYPE_DM = "dm"
|
||||
|
||||
internal actual object MessageNotificationSink {
|
||||
actual fun areEnabled(): Boolean = true
|
||||
|
||||
actual fun shouldSuppressPublic(): Boolean =
|
||||
AppForeground.isInForeground.value && isPublicChatVisible
|
||||
|
||||
actual fun shouldSuppressDm(peerUserId: Int): Boolean =
|
||||
AppForeground.isInForeground.value && ActiveDmChatTracker.isActive(peerUserId)
|
||||
|
||||
actual suspend fun present(notification: PresentedMessageNotification) {
|
||||
val context = appContext() ?: return
|
||||
createChannel(context)
|
||||
if (
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
Logger.w(TAG, "present skip: POST_NOTIFICATIONS missing")
|
||||
return
|
||||
}
|
||||
|
||||
val conversationTitle = if (notification.isDirectMessage) {
|
||||
getString(Res.string.notification_direct_message_from, notification.senderName)
|
||||
} else {
|
||||
publicConversationTitle()
|
||||
}
|
||||
val groupKey = if (notification.isDirectMessage && notification.peerUserId != null) {
|
||||
GROUP_DM_PREFIX + notification.peerUserId
|
||||
} else {
|
||||
GROUP_PUBLIC
|
||||
}
|
||||
val senderName = notification.senderName.ifBlank { "FromChat" }
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(NotificationSmallIcon.resId(context))
|
||||
.setContentTitle(
|
||||
if (notification.isDirectMessage) senderName else conversationTitle
|
||||
)
|
||||
.setContentText(notification.body)
|
||||
.setGroup(groupKey)
|
||||
.setStyle(
|
||||
NotificationCompat.MessagingStyle(Person.Builder().setName("FromChat").build())
|
||||
.setConversationTitle(conversationTitle)
|
||||
.setGroupConversation(true)
|
||||
.addMessage(
|
||||
NotificationCompat.MessagingStyle.Message(
|
||||
notification.body,
|
||||
System.currentTimeMillis(),
|
||||
Person.Builder().setName(senderName).build(),
|
||||
)
|
||||
)
|
||||
)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(notification.isUpdate)
|
||||
.addAction(replyAction(context, notification))
|
||||
.setContentIntent(contentIntent(context, notification))
|
||||
.setShortcutId(groupKey)
|
||||
if (!notification.isDirectMessage) {
|
||||
builder.setLargeIcon(PublicChatNotificationAvatar.create(conversationTitle))
|
||||
}
|
||||
NotificationManagerCompat.from(context).notify(notification.androidNotifyId, builder.build())
|
||||
Logger.i(
|
||||
TAG,
|
||||
"present id=${notification.identifier} androidId=${notification.androidNotifyId} " +
|
||||
"update=${notification.isUpdate}",
|
||||
)
|
||||
}
|
||||
|
||||
actual fun dismiss(identifier: String) {
|
||||
val context = appContext() ?: return
|
||||
val notifyId = notifyIdFor(identifier)
|
||||
NotificationManagerCompat.from(context).cancel(notifyId)
|
||||
Logger.i(TAG, "dismiss identifier=$identifier androidId=$notifyId")
|
||||
}
|
||||
|
||||
actual fun dismissAll() {
|
||||
val context = appContext() ?: return
|
||||
runCatching {
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
NotificationManagerCompat.from(context).cancel(1_000_000)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
manager.activeNotifications
|
||||
.filter { it.notification.channelId == CHANNEL_ID }
|
||||
.forEach { NotificationManagerCompat.from(context).cancel(it.tag, it.id) }
|
||||
}
|
||||
Logger.d(TAG, "dismissAll")
|
||||
}.onFailure {
|
||||
Logger.w(TAG, "dismissAll failed: ${it.message}", it)
|
||||
}
|
||||
}
|
||||
|
||||
actual fun refreshChrome() = Unit
|
||||
}
|
||||
|
||||
private fun appContext(): Context? =
|
||||
runCatching { UtilsLibrary.context }.getOrNull()
|
||||
|
||||
private fun notifyIdFor(identifier: String): Int = notificationAndroidId(identifier)
|
||||
|
||||
private fun createChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
||||
.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "Messages", NotificationManager.IMPORTANCE_DEFAULT)
|
||||
.apply { description = "FromChat message notifications" }
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun publicConversationTitle(): String =
|
||||
PublicChatProfileCache.profile?.title?.takeIf { it.isNotBlank() }
|
||||
?: runCatching {
|
||||
PublicChatProfileCache.hydrateFromDiskImmediate(
|
||||
CacheContext.activeInstanceId.value.trim(),
|
||||
)?.title?.takeIf { it.isNotBlank() }
|
||||
}.getOrNull()
|
||||
?: getString(Res.string.public_chat)
|
||||
|
||||
private suspend fun replyAction(
|
||||
context: Context,
|
||||
notification: PresentedMessageNotification,
|
||||
): NotificationCompat.Action {
|
||||
val replyLabel = getString(Res.string.notification_reply)
|
||||
val replyHint = getString(Res.string.notification_reply_hint)
|
||||
return NotificationCompat.Action.Builder(
|
||||
android.R.drawable.ic_menu_send,
|
||||
replyLabel,
|
||||
replyIntent(context, notification),
|
||||
)
|
||||
.addRemoteInput(RemoteInput.Builder(KEY_TEXT_REPLY).setLabel(replyHint).build())
|
||||
.setAllowGeneratedReplies(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun contentIntent(
|
||||
context: Context,
|
||||
notification: PresentedMessageNotification,
|
||||
): PendingIntent {
|
||||
val intent = Intent().setClassName(context.packageName, "ru.fromchat.MainActivity").apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(EXTRA_MESSAGE_ID, notification.messageId)
|
||||
putExtra(
|
||||
EXTRA_NOTIFICATION_CHAT_TYPE,
|
||||
if (notification.isDirectMessage) CHAT_TYPE_DM else CHAT_TYPE_PUBLIC,
|
||||
)
|
||||
putExtra(EXTRA_OPEN_DM_USER_ID, notification.peerUserId ?: -1)
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
notification.androidNotifyId,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun replyIntent(
|
||||
context: Context,
|
||||
notification: PresentedMessageNotification,
|
||||
): PendingIntent {
|
||||
val intent = Intent().setClassName(
|
||||
context.packageName,
|
||||
"ru.fromchat.notifications.NotificationReplyReceiver",
|
||||
).apply {
|
||||
action = "${context.packageName}.NOTIFICATION_REPLY"
|
||||
putExtra("notification_id", notification.androidNotifyId)
|
||||
putExtra(EXTRA_REPLY_CHAT_TYPE, if (notification.isDirectMessage) CHAT_TYPE_DM else CHAT_TYPE_PUBLIC)
|
||||
putExtra(EXTRA_REPLY_DM_USER_ID, notification.peerUserId ?: -1)
|
||||
putExtra(EXTRA_REPLY_PARENT_MESSAGE_ID, notification.messageId)
|
||||
}
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
notification.androidNotifyId,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
|
||||
)
|
||||
}
|
||||
+8
@@ -43,3 +43,11 @@ actual fun areAppNotificationsEnabled(): Boolean {
|
||||
}
|
||||
|
||||
actual fun arePushNotificationsSupported(): Boolean = true
|
||||
|
||||
actual fun areDesktopMessageNotificationsSupported(): Boolean = false
|
||||
|
||||
actual fun areDesktopMessageNotificationsEnabled(): Boolean = false
|
||||
|
||||
actual fun setDesktopMessageNotificationsEnabled(enabled: Boolean) = Unit
|
||||
|
||||
actual fun requestDesktopNotificationPermission(): Boolean = true
|
||||
|
||||
@@ -349,6 +349,15 @@
|
||||
<string name="settings_push_notifications">Push-уведомления</string>
|
||||
<string name="settings_push_notifications_d">Они помогут вам узнать, если появилось новое сообщение пока приложение закрыто.</string>
|
||||
<string name="settings_push_notifications_unavailable">Недоступны на этой платформе. Новые сообщения видны только пока приложение открыто.</string>
|
||||
<string name="settings_desktop_notifications">Уведомления о сообщениях</string>
|
||||
<string name="settings_desktop_notifications_d">Показывать оповещения в системном трее, когда окно скрыто или вы в другом чате.</string>
|
||||
|
||||
<string name="notification_direct_message">Личное сообщение</string>
|
||||
<string name="notification_direct_message_from">Личное сообщение от %1$s</string>
|
||||
<string name="notification_new_messages">%1$d новых сообщений</string>
|
||||
<string name="notification_new_messages_in">%1$s: %2$s</string>
|
||||
<string name="notification_reply">Ответить</string>
|
||||
<string name="notification_reply_hint">Ответ в чат…</string>
|
||||
|
||||
<string name="settings_devices_title">Устройства</string>
|
||||
<string name="settings_devices_empty">Нет активных сессий</string>
|
||||
|
||||
@@ -380,6 +380,15 @@
|
||||
<string name="settings_push_notifications">Push notifications</string>
|
||||
<string name="settings_push_notifications_d">These will let you know if you got a new message when the app is closed.</string>
|
||||
<string name="settings_push_notifications_unavailable">Not available on this platform. New messages are only visible while the app is open.</string>
|
||||
<string name="settings_desktop_notifications">Message notifications</string>
|
||||
<string name="settings_desktop_notifications_d">Show alerts in the system tray when the window is hidden or you are in another chat.</string>
|
||||
|
||||
<string name="notification_direct_message">Direct message</string>
|
||||
<string name="notification_direct_message_from">Direct message from %1$s</string>
|
||||
<string name="notification_new_messages">%1$d new messages</string>
|
||||
<string name="notification_new_messages_in">%1$s: %2$s</string>
|
||||
<string name="notification_reply">Reply</string>
|
||||
<string name="notification_reply_hint">Reply to chat…</string>
|
||||
|
||||
<string name="settings_devices_title">Devices</string>
|
||||
<string name="settings_devices_empty">No active sessions</string>
|
||||
|
||||
@@ -8,7 +8,16 @@ object AppForeground {
|
||||
private val _isInForeground = MutableStateFlow(true)
|
||||
val isInForeground: StateFlow<Boolean> = _isInForeground.asStateFlow()
|
||||
|
||||
private val _isWindowFocused = MutableStateFlow(true)
|
||||
val isWindowFocused: StateFlow<Boolean> = _isWindowFocused.asStateFlow()
|
||||
|
||||
fun setForeground(inForeground: Boolean) {
|
||||
_isInForeground.value = inForeground
|
||||
if (!inForeground) _isWindowFocused.value = false
|
||||
}
|
||||
|
||||
fun setWindowFocused(focused: Boolean) {
|
||||
_isWindowFocused.value = focused
|
||||
if (focused) _isInForeground.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,11 +614,11 @@ object ApiClient {
|
||||
}
|
||||
.body()
|
||||
|
||||
suspend fun markMessagesRead(messageIds: List<Int>) {
|
||||
if (messageIds.isEmpty()) return
|
||||
suspend fun markMessagesRead(messageIds: List<Int> = emptyList(), markAll: Boolean = false) {
|
||||
if (!markAll && messageIds.isEmpty()) return
|
||||
http.post("${ServerConfig.apiBaseUrl}/messages/read") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(MarkReadRequest(messageIds = messageIds))
|
||||
setBody(MarkReadRequest(messageIds = messageIds, markAll = markAll))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -751,10 +751,14 @@ object ApiClient {
|
||||
.body<DmConversationsResponse>()
|
||||
.conversations
|
||||
|
||||
suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) {
|
||||
suspend fun markDmConversationRead(
|
||||
otherUserId: Int,
|
||||
messageIds: List<Int>? = null,
|
||||
markAll: Boolean = false,
|
||||
) {
|
||||
http.post("${ServerConfig.apiBaseUrl}/dm/conversations/$otherUserId/read") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(DmMarkReadRequest(upToEnvelopeId = upToEnvelopeId))
|
||||
setBody(DmMarkReadRequest(messageIds = messageIds, markAll = markAll))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import ru.fromchat.api.local.db.store.visibleDisplayName
|
||||
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.config.ServerConfig
|
||||
import ru.fromchat.notifications.notifyIncomingCallIfBackground
|
||||
|
||||
private const val TAG = "CallStore"
|
||||
|
||||
@@ -115,6 +116,8 @@ object CallStore {
|
||||
fromUsername = fromUsername,
|
||||
roomName = roomName,
|
||||
)
|
||||
val callerLabel = peerLabel(fromUserId).ifBlank { fromUsername }
|
||||
notifyIncomingCallIfBackground(callerLabel)
|
||||
}
|
||||
|
||||
fun startOutgoingCall(peerUserId: Int) {
|
||||
|
||||
@@ -65,23 +65,26 @@ object WebSocketManager {
|
||||
private val _messages = MutableSharedFlow<WebSocketMessage>(replay = 0, extraBufferCapacity = 64)
|
||||
val messages = _messages.asSharedFlow()
|
||||
|
||||
private val globalHandlers = mutableListOf<((WebSocketMessage) -> Unit)>()
|
||||
private val sessionReadyHandlers = mutableListOf<suspend () -> Unit>()
|
||||
@Volatile
|
||||
private var globalHandlers: List<((WebSocketMessage) -> Unit)> = emptyList()
|
||||
|
||||
@Volatile
|
||||
private var sessionReadyHandlers: List<suspend () -> Unit> = emptyList()
|
||||
|
||||
fun addGlobalMessageHandler(handler: ((WebSocketMessage) -> Unit)) {
|
||||
globalHandlers += handler
|
||||
globalHandlers = globalHandlers + handler
|
||||
}
|
||||
|
||||
fun removeGlobalMessageHandler(handler: ((WebSocketMessage) -> Unit)) {
|
||||
globalHandlers -= handler
|
||||
globalHandlers = globalHandlers - handler
|
||||
}
|
||||
|
||||
fun addSessionReadyHandler(handler: suspend () -> Unit) {
|
||||
sessionReadyHandlers += handler
|
||||
sessionReadyHandlers = sessionReadyHandlers + handler
|
||||
}
|
||||
|
||||
fun removeSessionReadyHandler(handler: suspend () -> Unit) {
|
||||
sessionReadyHandlers -= handler
|
||||
sessionReadyHandlers = sessionReadyHandlers - handler
|
||||
}
|
||||
|
||||
private fun notifySessionReady() {
|
||||
@@ -258,7 +261,11 @@ object WebSocketManager {
|
||||
}
|
||||
}
|
||||
|
||||
globalHandlers.forEach { it(msg) }
|
||||
globalHandlers.forEach { handler ->
|
||||
runCatching { handler(msg) }.onFailure {
|
||||
logW("Global handler failed: ${it.message}", it)
|
||||
}
|
||||
}
|
||||
_messages.emit(msg)
|
||||
} catch (e: Throwable) {
|
||||
logW("Received malformed payload: ${e.message}", e)
|
||||
|
||||
+105
-44
@@ -140,15 +140,16 @@ object MessageCacheStore {
|
||||
): ChatListPreviewState? {
|
||||
if (instanceId.isBlank()) return null
|
||||
val convId = conversationIdForPublic()
|
||||
val recent = resolvePreviewSourceMessageRow(instanceId, convId) ?: return null
|
||||
val message = enrichQueuedOutboundUi(listOf(recent.toAppMessage()), convId).firstOrNull()
|
||||
?: return null
|
||||
return buildChatListPreviewState(message, strings, ApiClient.user?.id)
|
||||
.let { state ->
|
||||
state.copy(
|
||||
text = state.text?.trim()?.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
val recent = resolvePreviewSourceMessageRow(instanceId, convId)
|
||||
val message = recent?.toAppMessage()?.let {
|
||||
enrichQueuedOutboundUi(listOf(it), convId).firstOrNull()
|
||||
}
|
||||
val base = message?.let {
|
||||
buildChatListPreviewState(it, strings, ApiClient.user?.id).let { state ->
|
||||
state.copy(text = state.text?.trim()?.takeIf { text -> text.isNotEmpty() })
|
||||
}
|
||||
} ?: ChatListPreviewState(text = null)
|
||||
return withPublicUnread(base, instanceId, convId)
|
||||
}
|
||||
|
||||
suspend fun loadRecentPublicChatPreviewState(
|
||||
@@ -157,15 +158,16 @@ object MessageCacheStore {
|
||||
): ChatListPreviewState? = withContext(Dispatchers.Default) {
|
||||
val convId = conversationIdForPublic()
|
||||
val iid = instanceId()
|
||||
val recent = resolvePreviewSourceMessageRow(iid, convId) ?: return@withContext null
|
||||
val message = enrichQueuedOutboundUi(listOf(recent.toAppMessage()), convId).firstOrNull()
|
||||
?: return@withContext null
|
||||
buildChatListPreviewState(message, strings, ApiClient.user?.id)
|
||||
.let { state ->
|
||||
state.copy(
|
||||
text = state.text?.trim()?.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
val recent = resolvePreviewSourceMessageRow(iid, convId)
|
||||
val message = recent?.toAppMessage()?.let {
|
||||
enrichQueuedOutboundUi(listOf(it), convId).firstOrNull()
|
||||
}
|
||||
val base = message?.let {
|
||||
buildChatListPreviewState(it, strings, ApiClient.user?.id).let { state ->
|
||||
state.copy(text = state.text?.trim()?.takeIf { text -> text.isNotEmpty() })
|
||||
}
|
||||
} ?: ChatListPreviewState(text = null)
|
||||
withPublicUnread(base, iid, convId)
|
||||
}
|
||||
|
||||
suspend fun replacePublicMessages(messages: List<Message>, replaceAll: Boolean = false) {
|
||||
@@ -497,10 +499,6 @@ object MessageCacheStore {
|
||||
else -> conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: conv.user.username.trim()
|
||||
}
|
||||
val localUnread = db.messageDatabaseQueries
|
||||
.countUnreadInboundDmMessages(iid, conversationId, conv.user.id.toLong())
|
||||
.executeAsOne()
|
||||
.toInt()
|
||||
UpsertDmConversationRow(
|
||||
conversationId = conversationId,
|
||||
otherUserId = conv.user.id,
|
||||
@@ -511,7 +509,12 @@ object MessageCacheStore {
|
||||
currentUserId,
|
||||
previewStrings,
|
||||
),
|
||||
unreadCount = maxOf(conv.unreadCount, localUnread),
|
||||
unreadCount = localDmUnreadCount(
|
||||
iid,
|
||||
conversationId,
|
||||
conv.user.id,
|
||||
conv.unreadCount,
|
||||
),
|
||||
updatedAt = conv.lastMessage.timestamp,
|
||||
)
|
||||
}
|
||||
@@ -697,23 +700,20 @@ object MessageCacheStore {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markDmConversationReadLocally(otherUserId: Int, upToEnvelopeId: Int? = null) {
|
||||
suspend fun markDmConversationReadLocally(otherUserId: Int, messageIds: List<Int>? = null) {
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForDm(otherUserId)
|
||||
withContext(Dispatchers.Default) {
|
||||
if (upToEnvelopeId != null && upToEnvelopeId > 0) {
|
||||
db.messageDatabaseQueries.markInboundDmMessagesReadUpTo(
|
||||
instanceId = iid,
|
||||
conversationId = convId,
|
||||
userId = otherUserId.toLong(),
|
||||
id = upToEnvelopeId.toLong(),
|
||||
)
|
||||
} else {
|
||||
if (messageIds.isNullOrEmpty()) {
|
||||
db.messageDatabaseQueries.markAllInboundDmMessagesRead(
|
||||
instanceId = iid,
|
||||
conversationId = convId,
|
||||
userId = otherUserId.toLong(),
|
||||
)
|
||||
} else {
|
||||
messageIds.filter { it > 0 }.forEach { id ->
|
||||
db.messageDatabaseQueries.markPublicMessageRead(iid, convId, id.toLong())
|
||||
}
|
||||
}
|
||||
val unreadCount = db.messageDatabaseQueries
|
||||
.countUnreadInboundDmMessages(iid, convId, otherUserId.toLong())
|
||||
@@ -738,11 +738,29 @@ object MessageCacheStore {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markPublicMessagesReadLocally() {
|
||||
suspend fun isPublicMessageRead(messageId: Int): Boolean {
|
||||
if (messageId <= 0) return true
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForPublic()
|
||||
return withContext(Dispatchers.Default) {
|
||||
db.messageDatabaseQueries
|
||||
.selectMessageById(iid, convId, messageId.toLong())
|
||||
.executeAsOneOrNull()
|
||||
?.isRead == 1L
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markPublicMessagesReadLocally(messageIds: List<Int>? = null) {
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForPublic()
|
||||
withContext(Dispatchers.Default) {
|
||||
db.messageDatabaseQueries.markPublicMessagesRead(iid, convId)
|
||||
if (messageIds == null) {
|
||||
db.messageDatabaseQueries.markPublicMessagesRead(iid, convId)
|
||||
} else {
|
||||
messageIds.filter { it > 0 }.forEach { id ->
|
||||
db.messageDatabaseQueries.markPublicMessageRead(iid, convId, id.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -860,7 +878,12 @@ object MessageCacheStore {
|
||||
lastMessagePendingIndicator = previewState?.pendingIndicator
|
||||
?: ChatListPreviewPendingIndicator.None,
|
||||
lastMessageUploadProgress = previewState?.uploadProgress,
|
||||
unreadCount = row.unreadCount.toInt(),
|
||||
unreadCount = localDmUnreadCount(
|
||||
instanceId,
|
||||
row.id,
|
||||
row.otherUserId?.toInt() ?: 0,
|
||||
row.unreadCount.toInt(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -931,6 +954,37 @@ object MessageCacheStore {
|
||||
}
|
||||
}
|
||||
|
||||
private fun withPublicUnread(
|
||||
state: ChatListPreviewState,
|
||||
instanceId: String,
|
||||
conversationId: String,
|
||||
): ChatListPreviewState {
|
||||
val selfId = ApiClient.user?.id?.toLong() ?: return state
|
||||
return state.copy(
|
||||
unreadCount = db.messageDatabaseQueries
|
||||
.countUnreadPublicInboundMessages(instanceId, conversationId, selfId)
|
||||
.executeAsOne()
|
||||
.toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun localDmUnreadCount(
|
||||
instanceId: String,
|
||||
conversationId: String,
|
||||
otherUserId: Int,
|
||||
serverUnread: Int,
|
||||
): Int {
|
||||
if (otherUserId <= 0) return serverUnread.coerceAtLeast(0)
|
||||
val inbound = db.messageDatabaseQueries
|
||||
.countInboundDmMessages(instanceId, conversationId, otherUserId.toLong())
|
||||
.executeAsOne()
|
||||
if (inbound <= 0L) return serverUnread.coerceAtLeast(0)
|
||||
return db.messageDatabaseQueries
|
||||
.countUnreadInboundDmMessages(instanceId, conversationId, otherUserId.toLong())
|
||||
.executeAsOne()
|
||||
.toInt()
|
||||
}
|
||||
|
||||
private fun resolvePreviewSourceMessageRow(
|
||||
instanceId: String,
|
||||
conversationId: String,
|
||||
@@ -1061,10 +1115,9 @@ object MessageCacheStore {
|
||||
private suspend fun upsertSingle(conversationId: String, msg: Message) {
|
||||
val iid = instanceId()
|
||||
withContext(Dispatchers.Default) {
|
||||
val existingReplyToId = db.messageDatabaseQueries
|
||||
val existing = db.messageDatabaseQueries
|
||||
.selectMessageById(iid, conversationId, msg.id.toLong())
|
||||
.executeAsOneOrNull()
|
||||
?.replyToId
|
||||
db.messageDatabaseQueries.upsertMessage(
|
||||
instanceId = iid,
|
||||
id = msg.id.toLong(),
|
||||
@@ -1072,9 +1125,9 @@ object MessageCacheStore {
|
||||
userId = msg.user_id.toLong(),
|
||||
content = storedMessageContent(msg),
|
||||
timestamp = msg.timestamp,
|
||||
isRead = if (msg.is_read) 1L else 0L,
|
||||
isRead = mergedIsRead(existing?.isRead, msg.is_read),
|
||||
isEdited = if (msg.is_edited) 1L else 0L,
|
||||
replyToId = resolveReplyToIdForPersistence(msg, existingReplyToId),
|
||||
replyToId = resolveReplyToIdForPersistence(msg, existing?.replyToId),
|
||||
clientMessageId = msg.client_message_id,
|
||||
deletedFlag = 0L,
|
||||
sendStatus = if (msg.id < 0) "pending" else "sent"
|
||||
@@ -1092,11 +1145,10 @@ object MessageCacheStore {
|
||||
}
|
||||
withContext(Dispatchers.Default) {
|
||||
db.messageDatabaseQueries.transaction {
|
||||
val existingReplyToId = db.messageDatabaseQueries
|
||||
val existing = db.messageDatabaseQueries
|
||||
.selectMessagesByConversation(iid, conversationId)
|
||||
.executeAsList()
|
||||
.firstOrNull { it.clientMessageId == clientMessageId }
|
||||
?.replyToId
|
||||
db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId)
|
||||
db.messageDatabaseQueries.upsertMessage(
|
||||
instanceId = iid,
|
||||
@@ -1105,9 +1157,9 @@ object MessageCacheStore {
|
||||
userId = confirmed.user_id.toLong(),
|
||||
content = storedContent,
|
||||
timestamp = confirmed.timestamp,
|
||||
isRead = if (confirmed.is_read) 1L else 0L,
|
||||
isRead = mergedIsRead(existing?.isRead, confirmed.is_read),
|
||||
isEdited = if (confirmed.is_edited) 1L else 0L,
|
||||
replyToId = resolveReplyToIdForPersistence(confirmed, existingReplyToId),
|
||||
replyToId = resolveReplyToIdForPersistence(confirmed, existing?.replyToId),
|
||||
clientMessageId = confirmed.client_message_id,
|
||||
deletedFlag = 0L,
|
||||
sendStatus = "sent"
|
||||
@@ -1331,6 +1383,9 @@ object MessageCacheStore {
|
||||
?: existingReplyToId?.takeIf { it > 0L }
|
||||
}
|
||||
|
||||
private fun mergedIsRead(existingIsRead: Long?, incomingIsRead: Boolean): Long =
|
||||
if (existingIsRead == 1L || incomingIsRead) 1L else 0L
|
||||
|
||||
private fun DbMessage.toAppMessage(): Message {
|
||||
val uid = userId.toInt()
|
||||
val self = ApiClient.user
|
||||
@@ -1464,10 +1519,13 @@ object MessageCacheStore {
|
||||
"incoming=${messages.size} validated=${validated.size} rowsBefore=$beforeCount",
|
||||
)
|
||||
withContext(Dispatchers.Default) {
|
||||
val existingReplyToIds = db.messageDatabaseQueries
|
||||
val existingRows = db.messageDatabaseQueries
|
||||
.selectMessagesByConversation(iid, conversationId)
|
||||
.executeAsList()
|
||||
.associate { it.id.toInt() to it.replyToId }
|
||||
val existingReplyToIds = existingRows.associate { it.id.toInt() to it.replyToId }
|
||||
val existingReadIds = existingRows.mapNotNull { row ->
|
||||
row.id.toInt().takeIf { row.isRead == 1L }
|
||||
}.toSet()
|
||||
if (replaceAll) {
|
||||
db.messageDatabaseQueries.transaction {
|
||||
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
|
||||
@@ -1479,7 +1537,10 @@ object MessageCacheStore {
|
||||
userId = msg.user_id.toLong(),
|
||||
content = storedMessageContent(msg),
|
||||
timestamp = msg.timestamp,
|
||||
isRead = if (msg.is_read) 1L else 0L,
|
||||
isRead = mergedIsRead(
|
||||
if (msg.id in existingReadIds) 1L else 0L,
|
||||
msg.is_read,
|
||||
),
|
||||
isEdited = if (msg.is_edited) 1L else 0L,
|
||||
replyToId = resolveReplyToIdForPersistence(msg, existingReplyToIds[msg.id]),
|
||||
clientMessageId = msg.client_message_id,
|
||||
|
||||
+83
-21
@@ -2,6 +2,8 @@ package ru.fromchat.api.local.db.store
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.messages.ChatListPreviewState
|
||||
import ru.fromchat.api.local.messages.ChatListPreviewStrings
|
||||
@@ -11,11 +13,16 @@ import ru.fromchat.api.local.messages.conversationIdForGroup
|
||||
import ru.fromchat.api.schema.messages.Message
|
||||
import ru.fromchat.api.schema.messages.dm.DmConversation
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.notifications.ChatNotificationDismissals
|
||||
|
||||
/**
|
||||
* Instance-scoped message access for UI and send pipeline.
|
||||
*/
|
||||
object MessageRepository {
|
||||
private val visibleReadMutex = Mutex()
|
||||
private val sentPublicReadIds = mutableSetOf<Int>()
|
||||
private val sentDmReadIds = mutableSetOf<Int>()
|
||||
|
||||
private fun activeInstance(): String = CacheContext.requireActiveInstanceId()
|
||||
|
||||
fun observeMessages(conversationId: String): Flow<List<Message>> =
|
||||
@@ -139,31 +146,84 @@ object MessageRepository {
|
||||
suspend fun patchDmConversationPeerProfile(otherUserId: Int) =
|
||||
MessageCacheStore.patchDmConversationPeerProfile(otherUserId)
|
||||
|
||||
suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) {
|
||||
runCatching { ApiClient.markDmConversationRead(otherUserId, upToEnvelopeId) }
|
||||
MessageCacheStore.markDmConversationReadLocally(otherUserId, upToEnvelopeId)
|
||||
ru.fromchat.notifications.ChatNotificationDismissals.dismissAllMessageNotifications()
|
||||
}
|
||||
|
||||
suspend fun markDmConversationReadUpTo(otherUserId: Int, upToEnvelopeId: Int) {
|
||||
if (upToEnvelopeId <= 0) return
|
||||
val convId = conversationIdForDm(otherUserId)
|
||||
val alreadyRead = MessageCacheStore.isInboundDmMessageRead(otherUserId, upToEnvelopeId)
|
||||
if (alreadyRead) return
|
||||
markDmConversationRead(otherUserId, upToEnvelopeId)
|
||||
suspend fun markDmConversationRead(otherUserId: Int, messageIds: List<Int>? = null) {
|
||||
runCatching {
|
||||
ApiClient.markDmConversationRead(
|
||||
otherUserId,
|
||||
messageIds = messageIds,
|
||||
markAll = messageIds.isNullOrEmpty(),
|
||||
)
|
||||
}
|
||||
MessageCacheStore.markDmConversationReadLocally(otherUserId, messageIds)
|
||||
if (!messageIds.isNullOrEmpty()) {
|
||||
sentDmReadIds.addAll(messageIds.filter { it > 0 })
|
||||
}
|
||||
ChatNotificationDismissals.dismissAllMessageNotifications()
|
||||
}
|
||||
|
||||
suspend fun markPublicConversationRead() {
|
||||
val localIds = MessageCacheStore.selectUnreadPublicMessageIds()
|
||||
val serverIds = runCatching {
|
||||
ApiClient.getNewMessages().messages.map { it.id }
|
||||
}.getOrDefault(emptyList())
|
||||
val ids = (localIds + serverIds).distinct()
|
||||
if (ids.isNotEmpty()) {
|
||||
runCatching { ApiClient.markMessagesRead(ids) }
|
||||
}
|
||||
runCatching { ApiClient.markMessagesRead(markAll = true) }
|
||||
MessageCacheStore.markPublicMessagesReadLocally()
|
||||
ru.fromchat.notifications.ChatNotificationDismissals.dismissAllMessageNotifications()
|
||||
ChatNotificationDismissals.dismissAllMessageNotifications()
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks messages that are on-screen as read and reports those ids to the server once.
|
||||
* Off-screen unread messages in the same conversation are left unread.
|
||||
*/
|
||||
suspend fun markVisibleMessagesRead(
|
||||
peerUserId: Int?,
|
||||
visibleMessageIds: Set<Int>,
|
||||
currentUserId: Int?,
|
||||
messages: List<Message>,
|
||||
) {
|
||||
if (visibleMessageIds.isEmpty()) return
|
||||
visibleReadMutex.withLock {
|
||||
val ownId = currentUserId
|
||||
val visibleInboundIds = messages
|
||||
.filter { message ->
|
||||
message.id > 0 &&
|
||||
message.id in visibleMessageIds &&
|
||||
(ownId == null || message.user_id != ownId) &&
|
||||
(peerUserId == null || message.user_id == peerUserId)
|
||||
}
|
||||
.map { it.id }
|
||||
if (peerUserId != null && peerUserId > 0) {
|
||||
markVisibleDmRead(peerUserId, visibleInboundIds)
|
||||
} else {
|
||||
markVisiblePublicRead(visibleInboundIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun markVisibleDmRead(otherUserId: Int, messageIds: List<Int>) {
|
||||
val toSend = messageIds.filter { it > 0 }.distinct().filter { id ->
|
||||
id !in sentDmReadIds && !MessageCacheStore.isInboundDmMessageRead(otherUserId, id)
|
||||
}
|
||||
if (toSend.isEmpty()) return
|
||||
sentDmReadIds.addAll(toSend)
|
||||
runCatching { ApiClient.markDmConversationRead(otherUserId, messageIds = toSend) }
|
||||
.onSuccess {
|
||||
MessageCacheStore.markDmConversationReadLocally(otherUserId, toSend)
|
||||
ChatNotificationDismissals.dismissDmIfMessageRead(otherUserId, toSend)
|
||||
}
|
||||
.onFailure { sentDmReadIds.removeAll(toSend.toSet()) }
|
||||
}
|
||||
|
||||
private suspend fun markVisiblePublicRead(messageIds: List<Int>) {
|
||||
val distinct = messageIds.filter { it > 0 }.distinct()
|
||||
if (distinct.isEmpty()) return
|
||||
val toSend = distinct.filter { id ->
|
||||
id !in sentPublicReadIds && !MessageCacheStore.isPublicMessageRead(id)
|
||||
}
|
||||
if (toSend.isEmpty()) return
|
||||
sentPublicReadIds.addAll(toSend)
|
||||
runCatching { ApiClient.markMessagesRead(toSend) }
|
||||
.onSuccess {
|
||||
MessageCacheStore.markPublicMessagesReadLocally(toSend)
|
||||
ChatNotificationDismissals.dismissPublicIfMessageRead(toSend)
|
||||
}
|
||||
.onFailure { sentPublicReadIds.removeAll(toSend.toSet()) }
|
||||
}
|
||||
|
||||
suspend fun archiveDmConversation(otherUserId: Int) =
|
||||
@@ -185,5 +245,7 @@ object MessageRepository {
|
||||
|
||||
fun resetListPreviewStringsOnLogout() {
|
||||
MessageCacheStore.listPreviewStrings = null
|
||||
sentPublicReadIds.clear()
|
||||
sentDmReadIds.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ data class ChatListPreviewState(
|
||||
val text: String?,
|
||||
val pendingIndicator: ChatListPreviewPendingIndicator = ChatListPreviewPendingIndicator.None,
|
||||
val uploadProgress: Int? = null,
|
||||
val unreadCount: Int = 0,
|
||||
) {
|
||||
fun displayText(default: String): String =
|
||||
text?.trim()?.takeIf { it.isNotEmpty() } ?: default
|
||||
|
||||
+2
-5
@@ -8,7 +8,6 @@ import ru.fromchat.api.crypto.decryptEnvelope
|
||||
import ru.fromchat.api.local.db.parseDmMessageContent
|
||||
import ru.fromchat.api.local.db.store.MessageRepository
|
||||
import ru.fromchat.api.local.db.store.ProfileCache
|
||||
import ru.fromchat.api.local.messages.ActiveDmChatTracker
|
||||
import ru.fromchat.api.schema.messages.Message
|
||||
import ru.fromchat.api.schema.messages.dm.DmEnvelope
|
||||
import ru.fromchat.api.schema.websocket.types.DmDeletedData
|
||||
@@ -54,9 +53,7 @@ object DmInboundMessageProcessor {
|
||||
MessageRepository.upsertDmMessage(otherUserId, hydrated)
|
||||
}
|
||||
} else {
|
||||
val isRead = ActiveDmChatTracker.isActive(otherUserId)
|
||||
val inbound = hydrated.copy(is_read = isRead)
|
||||
MessageRepository.upsertDmMessage(otherUserId, inbound)
|
||||
MessageRepository.upsertDmMessage(otherUserId, hydrated)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,7 +173,7 @@ object DmInboundMessageProcessor {
|
||||
user_id = envelope.senderId,
|
||||
content = dec.text,
|
||||
timestamp = envelope.timestamp,
|
||||
is_read = envelope.senderId == currentUserId,
|
||||
is_read = envelope.isReadByViewer(currentUserId),
|
||||
is_edited = false,
|
||||
username = username,
|
||||
displayName = displayName,
|
||||
|
||||
@@ -4,5 +4,6 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MarkReadRequest(
|
||||
val messageIds: List<Int>,
|
||||
val messageIds: List<Int> = emptyList(),
|
||||
val markAll: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -16,5 +16,9 @@ data class DmEnvelope(
|
||||
val timestamp: String,
|
||||
@SerialName("client_message_id") val clientMessageId: String? = null,
|
||||
@SerialName("reply_to_id") val replyToId: Int? = null,
|
||||
val files: List<DmFile>? = null
|
||||
)
|
||||
val files: List<DmFile>? = null,
|
||||
val isRead: Boolean? = null,
|
||||
) {
|
||||
fun isReadByViewer(currentUserId: Int?): Boolean =
|
||||
(currentUserId != null && senderId == currentUserId) || isRead == true
|
||||
}
|
||||
+2
-1
@@ -4,5 +4,6 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class DmMarkReadRequest(
|
||||
val upToEnvelopeId: Int? = null,
|
||||
val messageIds: List<Int>? = null,
|
||||
val markAll: Boolean = false,
|
||||
)
|
||||
|
||||
+9
-3
@@ -1,6 +1,12 @@
|
||||
package ru.fromchat.notifications
|
||||
|
||||
/** Platform bridge so shared mark-read can dismiss message notifications. */
|
||||
expect object ChatNotificationDismissals {
|
||||
fun dismissAllMessageNotifications()
|
||||
/** Shared mark-read calls this so every platform can drop message notifications. */
|
||||
object ChatNotificationDismissals {
|
||||
fun dismissAllMessageNotifications() = MessageNotificationCoordinator.dismissAll()
|
||||
|
||||
fun dismissPublicIfMessageRead(messageIds: Collection<Int>) =
|
||||
MessageNotificationCoordinator.dismissPublicIfMessageRead(messageIds)
|
||||
|
||||
fun dismissDmIfMessageRead(peerUserId: Int, messageIds: Collection<Int>) =
|
||||
MessageNotificationCoordinator.dismissDmIfMessageRead(peerUserId, messageIds)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package ru.fromchat.notifications
|
||||
|
||||
/** Platform hook when an incoming call arrives while the app may be in the background. */
|
||||
internal expect fun notifyIncomingCallIfBackground(callerDisplayName: String)
|
||||
+508
@@ -0,0 +1,508 @@
|
||||
package ru.fromchat.notifications
|
||||
|
||||
import com.pr0gramm3r101.utils.settings.settings
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder
|
||||
import ru.fromchat.api.crypto.DmCiphertextCorruptedException
|
||||
import ru.fromchat.api.crypto.decryptEnvelope
|
||||
import ru.fromchat.api.local.WebSocketManager
|
||||
import ru.fromchat.api.local.db.store.ProfileCache
|
||||
import ru.fromchat.api.local.db.store.visibleDisplayName
|
||||
import ru.fromchat.api.local.messages.ChatListPreviewStrings
|
||||
import ru.fromchat.api.local.messages.buildChatListPreview
|
||||
import ru.fromchat.api.local.messages.buildChatListPreviewFromEnvelope
|
||||
import ru.fromchat.api.schema.messages.Message
|
||||
import ru.fromchat.api.schema.messages.dm.DmEnvelope
|
||||
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
||||
import ru.fromchat.api.schema.websocket.types.DmDeletedData
|
||||
import ru.fromchat.api.schema.websocket.types.MessageDeletedData
|
||||
import ru.fromchat.chat_preview_attachment
|
||||
import ru.fromchat.chat_preview_image
|
||||
import ru.fromchat.chat_preview_image_emoji
|
||||
|
||||
private const val TAG = "MessageNotificationCoordinator"
|
||||
private const val PREF_SHOWN_KEY = "shown_message_ids"
|
||||
private const val PREF_SHOWN_DM_KEY = "shown_dm_message_ids"
|
||||
private const val PREF_LAST_DM_MESSAGE_ID = "last_dm_message_id"
|
||||
private const val PUBLIC_FETCH_DEBOUNCE_MS = 450L
|
||||
private const val BANNER_STAGGER_MS = 350L
|
||||
|
||||
/**
|
||||
* Shared WebSocket / fetch pipeline for message notifications.
|
||||
* Platforms only implement [MessageNotificationSink].
|
||||
*/
|
||||
object MessageNotificationCoordinator {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val publicFetchMutex = Mutex()
|
||||
private val activeMutex = Mutex()
|
||||
private val displayedMessageIds = mutableMapOf<String, Int>()
|
||||
private val recentlyDeleted = ArrayDeque<String>()
|
||||
private var publicFetchJob: Job? = null
|
||||
private var installed = false
|
||||
|
||||
private val webSocketHandler: (WebSocketMessage) -> Unit = { msg ->
|
||||
scope.launch {
|
||||
runCatching { handleWebSocketMessage(msg) }
|
||||
.onFailure { Logger.e(TAG, "WebSocket handler failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun install() {
|
||||
if (installed) {
|
||||
Logger.i(TAG, "install skipped: already installed")
|
||||
return
|
||||
}
|
||||
installed = true
|
||||
WebSocketManager.addGlobalMessageHandler(webSocketHandler)
|
||||
MessageNotificationSink.dismiss(PUBLIC_NOTIFICATION_SLOT)
|
||||
Logger.i(TAG, "installed enabled=${MessageNotificationSink.areEnabled()}")
|
||||
}
|
||||
|
||||
fun refreshChrome() = MessageNotificationSink.refreshChrome()
|
||||
|
||||
fun uninstall() {
|
||||
if (!installed) return
|
||||
installed = false
|
||||
WebSocketManager.removeGlobalMessageHandler(webSocketHandler)
|
||||
publicFetchJob?.cancel()
|
||||
Logger.i(TAG, "uninstalled")
|
||||
}
|
||||
|
||||
fun dismissAll() {
|
||||
scope.launch {
|
||||
activeMutex.withLock {
|
||||
displayedMessageIds.clear()
|
||||
}
|
||||
MessageNotificationSink.dismissAll()
|
||||
MessageNotificationSink.refreshChrome()
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissPublicIfMessageRead(messageIds: Collection<Int>) {
|
||||
if (messageIds.isEmpty()) return
|
||||
scope.launch {
|
||||
messageIds.forEach { dismiss(publicNotificationId(it)) }
|
||||
val leftover = activeMutex.withLock { displayedMessageIds[PUBLIC_NOTIFICATION_SLOT] }
|
||||
if (leftover != null && leftover in messageIds) {
|
||||
dismiss(PUBLIC_NOTIFICATION_SLOT)
|
||||
}
|
||||
MessageNotificationSink.refreshChrome()
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissDmIfMessageRead(peerUserId: Int, messageIds: Collection<Int>) {
|
||||
if (peerUserId <= 0 || messageIds.isEmpty()) return
|
||||
scope.launch {
|
||||
messageIds.forEach { dismiss(dmNotificationId(peerUserId, it)) }
|
||||
val slot = dmNotificationSlot(peerUserId)
|
||||
val leftover = activeMutex.withLock { displayedMessageIds[slot] }
|
||||
if (leftover != null && leftover in messageIds) {
|
||||
dismiss(slot)
|
||||
}
|
||||
MessageNotificationSink.refreshChrome()
|
||||
}
|
||||
}
|
||||
|
||||
fun schedulePublicFetchAndNotify() {
|
||||
Logger.i(TAG, "schedule public fetch in ${PUBLIC_FETCH_DEBOUNCE_MS}ms")
|
||||
publicFetchJob?.cancel()
|
||||
publicFetchJob = scope.launch {
|
||||
delay(PUBLIC_FETCH_DEBOUNCE_MS)
|
||||
publicFetchMutex.withLock {
|
||||
fetchAndNotify(includeDmMessages = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchAndNotify(
|
||||
includeDmMessages: Boolean = false,
|
||||
dmMessageId: Int? = null,
|
||||
) {
|
||||
if (!MessageNotificationSink.areEnabled()) {
|
||||
Logger.i(TAG, "fetchAndNotify skip: disabled")
|
||||
return
|
||||
}
|
||||
val currentUserId = settings.getInt("current_user_id", -1)
|
||||
if (currentUserId <= 0) {
|
||||
Logger.i(TAG, "fetchAndNotify skip: no current_user_id")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val fetched = ApiClient.getNewMessages().messages
|
||||
val messages = fetched.filter { it.user_id != currentUserId }
|
||||
Logger.i(
|
||||
TAG,
|
||||
"fetchAndNotify /messages/new count=${fetched.size} afterOwnFilter=${messages.size} " +
|
||||
"ids=${fetched.joinToString { "${it.id}/u${it.user_id}" }} " +
|
||||
"includeDm=$includeDmMessages dmMessageId=$dmMessageId",
|
||||
)
|
||||
if (messages.isNotEmpty()) {
|
||||
displayPublicNotifications(messages, currentUserId)
|
||||
}
|
||||
if (includeDmMessages) {
|
||||
fetchAndNotifyDirectMessages(currentUserId, dmMessageId)
|
||||
}
|
||||
MessageNotificationSink.refreshChrome()
|
||||
} catch (e: Exception) {
|
||||
if (e is ClientRequestException && e.response.status.value == 401) {
|
||||
runCatching {
|
||||
ApiClient.loadPersistedData()
|
||||
val retryUserId = settings.getInt("current_user_id", -1)
|
||||
val retryMessages = ApiClient.getNewMessages()
|
||||
.messages
|
||||
.filter { it.user_id != retryUserId }
|
||||
if (retryMessages.isNotEmpty()) {
|
||||
displayPublicNotifications(retryMessages, retryUserId)
|
||||
}
|
||||
if (includeDmMessages) {
|
||||
fetchAndNotifyDirectMessages(retryUserId, dmMessageId)
|
||||
}
|
||||
MessageNotificationSink.refreshChrome()
|
||||
}.onFailure {
|
||||
Logger.e(TAG, "fetchAndNotify retry failed", it)
|
||||
}
|
||||
return
|
||||
}
|
||||
Logger.e(TAG, "fetchAndNotify failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleWebSocketMessage(msg: WebSocketMessage) {
|
||||
val enabled = MessageNotificationSink.areEnabled()
|
||||
val currentUserId = settings.getInt("current_user_id", -1)
|
||||
val data = msg.data?.jsonObject
|
||||
if (!enabled) return
|
||||
if (currentUserId <= 0) return
|
||||
|
||||
fun isOwnPublic(payload: JsonObject?) = jsonInt(payload, "user_id") == currentUserId
|
||||
fun isOwnDm(payload: JsonObject?) = jsonInt(payload, "senderId") == currentUserId
|
||||
|
||||
when (msg.type) {
|
||||
"newMessage" -> {
|
||||
if (!isOwnPublic(data)) schedulePublicFetchAndNotify()
|
||||
}
|
||||
"dmNew" -> {
|
||||
if (!isOwnDm(data)) {
|
||||
fetchAndNotify(includeDmMessages = true, dmMessageId = jsonInt(data, "id"))
|
||||
}
|
||||
}
|
||||
"messageEdited" -> msg.data?.let { handlePublicEdited(it, currentUserId) }
|
||||
"messageDeleted" -> msg.data?.let { handlePublicDeleted(it) }
|
||||
"dmEdited" -> msg.data?.let { handleDmEdited(it, currentUserId) }
|
||||
"dmDeleted" -> msg.data?.let { handleDmDeleted(it) }
|
||||
"updates" -> handleUpdates(data, currentUserId, ::isOwnPublic, ::isOwnDm)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleUpdates(
|
||||
data: JsonObject?,
|
||||
currentUserId: Int,
|
||||
isOwnPublic: (JsonObject?) -> Boolean,
|
||||
isOwnDm: (JsonObject?) -> Boolean,
|
||||
) {
|
||||
val updates = data?.get("updates")?.jsonArray ?: return
|
||||
var shouldFetchPublic = false
|
||||
var shouldFetchDm = false
|
||||
var latestDmMessageId: Int? = null
|
||||
|
||||
for (item in updates) {
|
||||
val obj = item.jsonObject
|
||||
val type = obj["type"]?.jsonPrimitive?.content
|
||||
val payload = obj["data"]?.jsonObject
|
||||
when (type) {
|
||||
"newMessage" -> if (!isOwnPublic(payload)) shouldFetchPublic = true
|
||||
"dmNew" -> if (!isOwnDm(payload)) {
|
||||
shouldFetchDm = true
|
||||
jsonInt(payload, "id")?.let { id ->
|
||||
latestDmMessageId = id.coerceAtLeast(latestDmMessageId ?: 0)
|
||||
}
|
||||
}
|
||||
"messageEdited" -> payload?.let { handlePublicEdited(it, currentUserId) }
|
||||
"messageDeleted" -> payload?.let { handlePublicDeleted(it) }
|
||||
"dmEdited" -> payload?.let { handleDmEdited(it, currentUserId) }
|
||||
"dmDeleted" -> payload?.let { handleDmDeleted(it) }
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldFetchPublic) schedulePublicFetchAndNotify()
|
||||
if (shouldFetchDm) {
|
||||
fetchAndNotify(includeDmMessages = true, dmMessageId = latestDmMessageId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handlePublicEdited(element: JsonElement, currentUserId: Int) {
|
||||
val message = decodeMessage(element) ?: return
|
||||
val identifier = publicNotificationId(message.id)
|
||||
if (!isDisplaying(identifier, message.id) &&
|
||||
!isDisplaying(PUBLIC_NOTIFICATION_SLOT, message.id)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (MessageNotificationSink.shouldSuppressPublic()) {
|
||||
dismiss(identifier)
|
||||
dismiss(PUBLIC_NOTIFICATION_SLOT)
|
||||
return
|
||||
}
|
||||
present(
|
||||
publicNotification(message, currentUserId, previewStrings(), isUpdate = true),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun handlePublicDeleted(element: JsonElement) {
|
||||
val deleted = runCatching {
|
||||
ApiClient.json.decodeFromJsonElement(MessageDeletedData.serializer(), element)
|
||||
}.getOrNull() ?: return
|
||||
rememberDeleted("public:${deleted.message_id}")
|
||||
dismiss(publicNotificationId(deleted.message_id))
|
||||
if (isDisplaying(PUBLIC_NOTIFICATION_SLOT, deleted.message_id)) {
|
||||
dismiss(PUBLIC_NOTIFICATION_SLOT)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleDmEdited(element: JsonElement, currentUserId: Int) {
|
||||
val envelope = decodeDmEnvelope(element) ?: return
|
||||
val identifier = dmNotificationId(envelope.senderId, envelope.id)
|
||||
val slot = dmNotificationSlot(envelope.senderId)
|
||||
if (!isDisplaying(identifier, envelope.id) && !isDisplaying(slot, envelope.id)) return
|
||||
if (MessageNotificationSink.shouldSuppressDm(envelope.senderId)) {
|
||||
dismiss(identifier)
|
||||
dismiss(slot)
|
||||
return
|
||||
}
|
||||
present(dmNotification(envelope, currentUserId, previewStrings(), isUpdate = true) ?: return)
|
||||
}
|
||||
|
||||
private suspend fun handleDmDeleted(element: JsonElement) {
|
||||
val deleted = runCatching {
|
||||
ApiClient.json.decodeFromJsonElement(DmDeletedData.serializer(), element)
|
||||
}.getOrNull() ?: return
|
||||
rememberDeleted("dm:${deleted.id}")
|
||||
val peerId = deleted.senderId
|
||||
dismiss(dmNotificationId(peerId, deleted.id))
|
||||
val slot = dmNotificationSlot(peerId)
|
||||
if (isDisplaying(slot, deleted.id)) {
|
||||
dismiss(slot)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun displayPublicNotifications(messages: List<Message>, currentUserId: Int) {
|
||||
if (MessageNotificationSink.shouldSuppressPublic()) {
|
||||
Logger.i(TAG, "displayPublic skip: suppress incomingIds=${messages.map { it.id }}")
|
||||
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
|
||||
messages.forEach { shown.add(it.id.toString()) }
|
||||
settings.putStringSet(PREF_SHOWN_KEY, shown)
|
||||
return
|
||||
}
|
||||
|
||||
val previewStrings = previewStrings()
|
||||
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
|
||||
val newMessages = messages
|
||||
.filter { msg ->
|
||||
!shown.contains(msg.id.toString()) &&
|
||||
msg.user_id != currentUserId &&
|
||||
"public:${msg.id}" !in recentlyDeleted
|
||||
}
|
||||
.sortedBy { it.id }
|
||||
if (newMessages.isEmpty()) {
|
||||
Logger.i(TAG, "displayPublic skip: already shown, own, or deleted")
|
||||
return
|
||||
}
|
||||
|
||||
newMessages.forEach { shown.add(it.id.toString()) }
|
||||
settings.putStringSet(PREF_SHOWN_KEY, shown)
|
||||
presentAll(
|
||||
newMessages.map { message ->
|
||||
publicNotification(message, currentUserId, previewStrings, isUpdate = false)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun fetchAndNotifyDirectMessages(currentUserId: Int, dmMessageId: Int? = null) {
|
||||
val storedLastDmMessageId = settings.getInt(PREF_LAST_DM_MESSAGE_ID, 0)
|
||||
val sinceId = when {
|
||||
dmMessageId != null && dmMessageId > storedLastDmMessageId -> dmMessageId - 1
|
||||
storedLastDmMessageId > 0 -> storedLastDmMessageId
|
||||
else -> null
|
||||
} ?: return
|
||||
|
||||
val response = runCatching {
|
||||
ApiClient.getDmFetch(sinceId)
|
||||
}.getOrElse { throwable ->
|
||||
if (throwable is ClientRequestException && throwable.response.status.value == 401) {
|
||||
throw throwable
|
||||
}
|
||||
Logger.e(TAG, "DM fetch failed: ${throwable.message}", throwable)
|
||||
return
|
||||
}
|
||||
|
||||
val dmMessages = response.messages
|
||||
if (dmMessages.isEmpty()) return
|
||||
|
||||
val previewStrings = previewStrings()
|
||||
val shownDm = settings.getStringSet(PREF_SHOWN_DM_KEY, emptySet()).toMutableSet()
|
||||
val latestMessageId = settings.getInt(PREF_LAST_DM_MESSAGE_ID, 0)
|
||||
val toPresent = buildList {
|
||||
dmMessages
|
||||
.filter { envelope -> envelope.id > 0 && envelope.senderId != currentUserId }
|
||||
.forEach { envelope ->
|
||||
val shownDmKey = "dm:${envelope.id}"
|
||||
if (shownDm.contains(shownDmKey) || envelope.id <= latestMessageId) return@forEach
|
||||
if ("dm:${envelope.id}" in recentlyDeleted) return@forEach
|
||||
if (MessageNotificationSink.shouldSuppressDm(envelope.senderId)) {
|
||||
shownDm.add(shownDmKey)
|
||||
return@forEach
|
||||
}
|
||||
val notification = dmNotification(
|
||||
envelope,
|
||||
currentUserId,
|
||||
previewStrings,
|
||||
isUpdate = false,
|
||||
) ?: return@forEach
|
||||
shownDm.add(shownDmKey)
|
||||
add(notification)
|
||||
}
|
||||
}
|
||||
presentAll(toPresent)
|
||||
|
||||
val newMaxDmId = dmMessages.maxOfOrNull { it.id } ?: 0
|
||||
if (newMaxDmId > latestMessageId) {
|
||||
settings.putInt(PREF_LAST_DM_MESSAGE_ID, newMaxDmId)
|
||||
}
|
||||
settings.putStringSet(PREF_SHOWN_DM_KEY, shownDm)
|
||||
}
|
||||
|
||||
private suspend fun presentAll(notifications: List<PresentedMessageNotification>) {
|
||||
notifications.forEachIndexed { index, notification ->
|
||||
if (index > 0) delay(BANNER_STAGGER_MS)
|
||||
present(notification)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun present(notification: PresentedMessageNotification) {
|
||||
MessageNotificationSink.present(notification)
|
||||
activeMutex.withLock {
|
||||
displayedMessageIds[notification.identifier] = notification.messageId
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun dismiss(identifier: String) {
|
||||
activeMutex.withLock {
|
||||
displayedMessageIds.remove(identifier)
|
||||
}
|
||||
MessageNotificationSink.dismiss(identifier)
|
||||
}
|
||||
|
||||
private suspend fun isDisplaying(identifier: String, messageId: Int): Boolean =
|
||||
activeMutex.withLock { displayedMessageIds[identifier] == messageId }
|
||||
|
||||
private fun rememberDeleted(identifier: String) {
|
||||
recentlyDeleted.addLast(identifier)
|
||||
while (recentlyDeleted.size > 50) recentlyDeleted.removeFirst()
|
||||
}
|
||||
|
||||
private fun publicNotification(
|
||||
message: Message,
|
||||
currentUserId: Int,
|
||||
previewStrings: ChatListPreviewStrings,
|
||||
isUpdate: Boolean,
|
||||
) = PresentedMessageNotification(
|
||||
identifier = publicNotificationId(message.id),
|
||||
messageId = message.id,
|
||||
isDirectMessage = false,
|
||||
peerUserId = null,
|
||||
senderName = senderDisplayLabel(message, currentUserId),
|
||||
body = buildChatListPreview(message, previewStrings)?.takeIf { it.isNotBlank() }
|
||||
?: message.content.trim(),
|
||||
isUpdate = isUpdate,
|
||||
launchTarget = NotificationLaunchTarget(
|
||||
startAtPublicChat = true,
|
||||
scrollToMessageId = message.id,
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun dmNotification(
|
||||
envelope: DmEnvelope,
|
||||
currentUserId: Int,
|
||||
previewStrings: ChatListPreviewStrings,
|
||||
isUpdate: Boolean,
|
||||
): PresentedMessageNotification? {
|
||||
if (envelope.senderId == currentUserId) return null
|
||||
val plaintext = runCatching {
|
||||
decryptEnvelope(envelope, currentUserId)
|
||||
}.getOrElse { throwable ->
|
||||
when (throwable) {
|
||||
is DmCiphertextCorruptedException -> CorruptedDmMessagePlaceholder
|
||||
else -> "Encrypted message"
|
||||
}
|
||||
}
|
||||
val senderName = envelope.senderDisplayName?.takeIf { it.isNotBlank() }
|
||||
?: ProfileCache.get(envelope.senderId)
|
||||
?.visibleDisplayName(currentUserId)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: envelope.senderUsername.orEmpty()
|
||||
val body = buildChatListPreviewFromEnvelope(
|
||||
envelope = envelope,
|
||||
decryptedPlaintext = plaintext,
|
||||
strings = previewStrings,
|
||||
)?.takeIf { it.isNotBlank() } ?: plaintext
|
||||
return PresentedMessageNotification(
|
||||
identifier = dmNotificationId(envelope.senderId, envelope.id),
|
||||
messageId = envelope.id,
|
||||
isDirectMessage = true,
|
||||
peerUserId = envelope.senderId,
|
||||
senderName = senderName,
|
||||
body = body,
|
||||
isUpdate = isUpdate,
|
||||
launchTarget = NotificationLaunchTarget(
|
||||
dmConversationUserId = envelope.senderId,
|
||||
scrollToMessageId = envelope.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun senderDisplayLabel(message: Message, currentUserId: Int): String {
|
||||
ProfileCache.get(message.user_id)
|
||||
?.visibleDisplayName(currentUserId)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { return it }
|
||||
message.displayName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
|
||||
return message.username.trim().ifBlank { "FromChat" }
|
||||
}
|
||||
|
||||
private suspend fun previewStrings(): ChatListPreviewStrings {
|
||||
val emoji = getString(Res.string.chat_preview_image_emoji)
|
||||
return ChatListPreviewStrings(
|
||||
imageEmoji = emoji,
|
||||
imageOnly = getString(Res.string.chat_preview_image, emoji),
|
||||
attachmentOnly = getString(Res.string.chat_preview_attachment),
|
||||
)
|
||||
}
|
||||
|
||||
private fun decodeMessage(element: JsonElement): Message? =
|
||||
runCatching { ApiClient.json.decodeFromJsonElement(Message.serializer(), element) }.getOrNull()
|
||||
|
||||
private fun decodeDmEnvelope(element: JsonElement): DmEnvelope? =
|
||||
runCatching { ApiClient.json.decodeFromJsonElement(DmEnvelope.serializer(), element) }.getOrNull()
|
||||
|
||||
private fun jsonInt(data: JsonObject?, key: String): Int? =
|
||||
runCatching { data?.get(key)?.jsonPrimitive?.content?.toIntOrNull() }.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package ru.fromchat.notifications
|
||||
|
||||
internal const val PUBLIC_NOTIFICATION_SLOT = "public"
|
||||
|
||||
internal fun publicNotificationId(messageId: Int) = "public:$messageId"
|
||||
|
||||
internal fun dmNotificationSlot(peerUserId: Int) = "dm:$peerUserId"
|
||||
|
||||
internal fun dmNotificationId(peerUserId: Int, messageId: Int) = "dm:$peerUserId:$messageId"
|
||||
|
||||
internal fun notificationAndroidId(identifier: String): Int {
|
||||
val hashed = identifier.hashCode() and 0x7FFFFFFF
|
||||
return if (hashed == 0) 1 else hashed
|
||||
}
|
||||
|
||||
internal data class PresentedMessageNotification(
|
||||
val identifier: String,
|
||||
val messageId: Int,
|
||||
val isDirectMessage: Boolean,
|
||||
val peerUserId: Int?,
|
||||
val senderName: String,
|
||||
val body: String,
|
||||
val isUpdate: Boolean,
|
||||
val launchTarget: NotificationLaunchTarget,
|
||||
) {
|
||||
val androidNotifyId: Int
|
||||
get() = notificationAndroidId(identifier)
|
||||
}
|
||||
|
||||
/** Platform notification UI. Shared code decides *when*; this decides *how*. */
|
||||
internal expect object MessageNotificationSink {
|
||||
fun areEnabled(): Boolean
|
||||
fun shouldSuppressPublic(): Boolean
|
||||
fun shouldSuppressDm(peerUserId: Int): Boolean
|
||||
suspend fun present(notification: PresentedMessageNotification)
|
||||
fun dismiss(identifier: String)
|
||||
fun dismissAll()
|
||||
fun refreshChrome()
|
||||
}
|
||||
@@ -312,6 +312,16 @@ fun App(
|
||||
}
|
||||
}
|
||||
}
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
if (!keepWebSocketAliveInBackground()) {
|
||||
AppForeground.setWindowFocused(true)
|
||||
}
|
||||
}
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
if (!keepWebSocketAliveInBackground()) {
|
||||
AppForeground.setWindowFocused(false)
|
||||
}
|
||||
}
|
||||
Lifecycle.Event.ON_STOP -> {
|
||||
if (!keepWebSocketAliveInBackground()) {
|
||||
AppForeground.setForeground(false)
|
||||
|
||||
@@ -71,6 +71,7 @@ import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -89,6 +90,7 @@ import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
@@ -100,6 +102,7 @@ import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.AppForeground
|
||||
import ru.fromchat.ui.main.ConversationDetailContentPadding
|
||||
import ru.fromchat.ui.main.LocalConversationListDetailActive
|
||||
import ru.fromchat.ui.main.detailPaneShowBackButton
|
||||
@@ -155,6 +158,7 @@ import ru.fromchat.ui.chat.utils.getImageDimensions
|
||||
import ru.fromchat.ui.chat.utils.imageAttachmentKey
|
||||
import ru.fromchat.ui.chat.utils.rememberAttachmentDropBridge
|
||||
import ru.fromchat.ui.chat.utils.visibleMessageIdsInChatList
|
||||
import ru.fromchat.ui.chat.utils.unobstructedVisibleMessageIdsInChatList
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.components.SuspendedAccountSupportSheet
|
||||
import ru.fromchat.ui.extraStatusBars
|
||||
@@ -581,9 +585,42 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val appWindowFocused by AppForeground.isWindowFocused.collectAsState()
|
||||
val windowFocused = LocalWindowInfo.current.isWindowFocused && appWindowFocused
|
||||
LaunchedEffect(listState, panel, windowFocused, currentUserId) {
|
||||
if (!windowFocused) return@LaunchedEffect
|
||||
var pending: Job? = null
|
||||
snapshotFlow {
|
||||
val (topClearancePx, bottomClearancePx) = chatScrollClearancePx.value
|
||||
unobstructedVisibleMessageIdsInChatList(
|
||||
listState = listState,
|
||||
messages = panelState.messages,
|
||||
topClearancePx = topClearancePx,
|
||||
bottomClearancePx = bottomClearancePx,
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { ids ->
|
||||
pending?.cancel()
|
||||
if (ids.isEmpty()) return@collect
|
||||
pending = launch {
|
||||
delay(150)
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageRepository.markVisibleMessagesRead(
|
||||
peerUserId = panel.getRecipientId(),
|
||||
visibleMessageIds = ids,
|
||||
currentUserId = currentUserId,
|
||||
messages = panel.getState().messages,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect WebSocket messages
|
||||
LaunchedEffect(Unit) {
|
||||
WebSocketManager.messages.collect { message ->
|
||||
try {
|
||||
Logger.d("ChatScreen", "Received WebSocket message: type=${message.type}, data=${message.data != null}")
|
||||
when (message.type) {
|
||||
"updates" -> {
|
||||
@@ -621,15 +658,14 @@ fun ChatScreen(
|
||||
Logger.d("ChatScreen", "handleWebSocketMessage for ${update.type}")
|
||||
try {
|
||||
panel.handleWebSocketMessage(wsMessage)
|
||||
} catch (e: Exception) {
|
||||
} catch (e: Throwable) {
|
||||
Logger.e("ChatScreen", "Error handling WebSocket message: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
} catch (e: Throwable) {
|
||||
Logger.e("ChatScreen", "Error parsing updates message: ${e.message}", e)
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
"statusUpdate" -> message.data?.jsonObject?.let { data ->
|
||||
@@ -642,12 +678,14 @@ fun ChatScreen(
|
||||
"dmTyping", "stopDmTyping", "typing", "stopTyping", "reactionUpdate",
|
||||
"registeredUserCount" -> {
|
||||
scope.launch {
|
||||
panel.handleWebSocketMessage(message)
|
||||
runCatching { panel.handleWebSocketMessage(message) }
|
||||
.onFailure { Logger.e("ChatScreen", "Error handling WebSocket message: ${it.message}", it) }
|
||||
}
|
||||
}
|
||||
"sendMessage" -> {
|
||||
scope.launch {
|
||||
panel.handleWebSocketMessage(message)
|
||||
runCatching { panel.handleWebSocketMessage(message) }
|
||||
.onFailure { Logger.e("ChatScreen", "Error handling sendMessage: ${it.message}", it) }
|
||||
}
|
||||
}
|
||||
"call_signaling" -> {
|
||||
@@ -660,6 +698,9 @@ fun ChatScreen(
|
||||
Logger.w("ChatScreen", "Unhandled top-level WebSocket message type: ${message.type}")
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e("ChatScreen", "WebSocket collect failed type=${message.type}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavOptionsBuilder
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import androidx.compose.ui.Modifier
|
||||
import ru.fromchat.api.local.db.store.ProfileCache
|
||||
@@ -20,15 +21,12 @@ import ru.fromchat.utils.haptic.rememberHapticFeedback
|
||||
|
||||
/** Route patterns and builders for DM chat + in-DM profile (stacked for predictive / system back). */
|
||||
object DmNav {
|
||||
const val CHAT_ROUTE = "dm/{otherUserId}/chat?sourceMessageId={sourceMessageId}"
|
||||
const val CHAT_ROUTE = "dm/{otherUserId}/chat/{sourceMessageId}"
|
||||
const val PROFILE_ROUTE = "dm/{otherUserId}/profile"
|
||||
|
||||
fun chatRoute(otherUserId: Int, sourceMessageId: Int? = null): String {
|
||||
return if (sourceMessageId != null && sourceMessageId > 0) {
|
||||
"dm/$otherUserId/chat?sourceMessageId=$sourceMessageId"
|
||||
} else {
|
||||
"dm/$otherUserId/chat"
|
||||
}
|
||||
val source = sourceMessageId?.takeIf { it > 0 } ?: 0
|
||||
return "dm/$otherUserId/chat/$source"
|
||||
}
|
||||
|
||||
fun profileRoute(otherUserId: Int) = "dm/$otherUserId/profile"
|
||||
@@ -46,20 +44,28 @@ fun NavController.navigateToDmChat(
|
||||
sourceMessageId: Int? = null,
|
||||
builder: NavOptionsBuilder.() -> Unit = {},
|
||||
) {
|
||||
if (isCurrentDmChat(otherUserId)) return
|
||||
if (sourceMessageId == null && isCurrentDmChat(otherUserId)) return
|
||||
|
||||
navigate(DmNav.chatRoute(otherUserId, sourceMessageId)) {
|
||||
popUpTo(graph.startDestinationId) { saveState = true }
|
||||
launchSingleTop = true
|
||||
builder()
|
||||
}
|
||||
val route = DmNav.chatRoute(otherUserId, sourceMessageId)
|
||||
runCatching {
|
||||
navigate(route) {
|
||||
popUpTo(graph.startDestinationId) { saveState = true }
|
||||
launchSingleTop = true
|
||||
builder()
|
||||
}
|
||||
}.onFailure { Logger.e("DmNav", "navigateToDmChat failed route=$route", it) }
|
||||
}
|
||||
|
||||
/** True when the back-stack top is already [DmNav.CHAT_ROUTE] for [otherUserId]. */
|
||||
fun NavController.isCurrentDmChat(otherUserId: Int): Boolean {
|
||||
val entry = currentBackStackEntry ?: return false
|
||||
if (entry.destination.route != DmNav.CHAT_ROUTE) return false
|
||||
val currentId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull()
|
||||
val currentId = when (val raw = entry.savedStateHandle.get<Any?>("otherUserId")) {
|
||||
is Int -> raw
|
||||
is Long -> raw.toInt()
|
||||
is String -> raw.toIntOrNull()
|
||||
else -> null
|
||||
}
|
||||
return currentId == otherUserId
|
||||
}
|
||||
|
||||
|
||||
@@ -586,10 +586,7 @@ class DmPanel(
|
||||
user_id = envelope.senderId,
|
||||
content = dec.text,
|
||||
timestamp = envelope.timestamp,
|
||||
is_read = when {
|
||||
envelope.senderId == currentUserId -> true
|
||||
else -> ActiveDmChatTracker.isActive(otherUserId)
|
||||
},
|
||||
is_read = envelope.isReadByViewer(currentUserId),
|
||||
is_edited = false,
|
||||
username = senderUsername,
|
||||
displayName = senderDisplayName,
|
||||
|
||||
@@ -8,12 +8,9 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.db.store.MessageRepository
|
||||
@@ -23,7 +20,6 @@ import ru.fromchat.api.local.send.scheduleOutboxProcessing
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.ui.chat.AvatarInfo
|
||||
import ru.fromchat.ui.chat.ChatScreen
|
||||
import ru.fromchat.ui.chat.utils.AttachmentDownloadVisibility
|
||||
|
||||
@Composable
|
||||
fun DmScreen(
|
||||
@@ -75,33 +71,6 @@ fun DmScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(panel, activeInstanceId, activePeerUserId, peerUserId) {
|
||||
val peerId = activePeerUserId.takeIf { it > 0 } ?: peerUserId ?: return@LaunchedEffect
|
||||
if (activeInstanceId.isBlank() || peerId <= 0) return@LaunchedEffect
|
||||
combine(
|
||||
AttachmentDownloadVisibility.visibleMessageIds,
|
||||
snapshotFlow { panel.getState().messages },
|
||||
) { visibleIds, messages ->
|
||||
visibleIds
|
||||
.filter { id ->
|
||||
messages.find { it.id == id }?.user_id == peerId
|
||||
}
|
||||
.maxOrNull()
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { maxVisibleInboundId ->
|
||||
if (
|
||||
maxVisibleInboundId != null &&
|
||||
maxVisibleInboundId > 0 &&
|
||||
ActiveDmChatTracker.isActive(peerId)
|
||||
) {
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageRepository.markDmConversationReadUpTo(peerId, maxVisibleInboundId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ChatScreen(
|
||||
panel = panel,
|
||||
currentUserId = currentUserId,
|
||||
|
||||
+27
@@ -39,3 +39,30 @@ fun visibleMessageIdsInChatList(
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* Message ids whose rows intersect the chat viewport after excluding the floating
|
||||
* header ([topClearancePx]) and input bar ([bottomClearancePx]).
|
||||
*
|
||||
* Reverse-layout offsets grow from the visual bottom (start).
|
||||
*/
|
||||
fun unobstructedVisibleMessageIdsInChatList(
|
||||
listState: LazyListState,
|
||||
messages: List<Message>,
|
||||
topClearancePx: Int,
|
||||
bottomClearancePx: Int,
|
||||
): Set<Int> {
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val unobstructedStart = layoutInfo.viewportStartOffset + bottomClearancePx.coerceAtLeast(0)
|
||||
val unobstructedEnd = (layoutInfo.viewportEndOffset - topClearancePx.coerceAtLeast(0))
|
||||
.coerceAtLeast(unobstructedStart)
|
||||
if (unobstructedEnd <= unobstructedStart) return emptySet()
|
||||
val reversed = messages.asReversed()
|
||||
return layoutInfo.visibleItemsInfo.mapNotNull { info ->
|
||||
if (info.index <= 0) return@mapNotNull null
|
||||
val itemStart = info.offset
|
||||
val itemEnd = info.offset + info.size
|
||||
if (itemEnd <= unobstructedStart || itemStart >= unobstructedEnd) return@mapNotNull null
|
||||
reversed.getOrNull(info.index - 1)?.id?.takeIf { it > 0 }
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ru.fromchat.ui.main
|
||||
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavType
|
||||
@@ -66,15 +67,15 @@ fun NavGraphBuilder.conversationDetailDestinations(
|
||||
route = DmNav.CHAT_ROUTE,
|
||||
arguments = listOf(
|
||||
navArgument("otherUserId") { type = NavType.StringType },
|
||||
navArgument("sourceMessageId") { type = NavType.IntType; defaultValue = -1 },
|
||||
navArgument("sourceMessageId") { type = NavType.StringType; defaultValue = "0" },
|
||||
),
|
||||
) { entry ->
|
||||
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
|
||||
val sourceMessageId = entry.savedStateHandle.get<Int>("sourceMessageId") ?: -1
|
||||
val otherUserId = entry.pathInt("otherUserId")
|
||||
val sourceMessageId = entry.pathInt("sourceMessageId")
|
||||
if (otherUserId <= 0) return@composable
|
||||
DmChatRoute(
|
||||
otherUserId = otherUserId,
|
||||
scrollToMessageId = if (sourceMessageId > 0) sourceMessageId else null,
|
||||
scrollToMessageId = sourceMessageId.takeIf { it > 0 },
|
||||
navController = navController,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = this,
|
||||
@@ -85,7 +86,7 @@ fun NavGraphBuilder.conversationDetailDestinations(
|
||||
route = DmNav.PROFILE_ROUTE,
|
||||
arguments = listOf(navArgument("otherUserId") { type = NavType.StringType }),
|
||||
) { entry ->
|
||||
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
|
||||
val otherUserId = entry.pathInt("otherUserId")
|
||||
if (otherUserId <= 0) return@composable
|
||||
DmProfileRoute(
|
||||
otherUserId = otherUserId,
|
||||
@@ -288,3 +289,11 @@ fun NavGraphBuilder.settingsDetailDestinations(
|
||||
ServerConfigScreen()
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavBackStackEntry.pathInt(name: String): Int =
|
||||
when (val raw = savedStateHandle.get<Any?>(name)) {
|
||||
is Int -> raw
|
||||
is Long -> raw.toInt()
|
||||
is String -> raw.toIntOrNull() ?: 0
|
||||
else -> 0
|
||||
}
|
||||
|
||||
@@ -943,6 +943,7 @@ internal fun PublicChatRowContent(
|
||||
val preview = publicChatPreviewState?.displayText(defaultLastMessage) ?: defaultLastMessage
|
||||
val pendingIndicator = publicChatPreviewState?.pendingIndicator
|
||||
?: ChatListPreviewPendingIndicator.None
|
||||
val unreadCount = publicChatPreviewState?.unreadCount ?: 0
|
||||
val showPreview = preview.isNotBlank() || pendingIndicator != ChatListPreviewPendingIndicator.None
|
||||
|
||||
ListItem(
|
||||
@@ -978,7 +979,12 @@ internal fun PublicChatRowContent(
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = {},
|
||||
trailingContent = {
|
||||
ChatUnreadBadge(
|
||||
count = unreadCount,
|
||||
visible = unreadCount > 0 && listMode == ChatsListMode.Normal,
|
||||
)
|
||||
},
|
||||
bodyModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(),
|
||||
|
||||
@@ -1224,7 +1224,7 @@ fun ChatsTab(
|
||||
selectedOtherUserIds = selectedOtherUserIds,
|
||||
isReadOnly = suspensionState.isSuspended,
|
||||
callsEnabled = callsEnabled,
|
||||
publicHasUnread = false,
|
||||
publicHasUnread = (publicChatPreviewState?.unreadCount ?: 0) > 0,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
|
||||
+104
-46
@@ -41,6 +41,8 @@ import ru.fromchat.settings_notifications_title
|
||||
import ru.fromchat.settings_push_notifications
|
||||
import ru.fromchat.settings_push_notifications_d
|
||||
import ru.fromchat.settings_push_notifications_unavailable
|
||||
import ru.fromchat.settings_desktop_notifications
|
||||
import ru.fromchat.settings_desktop_notifications_d
|
||||
import ru.fromchat.ui.components.FromChatSnackbarHost
|
||||
import ru.fromchat.ui.components.Text
|
||||
|
||||
@@ -53,7 +55,10 @@ fun NotificationsScreen(onBack: () -> Unit) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var notificationsEnabled by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
notificationsEnabled = areAppNotificationsEnabled() && isFcmPushRegisteredLocally()
|
||||
notificationsEnabled = when {
|
||||
areDesktopMessageNotificationsSupported() -> areDesktopMessageNotificationsEnabled()
|
||||
else -> areAppNotificationsEnabled() && isFcmPushRegisteredLocally()
|
||||
}
|
||||
}
|
||||
val notificationsPermissionText = stringResource(Res.string.settings_notifications_permission_required)
|
||||
val unexpectedErrorText = stringResource(Res.string.error_unexpected)
|
||||
@@ -81,57 +86,101 @@ fun NotificationsScreen(onBack: () -> Unit) {
|
||||
Category(
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
) {
|
||||
if (arePushNotificationsSupported()) {
|
||||
SwitchListItem(
|
||||
headline = stringResource(Res.string.settings_push_notifications),
|
||||
supportingText = stringResource(Res.string.settings_push_notifications_d),
|
||||
leadingContent = {
|
||||
Icon(Icons.Filled.Notifications, null)
|
||||
},
|
||||
checked = notificationsEnabled,
|
||||
onCheckedChange = { enabled ->
|
||||
coroutineScope.launch {
|
||||
if (enabled) {
|
||||
if (!areAppNotificationsEnabled()) {
|
||||
if (!openAppNotificationSettings()) {
|
||||
snackbarHostState.showSnackbar(message = unexpectedErrorText)
|
||||
} else {
|
||||
snackbarHostState.showSnackbar(message = notificationsPermissionText)
|
||||
when {
|
||||
arePushNotificationsSupported() -> {
|
||||
SwitchListItem(
|
||||
headline = stringResource(Res.string.settings_push_notifications),
|
||||
supportingText = stringResource(Res.string.settings_push_notifications_d),
|
||||
leadingContent = {
|
||||
Icon(Icons.Filled.Notifications, null)
|
||||
},
|
||||
checked = notificationsEnabled,
|
||||
onCheckedChange = { enabled ->
|
||||
coroutineScope.launch {
|
||||
if (enabled) {
|
||||
if (!areAppNotificationsEnabled()) {
|
||||
if (!openAppNotificationSettings()) {
|
||||
snackbarHostState.showSnackbar(message = unexpectedErrorText)
|
||||
} else {
|
||||
snackbarHostState.showSnackbar(message = notificationsPermissionText)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val registered = ensureFcmTokenRegistered()
|
||||
if (registered) {
|
||||
val registered = ensureFcmTokenRegistered()
|
||||
if (registered) {
|
||||
notificationsEnabled = true
|
||||
} else {
|
||||
snackbarHostState.showSnackbar(message = unexpectedErrorText)
|
||||
}
|
||||
} else {
|
||||
unregisterFcmTokenFromServer()
|
||||
notificationsEnabled = false
|
||||
}
|
||||
}
|
||||
},
|
||||
divider = true
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.settings_notification_settings),
|
||||
supportingText = stringResource(Res.string.settings_notification_settings_d),
|
||||
leadingContent = {
|
||||
Icon(Icons.Filled.Settings, null)
|
||||
},
|
||||
onClick = { openAppNotificationSettings() }
|
||||
)
|
||||
}
|
||||
|
||||
areDesktopMessageNotificationsSupported() -> {
|
||||
SwitchListItem(
|
||||
headline = stringResource(Res.string.settings_desktop_notifications),
|
||||
supportingText = stringResource(Res.string.settings_desktop_notifications_d),
|
||||
leadingContent = {
|
||||
Icon(Icons.Filled.Notifications, null)
|
||||
},
|
||||
checked = notificationsEnabled,
|
||||
onCheckedChange = { enabled ->
|
||||
coroutineScope.launch {
|
||||
if (enabled) {
|
||||
val granted = requestDesktopNotificationPermission()
|
||||
if (!granted && !areAppNotificationsEnabled()) {
|
||||
openAppNotificationSettings()
|
||||
snackbarHostState.showSnackbar(
|
||||
message = notificationsPermissionText,
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
setDesktopMessageNotificationsEnabled(true)
|
||||
notificationsEnabled = true
|
||||
} else {
|
||||
snackbarHostState.showSnackbar(message = unexpectedErrorText)
|
||||
setDesktopMessageNotificationsEnabled(false)
|
||||
notificationsEnabled = false
|
||||
}
|
||||
} else {
|
||||
unregisterFcmTokenFromServer()
|
||||
notificationsEnabled = false
|
||||
}
|
||||
}
|
||||
},
|
||||
divider = true
|
||||
)
|
||||
},
|
||||
divider = true,
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.settings_notification_settings),
|
||||
supportingText = stringResource(Res.string.settings_notification_settings_d),
|
||||
leadingContent = {
|
||||
Icon(Icons.Filled.Settings, null)
|
||||
},
|
||||
onClick = { openAppNotificationSettings() }
|
||||
)
|
||||
} else {
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.settings_push_notifications),
|
||||
supportingText = stringResource(Res.string.settings_push_notifications_unavailable),
|
||||
leadingContent = {
|
||||
Icon(Icons.Filled.Notifications, null)
|
||||
},
|
||||
)
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.settings_notification_settings),
|
||||
supportingText = stringResource(Res.string.settings_notification_settings_d),
|
||||
leadingContent = {
|
||||
Icon(Icons.Filled.Settings, null)
|
||||
},
|
||||
onClick = { openAppNotificationSettings() },
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.settings_push_notifications),
|
||||
supportingText = stringResource(Res.string.settings_push_notifications_unavailable),
|
||||
leadingContent = {
|
||||
Icon(Icons.Filled.Notifications, null)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,4 +199,13 @@ expect fun openAppNotificationSettings(): Boolean
|
||||
expect fun areAppNotificationsEnabled(): Boolean
|
||||
|
||||
/** False on platforms without push delivery (e.g. iOS without APNs). */
|
||||
expect fun arePushNotificationsSupported(): Boolean
|
||||
expect fun arePushNotificationsSupported(): Boolean
|
||||
|
||||
/** True on desktop (tray / notification center via persistent WebSocket). */
|
||||
expect fun areDesktopMessageNotificationsSupported(): Boolean
|
||||
|
||||
expect fun areDesktopMessageNotificationsEnabled(): Boolean
|
||||
|
||||
expect fun setDesktopMessageNotificationsEnabled(enabled: Boolean)
|
||||
|
||||
expect fun requestDesktopNotificationPermission(): Boolean
|
||||
@@ -201,21 +201,31 @@ SELECT id
|
||||
FROM message
|
||||
WHERE instanceId = ? AND conversationId = ? AND isRead = 0 AND id > 0 AND deletedFlag = 0;
|
||||
|
||||
countUnreadPublicInboundMessages:
|
||||
SELECT COUNT(*)
|
||||
FROM message
|
||||
WHERE instanceId = ? AND conversationId = ? AND isRead = 0 AND id > 0 AND deletedFlag = 0 AND userId != ?;
|
||||
|
||||
countInboundDmMessages:
|
||||
SELECT COUNT(*)
|
||||
FROM message
|
||||
WHERE instanceId = ? AND conversationId = ? AND userId = ? AND id > 0 AND deletedFlag = 0;
|
||||
|
||||
markPublicMessagesRead:
|
||||
UPDATE message
|
||||
SET isRead = 1
|
||||
WHERE instanceId = ? AND conversationId = ? AND isRead = 0 AND deletedFlag = 0;
|
||||
|
||||
markPublicMessageRead:
|
||||
UPDATE message
|
||||
SET isRead = 1
|
||||
WHERE instanceId = ? AND conversationId = ? AND id = ? AND isRead = 0 AND deletedFlag = 0;
|
||||
|
||||
countUnreadInboundDmMessages:
|
||||
SELECT COUNT(*)
|
||||
FROM message
|
||||
WHERE instanceId = ? AND conversationId = ? AND userId = ? AND isRead = 0 AND id > 0 AND deletedFlag = 0;
|
||||
|
||||
markInboundDmMessagesReadUpTo:
|
||||
UPDATE message
|
||||
SET isRead = 1
|
||||
WHERE instanceId = ? AND conversationId = ? AND userId = ? AND id <= ? AND isRead = 0 AND id > 0 AND deletedFlag = 0;
|
||||
|
||||
markAllInboundDmMessagesRead:
|
||||
UPDATE message
|
||||
SET isRead = 1
|
||||
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
package ru.fromchat.notifications
|
||||
|
||||
actual object ChatNotificationDismissals {
|
||||
actual fun dismissAllMessageNotifications() = Unit
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package ru.fromchat.notifications
|
||||
|
||||
internal actual fun notifyIncomingCallIfBackground(callerDisplayName: String) = Unit
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package ru.fromchat.notifications
|
||||
|
||||
internal actual object MessageNotificationSink {
|
||||
actual fun areEnabled(): Boolean = false
|
||||
actual fun shouldSuppressPublic(): Boolean = true
|
||||
actual fun shouldSuppressDm(peerUserId: Int): Boolean = true
|
||||
actual suspend fun present(notification: PresentedMessageNotification) = Unit
|
||||
actual fun dismiss(identifier: String) = Unit
|
||||
actual fun dismissAll() = Unit
|
||||
actual fun refreshChrome() = Unit
|
||||
}
|
||||
+9
-1
@@ -13,4 +13,12 @@ actual fun openAppNotificationSettings(): Boolean {
|
||||
|
||||
actual fun areAppNotificationsEnabled(): Boolean = false
|
||||
|
||||
actual fun arePushNotificationsSupported(): Boolean = false
|
||||
actual fun arePushNotificationsSupported(): Boolean = false
|
||||
|
||||
actual fun areDesktopMessageNotificationsSupported(): Boolean = false
|
||||
|
||||
actual fun areDesktopMessageNotificationsEnabled(): Boolean = false
|
||||
|
||||
actual fun setDesktopMessageNotificationsEnabled(enabled: Boolean) = Unit
|
||||
|
||||
actual fun requestDesktopNotificationPermission(): Boolean = false
|
||||
@@ -0,0 +1,16 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
/** Tracks whether the main desktop window is shown and whether it has OS focus. */
|
||||
object DesktopAppVisibility {
|
||||
@Volatile
|
||||
var isWindowVisible: Boolean = true
|
||||
|
||||
@Volatile
|
||||
var isWindowFocused: Boolean = true
|
||||
|
||||
val isOsFrontmost: Boolean
|
||||
get() = !MacNotificationCenter.isAvailable() || MacNotificationCenter.isAppFrontmost()
|
||||
|
||||
val isForeground: Boolean
|
||||
get() = isWindowVisible && isWindowFocused && isOsFrontmost
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
object DesktopNotificationSettings {
|
||||
private const val PREF_NODE = "ru/fromchat/desktop"
|
||||
private const val KEY_ENABLED = "message_notifications_enabled"
|
||||
|
||||
var enabled: Boolean
|
||||
get() = preferences().getBoolean(KEY_ENABLED, true)
|
||||
set(value) {
|
||||
preferences().putBoolean(KEY_ENABLED, value)
|
||||
}
|
||||
|
||||
private fun preferences(): Preferences =
|
||||
Preferences.userRoot().node(PREF_NODE)
|
||||
}
|
||||
@@ -3,6 +3,17 @@ package ru.fromchat.desktop
|
||||
import java.awt.SystemTray
|
||||
import java.awt.TrayIcon
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.notifications.NotificationLaunchCoordinator
|
||||
import ru.fromchat.notifications.NotificationLaunchTarget
|
||||
|
||||
data class DesktopNotificationPayload(
|
||||
val title: String,
|
||||
val body: String,
|
||||
val subtitle: String = "",
|
||||
val launchTarget: NotificationLaunchTarget? = null,
|
||||
) {
|
||||
fun displayBody(): String = if (subtitle.isBlank()) body else "$subtitle\n$body"
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort desktop notifications. [Main] may install a Compose Tray sink;
|
||||
@@ -12,23 +23,79 @@ object DesktopNotifier {
|
||||
private const val TAG = "DesktopNotifier"
|
||||
|
||||
@Volatile
|
||||
var sink: ((title: String, body: String) -> Unit)? = null
|
||||
var sink: ((DesktopNotificationPayload) -> Unit)? = null
|
||||
|
||||
fun show(title: String, body: String) {
|
||||
val t = title.trim().ifEmpty { "FromChat" }
|
||||
val b = body.trim()
|
||||
sink?.invoke(t, b)?.let { return }
|
||||
showAwtBalloon(t, b)
|
||||
@Volatile
|
||||
private var pendingLaunchTarget: NotificationLaunchTarget? = null
|
||||
private val launches = java.util.concurrent.ConcurrentHashMap<String, NotificationLaunchTarget>()
|
||||
|
||||
fun rememberLaunch(identifier: String, target: NotificationLaunchTarget) {
|
||||
launches[identifier] = target
|
||||
pendingLaunchTarget = target
|
||||
}
|
||||
|
||||
fun rememberLaunch(target: NotificationLaunchTarget) {
|
||||
pendingLaunchTarget = target
|
||||
}
|
||||
|
||||
fun forgetLaunch(identifier: String) {
|
||||
launches.remove(identifier)
|
||||
}
|
||||
|
||||
fun show(
|
||||
title: String,
|
||||
body: String,
|
||||
subtitle: String = "",
|
||||
launchTarget: NotificationLaunchTarget? = null,
|
||||
) {
|
||||
val payload = DesktopNotificationPayload(
|
||||
title = title.trim().ifEmpty { "FromChat" },
|
||||
body = body.trim(),
|
||||
subtitle = subtitle.trim(),
|
||||
launchTarget = launchTarget,
|
||||
)
|
||||
if (payload.launchTarget != null) {
|
||||
rememberLaunch(payload.launchTarget)
|
||||
}
|
||||
val sink = this.sink
|
||||
Logger.i(
|
||||
TAG,
|
||||
"show sink=${sink != null} titleLen=${payload.title.length} " +
|
||||
"subtitleLen=${payload.subtitle.length} bodyLen=${payload.body.length} " +
|
||||
"launch=$launchTarget",
|
||||
)
|
||||
sink?.invoke(payload) ?: showAwtBalloon(payload.title, payload.displayBody())
|
||||
}
|
||||
|
||||
/** Opens the chat referenced by [identifier], or the most recent notification if unknown. */
|
||||
fun deliverPendingLaunch(identifier: String? = null) {
|
||||
val target = identifier?.let { launches.remove(it) } ?: pendingLaunchTarget ?: return
|
||||
pendingLaunchTarget = null
|
||||
NotificationLaunchCoordinator.publish(target)
|
||||
}
|
||||
|
||||
fun clearPendingLaunch() {
|
||||
pendingLaunchTarget = null
|
||||
launches.clear()
|
||||
}
|
||||
|
||||
fun showAwtFallback(title: String, body: String) {
|
||||
showAwtBalloon(title, body)
|
||||
}
|
||||
|
||||
private fun showAwtBalloon(title: String, body: String) {
|
||||
runCatching {
|
||||
if (!SystemTray.isSupported()) {
|
||||
Logger.d(TAG, "SystemTray unsupported; drop notification")
|
||||
Logger.i(TAG, "AWT balloon skip: SystemTray unsupported")
|
||||
return
|
||||
}
|
||||
val tray = SystemTray.getSystemTray()
|
||||
val icon = tray.trayIcons.firstOrNull() ?: return
|
||||
val icon = tray.trayIcons.firstOrNull()
|
||||
if (icon == null) {
|
||||
Logger.i(TAG, "AWT balloon skip: no tray icon")
|
||||
return
|
||||
}
|
||||
Logger.i(TAG, "AWT balloon displayMessage titleLen=${title.length}")
|
||||
icon.displayMessage(title, body, TrayIcon.MessageType.INFO)
|
||||
}.onFailure {
|
||||
Logger.w(TAG, "AWT balloon failed: ${it.message}", it)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import java.awt.Taskbar
|
||||
import ru.fromchat.Logger
|
||||
|
||||
/** Best-effort dock / taskbar unread badge (macOS when supported; no-op elsewhere). */
|
||||
object DesktopTaskbarBadge {
|
||||
private const val TAG = "DesktopTaskbarBadge"
|
||||
|
||||
fun setUnreadCount(count: Int) {
|
||||
runCatching {
|
||||
if (!Taskbar.isTaskbarSupported()) return
|
||||
val taskbar = Taskbar.getTaskbar()
|
||||
val badgeFeature = runCatching {
|
||||
Taskbar.Feature.valueOf("ICON_BADGE")
|
||||
}.getOrNull() ?: return
|
||||
if (!taskbar.isSupported(badgeFeature)) return
|
||||
val badge = when {
|
||||
count <= 0 -> null
|
||||
count > 99 -> "99+"
|
||||
else -> count.toString()
|
||||
}
|
||||
val method = taskbar.javaClass.getMethod("setIconBadge", String::class.java)
|
||||
method.invoke(taskbar, badge)
|
||||
}.onFailure {
|
||||
Logger.d(TAG, "icon badge update failed: ${it.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import ru.fromchat.Logger
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
/**
|
||||
* Registers FromChat with macOS Notification Center ([UNUserNotificationCenter])
|
||||
* and delivers native banners that appear under our bundle id, not "java".
|
||||
*/
|
||||
object MacNotificationCenter {
|
||||
private const val TAG = "MacNotificationCenter"
|
||||
private const val LIBRARY = "fromchat_notifications"
|
||||
private const val SPURIOUS_ACTIVATION_MS = 5_000L
|
||||
|
||||
@Volatile
|
||||
var onActivated: ((String?) -> Unit)? = null
|
||||
|
||||
@Volatile
|
||||
private var lastDeliverId: String? = null
|
||||
|
||||
@Volatile
|
||||
private var lastDeliverAtMs: Long = 0L
|
||||
|
||||
@Volatile
|
||||
private var lastDeliverWhileFrontmost: Boolean = false
|
||||
|
||||
@Volatile
|
||||
private var lastWillPresentId: String? = null
|
||||
|
||||
@Volatile
|
||||
private var lastWillPresentAtMs: Long = 0L
|
||||
|
||||
private val available: Boolean by lazy { loadLibrary() }
|
||||
|
||||
fun isAvailable(): Boolean = available
|
||||
|
||||
fun registerAndRequestAuthorization(): Boolean {
|
||||
if (!available) {
|
||||
Logger.w(TAG, "register skip: native library unavailable")
|
||||
return false
|
||||
}
|
||||
Logger.i(TAG, "register begin ${runCatching { nativeDebugInfo() }.getOrDefault("debug=fail")}")
|
||||
if (!runCatching { nativeIsBundled() }.getOrDefault(false)) {
|
||||
Logger.w(TAG, "skip UNUserNotificationCenter: process is not an .app bundle")
|
||||
return false
|
||||
}
|
||||
runCatching { nativeRegisterBundle() }
|
||||
val granted = runCatching { nativeRequestAuthorization() }.getOrDefault(false)
|
||||
val status = runCatching { nativeAuthorizationStatus() }.getOrDefault(-1)
|
||||
Logger.i(TAG, "register done granted=$granted auth=${authStatusName(status)}")
|
||||
if (!granted && status == 1) {
|
||||
Logger.w(TAG, "authorization denied — opening Notification settings")
|
||||
openSystemSettings()
|
||||
}
|
||||
return granted
|
||||
}
|
||||
|
||||
fun isAuthorized(): Boolean {
|
||||
if (!available) return true
|
||||
return runCatching { nativeAuthorizationStatus() }.getOrDefault(0) >= 2
|
||||
}
|
||||
|
||||
fun isAppFrontmost(): Boolean =
|
||||
available && runCatching { nativeIsAppFrontmost() }.getOrDefault(false)
|
||||
|
||||
/** Resigns key status so Notification Center can show banners when another app is in use. */
|
||||
fun resignActive() {
|
||||
if (!available) return
|
||||
runCatching { nativeResignActive() }
|
||||
}
|
||||
|
||||
/** Lets the real frontmost app own activation so Notification Center uses banners. */
|
||||
fun yieldActivationIfNotFrontmost() {
|
||||
if (!available) return
|
||||
runCatching { nativeYieldActivation() }
|
||||
}
|
||||
|
||||
fun deliver(
|
||||
title: String,
|
||||
body: String,
|
||||
subtitle: String = "",
|
||||
identifier: String = System.nanoTime().toString(),
|
||||
playSound: Boolean = true,
|
||||
): Boolean {
|
||||
if (!available) {
|
||||
Logger.i(TAG, "deliver skip: native library unavailable")
|
||||
return false
|
||||
}
|
||||
if (!runCatching { nativeIsBundled() }.getOrDefault(false)) {
|
||||
Logger.i(
|
||||
TAG,
|
||||
"deliver skip: not bundled ${runCatching { nativeDebugInfo() }.getOrDefault("")}",
|
||||
)
|
||||
return false
|
||||
}
|
||||
val status = runCatching { nativeAuthorizationStatus() }.getOrDefault(-1)
|
||||
if (status < 2) {
|
||||
Logger.i(TAG, "deliver skip: auth=${authStatusName(status)} id=$identifier")
|
||||
return false
|
||||
}
|
||||
lastDeliverId = identifier
|
||||
lastDeliverAtMs = System.currentTimeMillis()
|
||||
val windowFocused = DesktopAppVisibility.isWindowFocused
|
||||
lastDeliverWhileFrontmost = windowFocused
|
||||
Logger.i(
|
||||
TAG,
|
||||
"deliver begin auth=${authStatusName(status)} id=$identifier " +
|
||||
"titleLen=${title.length} subtitleLen=${subtitle.length} bodyLen=${body.length} " +
|
||||
"windowFocused=$windowFocused frontmost=$lastDeliverWhileFrontmost " +
|
||||
runCatching { nativeDebugInfo() }.getOrDefault(""),
|
||||
)
|
||||
val ok = runCatching {
|
||||
nativeDeliver(title, body, subtitle, identifier, playSound, windowFocused)
|
||||
}
|
||||
.onFailure { Logger.e(TAG, "deliver native threw", it) }
|
||||
.getOrDefault(false)
|
||||
Logger.i(TAG, "deliver end ok=$ok id=$identifier")
|
||||
return ok
|
||||
}
|
||||
|
||||
fun removeAll() {
|
||||
if (!available) return
|
||||
Logger.i(TAG, "removeAll")
|
||||
runCatching { nativeRemoveAll() }
|
||||
}
|
||||
|
||||
fun remove(identifier: String) {
|
||||
if (!available) return
|
||||
Logger.i(TAG, "remove id=$identifier")
|
||||
runCatching { nativeRemove(arrayOf(identifier)) }
|
||||
}
|
||||
|
||||
fun openSystemSettings(): Boolean {
|
||||
if (available) {
|
||||
val native = runCatching { nativeOpenSettings() }.getOrDefault(false)
|
||||
if (native) return true
|
||||
}
|
||||
return runCatching {
|
||||
ProcessBuilder(
|
||||
"open",
|
||||
"x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=ru.fromchat.desktop",
|
||||
).start().waitFor() == 0
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onNativeActivated(identifier: String?) {
|
||||
val now = System.currentTimeMillis()
|
||||
val deliverElapsed = now - lastDeliverAtMs
|
||||
val presentElapsed = now - lastWillPresentAtMs
|
||||
val fromForegroundDeliver = lastDeliverWhileFrontmost &&
|
||||
identifier != null &&
|
||||
identifier == lastDeliverId &&
|
||||
deliverElapsed in 0 until SPURIOUS_ACTIVATION_MS
|
||||
val fromWillPresent = identifier != null &&
|
||||
identifier == lastWillPresentId &&
|
||||
presentElapsed in 0 until SPURIOUS_ACTIVATION_MS
|
||||
if (fromForegroundDeliver || fromWillPresent) {
|
||||
Logger.i(
|
||||
TAG,
|
||||
"ignore spurious activation id=$identifier " +
|
||||
"deliverElapsedMs=$deliverElapsed presentElapsedMs=$presentElapsed",
|
||||
)
|
||||
return
|
||||
}
|
||||
Logger.i(TAG, "notification activated id=$identifier")
|
||||
val callback = onActivated ?: return
|
||||
java.awt.EventQueue.invokeLater {
|
||||
runCatching { callback(identifier) }.onFailure {
|
||||
Logger.e(TAG, "onActivated failed", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onNativeWillPresent(identifier: String?) {
|
||||
lastWillPresentId = identifier
|
||||
lastWillPresentAtMs = System.currentTimeMillis()
|
||||
Logger.i(TAG, "willPresent id=$identifier")
|
||||
}
|
||||
|
||||
private fun loadLibrary(): Boolean {
|
||||
if (!isMacOs()) return false
|
||||
return runCatching {
|
||||
val extracted = extractDylib()
|
||||
if (extracted != null) {
|
||||
System.load(extracted)
|
||||
} else {
|
||||
System.loadLibrary(LIBRARY)
|
||||
}
|
||||
Logger.i(TAG, "native library loaded")
|
||||
true
|
||||
}.onFailure {
|
||||
Logger.w(TAG, "native notifications unavailable: ${it.message}")
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun extractDylib(): String? {
|
||||
val resourceNames = listOf(
|
||||
"/natives/lib$LIBRARY.dylib",
|
||||
"/lib$LIBRARY.dylib",
|
||||
)
|
||||
val stream = resourceNames.firstNotNullOfOrNull { name ->
|
||||
MacNotificationCenter::class.java.getResourceAsStream(name)
|
||||
?: Thread.currentThread().contextClassLoader.getResourceAsStream(name.removePrefix("/"))
|
||||
} ?: run {
|
||||
val packaged = System.getProperty("compose.application.resources.dir")
|
||||
?.let { java.io.File(it, "lib$LIBRARY.dylib") }
|
||||
return packaged?.takeIf { it.isFile }?.absolutePath
|
||||
}
|
||||
val tmp = Files.createTempFile("lib$LIBRARY", ".dylib")
|
||||
stream.use { input ->
|
||||
Files.copy(input, tmp, StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
tmp.toFile().deleteOnExit()
|
||||
return tmp.toAbsolutePath().toString()
|
||||
}
|
||||
|
||||
private fun isMacOs(): Boolean =
|
||||
System.getProperty("os.name").orEmpty().lowercase().contains("mac")
|
||||
|
||||
private fun authStatusName(status: Int): String = when (status) {
|
||||
0 -> "notDetermined"
|
||||
1 -> "denied"
|
||||
2 -> "authorized"
|
||||
3 -> "provisional"
|
||||
4 -> "ephemeral"
|
||||
else -> "unknown($status)"
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeRequestAuthorization(): Boolean
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeAuthorizationStatus(): Int
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeDeliver(
|
||||
title: String,
|
||||
body: String,
|
||||
subtitle: String,
|
||||
identifier: String,
|
||||
playSound: Boolean,
|
||||
windowFocused: Boolean,
|
||||
): Boolean
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeRemoveAll()
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeRemove(identifiers: Array<String>)
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeOpenSettings(): Boolean
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeRegisterBundle()
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeIsAppFrontmost(): Boolean
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeResignActive()
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeYieldActivation()
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeIsBundled(): Boolean
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeDebugInfo(): String
|
||||
}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
package ru.fromchat.notifications
|
||||
|
||||
actual object ChatNotificationDismissals {
|
||||
actual fun dismissAllMessageNotifications() = Unit
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package ru.fromchat.notifications
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.call_incoming_subtitle
|
||||
import ru.fromchat.desktop.DesktopAppVisibility
|
||||
import ru.fromchat.desktop.DesktopNotificationSettings
|
||||
import ru.fromchat.ui.calls.notifyIncomingCall
|
||||
|
||||
internal actual fun notifyIncomingCallIfBackground(callerDisplayName: String) {
|
||||
if (!DesktopNotificationSettings.enabled) return
|
||||
if (DesktopAppVisibility.isWindowVisible) return
|
||||
val title = callerDisplayName.trim().ifBlank { "FromChat" }
|
||||
val body = runBlocking { getString(Res.string.call_incoming_subtitle) }
|
||||
notifyIncomingCall(title, body)
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package ru.fromchat.notifications
|
||||
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.api.local.messages.ActiveDmChatTracker
|
||||
import ru.fromchat.desktop.DesktopAppVisibility
|
||||
import ru.fromchat.desktop.DesktopNotificationSettings
|
||||
import ru.fromchat.desktop.DesktopNotifier
|
||||
import ru.fromchat.desktop.DesktopTaskbarBadge
|
||||
import ru.fromchat.desktop.MacNotificationCenter
|
||||
import ru.fromchat.public_chat
|
||||
import ru.fromchat.ui.chat.panels.publicchat.isPublicChatVisible
|
||||
|
||||
private const val TAG = "MessageNotificationSink"
|
||||
|
||||
private fun isUsingDesktopApp(): Boolean =
|
||||
DesktopAppVisibility.isWindowVisible && DesktopAppVisibility.isWindowFocused
|
||||
|
||||
internal actual object MessageNotificationSink {
|
||||
actual fun areEnabled(): Boolean = DesktopNotificationSettings.enabled
|
||||
|
||||
actual fun shouldSuppressPublic(): Boolean =
|
||||
isUsingDesktopApp() && isPublicChatVisible
|
||||
|
||||
actual fun shouldSuppressDm(peerUserId: Int): Boolean =
|
||||
isUsingDesktopApp() && ActiveDmChatTracker.isActive(peerUserId)
|
||||
|
||||
actual suspend fun present(notification: PresentedMessageNotification) {
|
||||
val title: String
|
||||
val subtitle: String
|
||||
if (notification.isDirectMessage) {
|
||||
title = notification.senderName.ifBlank { "FromChat" }
|
||||
subtitle = ""
|
||||
} else {
|
||||
title = getString(Res.string.public_chat)
|
||||
subtitle = notification.senderName
|
||||
}
|
||||
DesktopNotifier.rememberLaunch(notification.identifier, notification.launchTarget)
|
||||
Logger.i(
|
||||
TAG,
|
||||
"present id=${notification.identifier} update=${notification.isUpdate} " +
|
||||
"titleLen=${title.length} subtitleLen=${subtitle.length} bodyLen=${notification.body.length}",
|
||||
)
|
||||
val native = MacNotificationCenter.deliver(
|
||||
title = title,
|
||||
body = notification.body,
|
||||
subtitle = subtitle,
|
||||
identifier = notification.identifier,
|
||||
playSound = !notification.isUpdate,
|
||||
)
|
||||
if (!native) {
|
||||
DesktopNotifier.showAwtFallback(
|
||||
title,
|
||||
if (subtitle.isBlank()) notification.body else "$subtitle\n${notification.body}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
actual fun dismiss(identifier: String) {
|
||||
Logger.i(TAG, "dismiss identifier=$identifier")
|
||||
DesktopNotifier.forgetLaunch(identifier)
|
||||
MacNotificationCenter.remove(identifier)
|
||||
}
|
||||
|
||||
actual fun dismissAll() {
|
||||
Logger.i(TAG, "dismissAll")
|
||||
DesktopNotifier.clearPendingLaunch()
|
||||
MacNotificationCenter.removeAll()
|
||||
DesktopTaskbarBadge.setUnreadCount(0)
|
||||
}
|
||||
|
||||
actual fun refreshChrome() {
|
||||
runCatching {
|
||||
val instanceId = CacheContext.activeInstanceId.value.trim()
|
||||
val total = if (instanceId.isEmpty()) {
|
||||
0
|
||||
} else {
|
||||
ru.fromchat.api.local.db.store.MessageCacheStore
|
||||
.loadCachedDmConversationsImmediate(instanceId)
|
||||
.sumOf { it.unreadCount }
|
||||
}
|
||||
DesktopTaskbarBadge.setUnreadCount(total)
|
||||
}.onFailure {
|
||||
Logger.d(TAG, "badge refresh failed: ${it.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-2
@@ -1,7 +1,36 @@
|
||||
package ru.fromchat.ui.main.settings
|
||||
|
||||
actual fun openAppNotificationSettings(): Boolean = false
|
||||
import ru.fromchat.desktop.DesktopNotificationSettings
|
||||
import ru.fromchat.desktop.MacNotificationCenter
|
||||
|
||||
actual fun areAppNotificationsEnabled(): Boolean = true
|
||||
actual fun openAppNotificationSettings(): Boolean {
|
||||
if (isMacOs()) return MacNotificationCenter.openSystemSettings()
|
||||
return false
|
||||
}
|
||||
|
||||
actual fun areAppNotificationsEnabled(): Boolean {
|
||||
if (!isMacOs()) return true
|
||||
if (!MacNotificationCenter.isAvailable()) return true
|
||||
return MacNotificationCenter.isAuthorized()
|
||||
}
|
||||
|
||||
actual fun arePushNotificationsSupported(): Boolean = false
|
||||
|
||||
actual fun areDesktopMessageNotificationsSupported(): Boolean = true
|
||||
|
||||
actual fun areDesktopMessageNotificationsEnabled(): Boolean = DesktopNotificationSettings.enabled
|
||||
|
||||
actual fun setDesktopMessageNotificationsEnabled(enabled: Boolean) {
|
||||
DesktopNotificationSettings.enabled = enabled
|
||||
if (enabled && isMacOs()) {
|
||||
MacNotificationCenter.registerAndRequestAuthorization()
|
||||
}
|
||||
}
|
||||
|
||||
actual fun requestDesktopNotificationPermission(): Boolean {
|
||||
if (!isMacOs()) return true
|
||||
return MacNotificationCenter.registerAndRequestAuthorization()
|
||||
}
|
||||
|
||||
private fun isMacOs(): Boolean =
|
||||
System.getProperty("os.name").orEmpty().lowercase().contains("mac")
|
||||
|
||||
Reference in New Issue
Block a user