Merge remote-tracking branch 'origin/main' into claude/improve-desktop-design-iOokB
This commit is contained in:
@@ -395,8 +395,17 @@ class AppModules(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manages always-on notification service lifecycle
|
// Manages always-on notification service lifecycle. Preloads every saved
|
||||||
val alwaysOnNotificationServiceManager = AlwaysOnNotificationServiceManager(appContext, applicationIOScope)
|
// writable account while enabled so GiftWraps for non-active accounts still
|
||||||
|
// get unwrapped by their owning account's newNotesPreProcessor.
|
||||||
|
val alwaysOnNotificationServiceManager =
|
||||||
|
AlwaysOnNotificationServiceManager(
|
||||||
|
context = appContext,
|
||||||
|
scope = applicationIOScope,
|
||||||
|
accountsCache = accountsCache,
|
||||||
|
localPreferences = LocalPreferences,
|
||||||
|
activePubKeyProvider = { sessionManager.loggedInAccount()?.pubKey },
|
||||||
|
)
|
||||||
|
|
||||||
// Observes LocalCache for notification-relevant events and routes them to
|
// Observes LocalCache for notification-relevant events and routes them to
|
||||||
// EventNotificationConsumer. Sources: FCM, UnifiedPush, Pokey, active relay
|
// EventNotificationConsumer. Sources: FCM, UnifiedPush, Pokey, active relay
|
||||||
|
|||||||
@@ -384,14 +384,20 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
|
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emits each new event that matches the filter, one at a time, as it is
|
* Emits each new event for which [predicate] returns true, one at a time,
|
||||||
* inserted into the cache. Unlike [observeEvents], this does not accumulate
|
* as it is inserted into the cache. Unlike [observeEvents], this does not
|
||||||
* a list — useful for per-event reactive pipelines like notifications.
|
* accumulate a list — useful for per-event reactive pipelines like
|
||||||
|
* notifications.
|
||||||
|
*
|
||||||
|
* The predicate runs on every insertion, so keep it cheap. Callers with a
|
||||||
|
* Nostr [Filter] can pass `filter::match`; compose additional local checks
|
||||||
|
* (rolling windows, derived fields the Filter grammar can't express) with
|
||||||
|
* `&&`.
|
||||||
*/
|
*/
|
||||||
fun <T : Event> observeNewEvents(filter: Filter): Flow<T> =
|
fun <T : Event> observeNewEvents(predicate: (Event) -> Boolean): Flow<T> =
|
||||||
callbackFlow {
|
callbackFlow {
|
||||||
val newFilter =
|
val newFilter =
|
||||||
NewEventMatchingFilter<T>(filter) {
|
NewEventMatchingFilter<T>(predicate) {
|
||||||
trySend(it)
|
trySend(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,6 +408,8 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun <T : Event> observeNewEvents(filter: Filter): Flow<T> = observeNewEvents(filter::match)
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
fun <T : Event> observeLatestEvent(filter: Filter) = observeEvents<T>(filter).map { it.firstOrNull() }
|
fun <T : Event> observeLatestEvent(filter: Filter) = observeEvents<T>(filter).map { it.firstOrNull() }
|
||||||
|
|
||||||
|
|||||||
+37
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.amethyst.model.accountsCache
|
package com.vitorpamplona.amethyst.model.accountsCache
|
||||||
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
|
import com.vitorpamplona.amethyst.LocalPreferences
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
@@ -68,6 +69,42 @@ class AccountCacheState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads every saved account that can sign (has a private key or an external signer)
|
||||||
|
* into the cache. Safe to call repeatedly — [loadAccount] is idempotent, so already
|
||||||
|
* loaded accounts are returned as-is. Used by the always-on notification service so
|
||||||
|
* GiftWraps addressed to non-active accounts still get unwrapped and notified.
|
||||||
|
*/
|
||||||
|
suspend fun loadAllWritableAccounts(localPreferences: LocalPreferences) {
|
||||||
|
localPreferences.allSavedAccounts().forEach { savedAccount ->
|
||||||
|
if (!savedAccount.hasPrivKey && !savedAccount.loggedInWithExternalSigner) return@forEach
|
||||||
|
try {
|
||||||
|
val accountSettings = localPreferences.loadAccountConfigFromEncryptedStorage(savedAccount.npub) ?: return@forEach
|
||||||
|
loadAccount(accountSettings)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||||
|
Log.w("AccountCacheState", "Failed to preload account ${savedAccount.npub}: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels and removes every cached account whose pubkey is not in [keepPubkeys].
|
||||||
|
* Used to release accounts that were preloaded for background notification handling
|
||||||
|
* when the always-on service is turned off, while preserving the active account.
|
||||||
|
*/
|
||||||
|
fun retainOnly(keepPubkeys: Set<HexKey>) {
|
||||||
|
accounts.update { existingAccounts ->
|
||||||
|
val toRemove = existingAccounts.filterKeys { it !in keepPubkeys }
|
||||||
|
if (toRemove.isEmpty()) {
|
||||||
|
existingAccounts
|
||||||
|
} else {
|
||||||
|
toRemove.values.forEach { it.scope.cancel() }
|
||||||
|
existingAccounts.minus(toRemove.keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun loadAccount(accountSettings: AccountSettings): Account =
|
fun loadAccount(accountSettings: AccountSettings): Account =
|
||||||
loadAccount(
|
loadAccount(
|
||||||
signer =
|
signer =
|
||||||
|
|||||||
+61
@@ -21,8 +21,12 @@
|
|||||||
package com.vitorpamplona.amethyst.service.notifications
|
package com.vitorpamplona.amethyst.service.notifications
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import com.vitorpamplona.amethyst.LocalPreferences
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
@@ -40,16 +44,27 @@ import kotlinx.coroutines.launch
|
|||||||
* When enabled, all layers activate. When disabled, all layers deactivate.
|
* When enabled, all layers activate. When disabled, all layers deactivate.
|
||||||
* The manager watches the account's alwaysOnNotificationService setting
|
* The manager watches the account's alwaysOnNotificationService setting
|
||||||
* and reacts to changes in real time.
|
* and reacts to changes in real time.
|
||||||
|
*
|
||||||
|
* While enabled, every saved writable account is kept loaded in
|
||||||
|
* [AccountCacheState] so GiftWraps addressed to any of them (delivered via
|
||||||
|
* open relay subscriptions) get unwrapped by the owning account's
|
||||||
|
* `newNotesPreProcessor`. Without this, wraps for non-active accounts would
|
||||||
|
* sit in [com.vitorpamplona.amethyst.model.LocalCache] with no subscriber
|
||||||
|
* able to decrypt them.
|
||||||
*/
|
*/
|
||||||
class AlwaysOnNotificationServiceManager(
|
class AlwaysOnNotificationServiceManager(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
|
private val accountsCache: AccountCacheState,
|
||||||
|
private val localPreferences: LocalPreferences,
|
||||||
|
private val activePubKeyProvider: () -> HexKey?,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "AlwaysOnNotifManager"
|
private const val TAG = "AlwaysOnNotifManager"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var watchJob: Job? = null
|
private var watchJob: Job? = null
|
||||||
|
private var preloadJob: Job? = null
|
||||||
private var wasEnabled = false
|
private var wasEnabled = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,6 +91,8 @@ class AlwaysOnNotificationServiceManager(
|
|||||||
fun stop() {
|
fun stop() {
|
||||||
watchJob?.cancel()
|
watchJob?.cancel()
|
||||||
watchJob = null
|
watchJob = null
|
||||||
|
preloadJob?.cancel()
|
||||||
|
preloadJob = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enableAllLayers() {
|
private fun enableAllLayers() {
|
||||||
@@ -91,6 +108,8 @@ class AlwaysOnNotificationServiceManager(
|
|||||||
ServiceWatchdogManager.schedule(context)
|
ServiceWatchdogManager.schedule(context)
|
||||||
|
|
||||||
// L2 (FCM) and L4 (BOOT_COMPLETED) are always active via manifest
|
// L2 (FCM) and L4 (BOOT_COMPLETED) are always active via manifest
|
||||||
|
|
||||||
|
startMultiAccountPreload()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun disableAllLayers() {
|
private fun disableAllLayers() {
|
||||||
@@ -104,5 +123,47 @@ class AlwaysOnNotificationServiceManager(
|
|||||||
|
|
||||||
// L5: Cancel watchdog alarm
|
// L5: Cancel watchdog alarm
|
||||||
ServiceWatchdogManager.cancel(context)
|
ServiceWatchdogManager.cancel(context)
|
||||||
|
|
||||||
|
stopMultiAccountPreload()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preloads every saved writable account into [AccountCacheState] and keeps the set
|
||||||
|
* in sync by observing [LocalPreferences.accountsFlow]. New accounts added while
|
||||||
|
* the service is enabled (login flow) are picked up automatically.
|
||||||
|
*
|
||||||
|
* Note: the first [LocalPreferences.accountsFlow] emission is `null` (lazily
|
||||||
|
* populated). We still call [AccountCacheState.loadAllWritableAccounts] on every
|
||||||
|
* emission — its suspend call to `allSavedAccounts()` triggers flow population,
|
||||||
|
* and subsequent [loadAccount] calls are idempotent on already-loaded accounts.
|
||||||
|
*/
|
||||||
|
private fun startMultiAccountPreload() {
|
||||||
|
preloadJob?.cancel()
|
||||||
|
preloadJob =
|
||||||
|
scope.launch {
|
||||||
|
localPreferences.accountsFlow().collect {
|
||||||
|
try {
|
||||||
|
accountsCache.loadAllWritableAccounts(localPreferences)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
Log.w(TAG, "Multi-account preload failed: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels the preload collector and releases every cached account except the
|
||||||
|
* currently active one, so users with the setting off return to single-account
|
||||||
|
* memory/battery footprint.
|
||||||
|
*/
|
||||||
|
private fun stopMultiAccountPreload() {
|
||||||
|
preloadJob?.cancel()
|
||||||
|
preloadJob = null
|
||||||
|
// remove this because we don't know which other accounts might be getting used.
|
||||||
|
// val active = activePubKeyProvider()
|
||||||
|
// if (active != null) {
|
||||||
|
// accountsCache.retainOnly(setOf(active))
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+400
-221
@@ -37,9 +37,12 @@ import com.vitorpamplona.amethyst.model.Account
|
|||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
|
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
|
||||||
|
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.InlineReplyTarget
|
||||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendChessNotification
|
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendChessNotification
|
||||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
|
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
|
||||||
|
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendMentionNotification
|
||||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReactionNotification
|
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReactionNotification
|
||||||
|
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReplyNotification
|
||||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
|
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount
|
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
||||||
@@ -51,20 +54,34 @@ import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
|
|||||||
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
|
|
||||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||||
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||||
|
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||||
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
|
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
|
||||||
|
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||||
|
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||||
|
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||||
|
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||||
|
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||||
|
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||||
import com.vitorpamplona.quartz.nip64Chess.baseEvent.BaseChessEvent
|
import com.vitorpamplona.quartz.nip64Chess.baseEvent.BaseChessEvent
|
||||||
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
|
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
|
||||||
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
|
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
|
||||||
|
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||||
|
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||||
|
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||||
|
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
|
||||||
|
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
|
||||||
|
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||||
|
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
|
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
@@ -129,15 +146,15 @@ class EventNotificationConsumer(
|
|||||||
|
|
||||||
if (!notificationManager().areNotificationsEnabled()) return@withWakeLock
|
if (!notificationManager().areNotificationsEnabled()) return@withWakeLock
|
||||||
|
|
||||||
val taggedNpubs =
|
// Ask the event which of our signing accounts it's notifying.
|
||||||
event
|
// Each kind declares its own notification semantics in
|
||||||
.taggedUserIds()
|
// [Event.notifies] (lowercase `p` by default, NIP-22 also checks
|
||||||
.mapTo(mutableSetOf()) { LocalCache.getOrCreateUser(it).pubkeyNpub() }
|
// uppercase `P`, etc.), so we don't hard-code tag names here.
|
||||||
if (taggedNpubs.isEmpty()) return@withWakeLock
|
|
||||||
|
|
||||||
LocalPreferences.allSavedAccounts().forEach { savedAccount ->
|
LocalPreferences.allSavedAccounts().forEach { savedAccount ->
|
||||||
if (!savedAccount.hasPrivKey && !savedAccount.loggedInWithExternalSigner) return@forEach
|
if (!savedAccount.hasPrivKey && !savedAccount.loggedInWithExternalSigner) return@forEach
|
||||||
if (savedAccount.npub !in taggedNpubs) return@forEach
|
|
||||||
|
val accountHex = npubToHexOrNull(savedAccount.npub) ?: return@forEach
|
||||||
|
if (!event.notifies(accountHex)) return@forEach
|
||||||
|
|
||||||
val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(savedAccount.npub) ?: return@forEach
|
val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(savedAccount.npub) ?: return@forEach
|
||||||
try {
|
try {
|
||||||
@@ -150,11 +167,19 @@ class EventNotificationConsumer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun npubToHexOrNull(npub: String): String? =
|
||||||
|
runCatching { npub.bechToBytes("npub").toHexKey() }
|
||||||
|
.onFailure { Log.d(TAG) { "Skipping non-decodable npub $npub: ${it.message}" } }
|
||||||
|
.getOrNull()
|
||||||
|
|
||||||
private suspend fun dispatchForAccount(
|
private suspend fun dispatchForAccount(
|
||||||
event: Event,
|
event: Event,
|
||||||
account: Account,
|
account: Account,
|
||||||
) {
|
) {
|
||||||
// Calls and wake-ups are high-priority and always notify, even when MainActivity is visible.
|
// Calls and wake-ups are high-priority and always notify, even when MainActivity is visible.
|
||||||
|
// They have their own freshness rules (CallManager.MAX_EVENT_AGE_SECONDS = 20s) and
|
||||||
|
// author-identity semantics (caller pubkey is the other party), so they bypass the
|
||||||
|
// shared gates below.
|
||||||
when (event) {
|
when (event) {
|
||||||
is CallOfferEvent -> {
|
is CallOfferEvent -> {
|
||||||
notifyIncomingCall(event, account)
|
notifyIncomingCall(event, account)
|
||||||
@@ -170,13 +195,45 @@ class EventNotificationConsumer(
|
|||||||
// Everything else is suppressed while the user is actively on the home screen.
|
// Everything else is suppressed while the user is actively on the home screen.
|
||||||
if (MainActivity.isResumed) return
|
if (MainActivity.isResumed) return
|
||||||
|
|
||||||
|
// Shared per-account gate: don't push-notify events this account authored.
|
||||||
|
// Applied here (not at the observer) because in a multi-account session
|
||||||
|
// account A's outgoing event legitimately becomes account B's incoming
|
||||||
|
// notification on the same device. The observer already enforces the
|
||||||
|
// 15-min rolling age window, so individual notify() methods don't need
|
||||||
|
// to repeat either check.
|
||||||
|
if (event.pubKey == account.signer.pubKey) return
|
||||||
|
|
||||||
when (event) {
|
when (event) {
|
||||||
is PrivateDmEvent -> notify(event, account)
|
is PrivateDmEvent -> notify(event, account)
|
||||||
|
|
||||||
is LnZapEvent -> notify(event, account)
|
is LnZapEvent -> notify(event, account)
|
||||||
|
|
||||||
is ChatMessageEvent -> notify(event, account)
|
is ChatMessageEvent -> notify(event, account)
|
||||||
|
|
||||||
is ChatMessageEncryptedFileHeaderEvent -> notify(event, account)
|
is ChatMessageEncryptedFileHeaderEvent -> notify(event, account)
|
||||||
|
|
||||||
is ReactionEvent -> notify(event, account)
|
is ReactionEvent -> notify(event, account)
|
||||||
|
|
||||||
|
is TextNoteEvent -> notify(event, account)
|
||||||
|
|
||||||
|
is CommentEvent -> notify(event, account)
|
||||||
|
|
||||||
|
is PictureEvent,
|
||||||
|
is VideoNormalEvent,
|
||||||
|
is VideoShortEvent,
|
||||||
|
is VideoHorizontalEvent,
|
||||||
|
is VideoVerticalEvent,
|
||||||
|
is ChannelMessageEvent,
|
||||||
|
is PollEvent,
|
||||||
|
is GitPatchEvent,
|
||||||
|
is GitIssueEvent,
|
||||||
|
is HighlightEvent,
|
||||||
|
is LongTextNoteEvent,
|
||||||
|
is WikiNoteEvent,
|
||||||
|
-> notifyMention(event, account)
|
||||||
|
|
||||||
is LiveChessGameAcceptEvent -> notifyChessEvent(event, account, R.string.app_notification_chess_challenge_accepted)
|
is LiveChessGameAcceptEvent -> notifyChessEvent(event, account, R.string.app_notification_chess_challenge_accepted)
|
||||||
|
|
||||||
is LiveChessMoveEvent -> notifyChessEvent(event, account, R.string.app_notification_chess_your_turn)
|
is LiveChessMoveEvent -> notifyChessEvent(event, account, R.string.app_notification_chess_your_turn)
|
||||||
// WelcomeEvent is dispatched directly from processMarmotWelcomeFlow
|
// WelcomeEvent is dispatched directly from processMarmotWelcomeFlow
|
||||||
// (no `p` tag, so tag-based matching doesn't work).
|
// (no `p` tag, so tag-based matching doesn't work).
|
||||||
@@ -257,50 +314,42 @@ class EventNotificationConsumer(
|
|||||||
account: Account,
|
account: Account,
|
||||||
) {
|
) {
|
||||||
Log.d(TAG, "New ChatMessage File to Notify")
|
Log.d(TAG, "New ChatMessage File to Notify")
|
||||||
if (
|
// Age + self-author gates run centrally in dispatchForAccount.
|
||||||
// old event being re-broadcasted
|
val chatroomList = LocalCache.getOrCreateChatroomList(account.signer.pubKey)
|
||||||
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
|
val chatNote = LocalCache.getNoteIfExists(event.id) ?: return
|
||||||
// don't display if it comes from me.
|
val chatRoom = event.chatroomKey(account.signer.pubKey)
|
||||||
event.pubKey != account.signer.pubKey
|
|
||||||
) { // from the user
|
|
||||||
Log.d(TAG, "Notifying")
|
|
||||||
val chatroomList = LocalCache.getOrCreateChatroomList(account.signer.pubKey)
|
|
||||||
val chatNote = LocalCache.getNoteIfExists(event.id) ?: return
|
|
||||||
val chatRoom = event.chatroomKey(account.signer.pubKey)
|
|
||||||
|
|
||||||
val followingKeySet = account.followingKeySet()
|
val followingKeySet = account.followingKeySet()
|
||||||
|
|
||||||
val isKnownRoom =
|
val isKnownRoom =
|
||||||
(
|
chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true ||
|
||||||
chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || chatroomList.hasSentMessagesTo(chatRoom)
|
chatroomList.hasSentMessagesTo(chatRoom)
|
||||||
)
|
|
||||||
|
|
||||||
if (isKnownRoom) {
|
if (!isKnownRoom) return
|
||||||
val content = chatNote.event?.content ?: ""
|
|
||||||
val user = chatNote.author?.toBestDisplayName() ?: ""
|
|
||||||
val userPicture = chatNote.author?.profilePicture()
|
|
||||||
val accountNpub =
|
|
||||||
account.signer.pubKey
|
|
||||||
.hexToByteArray()
|
|
||||||
.toNpub()
|
|
||||||
val chatroomMembers = chatRoom.users.joinToString(",")
|
|
||||||
val noteUri = chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
|
|
||||||
|
|
||||||
notificationManager()
|
val content = chatNote.event?.content ?: ""
|
||||||
.sendDMNotification(
|
val user = chatNote.author?.toBestDisplayName() ?: ""
|
||||||
event.id,
|
val userPicture = chatNote.author?.profilePicture()
|
||||||
content,
|
val accountNpub =
|
||||||
user,
|
account.signer.pubKey
|
||||||
event.createdAt,
|
.hexToByteArray()
|
||||||
userPicture,
|
.toNpub()
|
||||||
noteUri,
|
val chatroomMembers = chatRoom.users.joinToString(",")
|
||||||
applicationContext,
|
val noteUri = chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
|
||||||
accountNpub = accountNpub,
|
|
||||||
accountPictureUrl = account.userProfile().profilePicture(),
|
notificationManager()
|
||||||
chatroomMembers = chatroomMembers,
|
.sendDMNotification(
|
||||||
)
|
event.id,
|
||||||
}
|
content,
|
||||||
}
|
user,
|
||||||
|
event.createdAt,
|
||||||
|
userPicture,
|
||||||
|
noteUri,
|
||||||
|
applicationContext,
|
||||||
|
accountNpub = accountNpub,
|
||||||
|
accountPictureUrl = account.userProfile().profilePicture(),
|
||||||
|
chatroomMembers = chatroomMembers,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun notify(
|
private suspend fun notify(
|
||||||
@@ -308,47 +357,42 @@ class EventNotificationConsumer(
|
|||||||
account: Account,
|
account: Account,
|
||||||
) {
|
) {
|
||||||
Log.d(TAG, "New ChatMessage to Notify")
|
Log.d(TAG, "New ChatMessage to Notify")
|
||||||
if (
|
// Age + self-author gates run centrally in dispatchForAccount.
|
||||||
// old event being re-broadcasted
|
val chatroomList = LocalCache.getOrCreateChatroomList(account.signer.pubKey)
|
||||||
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
|
val chatNote = LocalCache.getNoteIfExists(event.id) ?: return
|
||||||
// don't display if it comes from me.
|
val chatRoom = event.chatroomKey(account.signer.pubKey)
|
||||||
event.pubKey != account.signer.pubKey
|
|
||||||
) { // from the user
|
|
||||||
Log.d(TAG, "Notifying")
|
|
||||||
val chatroomList = LocalCache.getOrCreateChatroomList(account.signer.pubKey)
|
|
||||||
val chatNote = LocalCache.getNoteIfExists(event.id) ?: return
|
|
||||||
val chatRoom = event.chatroomKey(account.signer.pubKey)
|
|
||||||
|
|
||||||
val followingKeySet = account.followingKeySet()
|
val followingKeySet = account.followingKeySet()
|
||||||
|
|
||||||
val isKnownRoom = chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || chatroomList.hasSentMessagesTo(chatRoom)
|
val isKnownRoom =
|
||||||
|
chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true ||
|
||||||
|
chatroomList.hasSentMessagesTo(chatRoom)
|
||||||
|
|
||||||
if (isKnownRoom) {
|
if (!isKnownRoom) return
|
||||||
val content = chatNote.event?.content ?: ""
|
|
||||||
val user = chatNote.author?.toBestDisplayName() ?: ""
|
|
||||||
val userPicture = chatNote.author?.profilePicture()
|
|
||||||
val accountNpub =
|
|
||||||
account.signer.pubKey
|
|
||||||
.hexToByteArray()
|
|
||||||
.toNpub()
|
|
||||||
val chatroomMembers = chatRoom.users.joinToString(",")
|
|
||||||
val noteUri = chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
|
|
||||||
|
|
||||||
notificationManager()
|
val content = chatNote.event?.content ?: ""
|
||||||
.sendDMNotification(
|
val user = chatNote.author?.toBestDisplayName() ?: ""
|
||||||
id = event.id,
|
val userPicture = chatNote.author?.profilePicture()
|
||||||
messageBody = content,
|
val accountNpub =
|
||||||
senderName = user,
|
account.signer.pubKey
|
||||||
time = event.createdAt,
|
.hexToByteArray()
|
||||||
pictureUrl = userPicture,
|
.toNpub()
|
||||||
uri = noteUri,
|
val chatroomMembers = chatRoom.users.joinToString(",")
|
||||||
applicationContext = applicationContext,
|
val noteUri = chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
|
||||||
accountNpub = accountNpub,
|
|
||||||
accountPictureUrl = account.userProfile().profilePicture(),
|
notificationManager()
|
||||||
chatroomMembers = chatroomMembers,
|
.sendDMNotification(
|
||||||
)
|
id = event.id,
|
||||||
}
|
messageBody = content,
|
||||||
}
|
senderName = user,
|
||||||
|
time = event.createdAt,
|
||||||
|
pictureUrl = userPicture,
|
||||||
|
uri = noteUri,
|
||||||
|
applicationContext = applicationContext,
|
||||||
|
accountNpub = accountNpub,
|
||||||
|
accountPictureUrl = account.userProfile().profilePicture(),
|
||||||
|
chatroomMembers = chatroomMembers,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun notify(
|
private suspend fun notify(
|
||||||
@@ -356,47 +400,48 @@ class EventNotificationConsumer(
|
|||||||
account: Account,
|
account: Account,
|
||||||
) {
|
) {
|
||||||
Log.d(TAG, "New Nip-04 DM to Notify")
|
Log.d(TAG, "New Nip-04 DM to Notify")
|
||||||
// old event being re-broadcast
|
// Age + self-author gates run centrally in dispatchForAccount. The
|
||||||
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
|
// dispatchForAccount self-check (event.pubKey != account.signer.pubKey)
|
||||||
|
// also covers the "don't notify myself about DMs I sent" case that
|
||||||
|
// was previously implicit via the recipient match below.
|
||||||
|
if (account.signer.pubKey != event.verifiedRecipientPubKey()) return
|
||||||
|
|
||||||
if (account.signer.pubKey == event.verifiedRecipientPubKey()) {
|
val note = LocalCache.getNoteIfExists(event.id) ?: return
|
||||||
val note = LocalCache.getNoteIfExists(event.id) ?: return
|
val chatroomList = LocalCache.getOrCreateChatroomList(account.signer.pubKey)
|
||||||
val chatroomList = LocalCache.getOrCreateChatroomList(account.signer.pubKey)
|
|
||||||
|
|
||||||
val followingKeySet = account.followingKeySet()
|
val followingKeySet = account.followingKeySet()
|
||||||
|
|
||||||
val chatRoom = event.chatroomKey(account.signer.pubKey)
|
val chatRoom = event.chatroomKey(account.signer.pubKey)
|
||||||
|
|
||||||
val isKnownRoom = chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true || chatroomList.hasSentMessagesTo(chatRoom)
|
val isKnownRoom =
|
||||||
|
chatroomList.rooms.get(chatRoom)?.senderIntersects(followingKeySet) == true ||
|
||||||
|
chatroomList.hasSentMessagesTo(chatRoom)
|
||||||
|
|
||||||
if (isKnownRoom) {
|
if (!isKnownRoom) return
|
||||||
note.author?.let {
|
|
||||||
decryptContent(note, account.signer)?.let { content ->
|
|
||||||
val user = note.author?.toBestDisplayName() ?: ""
|
|
||||||
val userPicture = note.author?.profilePicture()
|
|
||||||
val accountNpub =
|
|
||||||
account.signer.pubKey
|
|
||||||
.hexToByteArray()
|
|
||||||
.toNpub()
|
|
||||||
val noteUri = note.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
|
|
||||||
|
|
||||||
notificationManager()
|
val author = note.author ?: return
|
||||||
.sendDMNotification(
|
val content = decryptContent(note, account.signer) ?: return
|
||||||
id = event.id,
|
val user = author.toBestDisplayName()
|
||||||
messageBody = content,
|
val userPicture = author.profilePicture()
|
||||||
senderName = user,
|
val accountNpub =
|
||||||
time = event.createdAt,
|
account.signer.pubKey
|
||||||
pictureUrl = userPicture,
|
.hexToByteArray()
|
||||||
uri = noteUri,
|
.toNpub()
|
||||||
applicationContext = applicationContext,
|
val noteUri = note.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
|
||||||
accountNpub = accountNpub,
|
|
||||||
accountPictureUrl = account.userProfile().profilePicture(),
|
notificationManager()
|
||||||
chatroomMembers = null,
|
.sendDMNotification(
|
||||||
)
|
id = event.id,
|
||||||
}
|
messageBody = content,
|
||||||
}
|
senderName = user,
|
||||||
}
|
time = event.createdAt,
|
||||||
}
|
pictureUrl = userPicture,
|
||||||
|
uri = noteUri,
|
||||||
|
applicationContext = applicationContext,
|
||||||
|
accountNpub = accountNpub,
|
||||||
|
accountPictureUrl = account.userProfile().profilePicture(),
|
||||||
|
chatroomMembers = null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -488,13 +533,10 @@ class EventNotificationConsumer(
|
|||||||
Log.d(TAG) { "Notify Start ${event.toNostrUri()}" }
|
Log.d(TAG) { "Notify Start ${event.toNostrUri()}" }
|
||||||
LocalCache.getNoteIfExists(event.id) ?: return
|
LocalCache.getNoteIfExists(event.id) ?: return
|
||||||
|
|
||||||
Log.d(TAG, "Notify Not Notified Yet")
|
// Age + self-author gates run centrally in dispatchForAccount. For zaps
|
||||||
|
// the self-check is effectively a no-op (receipts are signed by the LN
|
||||||
// old event being re-broadcast
|
// service, not the zapper) but the uniform rule is cheap and keeps the
|
||||||
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
|
// downstream invariants simple.
|
||||||
|
|
||||||
Log.d(TAG, "Notify Not an old event")
|
|
||||||
|
|
||||||
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
|
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
|
||||||
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
|
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
|
||||||
|
|
||||||
@@ -504,79 +546,44 @@ class EventNotificationConsumer(
|
|||||||
|
|
||||||
Log.d(TAG, "Notify Amount Bigger than 10")
|
Log.d(TAG, "Notify Amount Bigger than 10")
|
||||||
|
|
||||||
if (event.isTaggedUser(account.signer.pubKey)) {
|
// Zap routing (recipient == account) is enforced by the dispatcher
|
||||||
val amount = showAmount(event.amount)
|
// predicate + consumeFromCache via Event.notifies; no re-check here.
|
||||||
|
val amount = showAmount(event.amount)
|
||||||
|
|
||||||
Log.d(TAG) { "Notify Amount $amount" }
|
Log.d(TAG) { "Notify Amount $amount" }
|
||||||
|
|
||||||
(noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
|
(noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
|
||||||
decryptZapContentAuthor(event, account.signer)?.let { decryptedEvent ->
|
decryptZapContentAuthor(event, account.signer)?.let { decryptedEvent ->
|
||||||
Log.d(TAG) { "Notify Decrypted if Private Zap ${event.id}" }
|
Log.d(TAG) { "Notify Decrypted if Private Zap ${event.id}" }
|
||||||
|
|
||||||
val author = LocalCache.getOrCreateUser(decryptedEvent.pubKey)
|
val author = LocalCache.getOrCreateUser(decryptedEvent.pubKey)
|
||||||
val senderInfo = Pair(author, decryptedEvent.content.ifBlank { null })
|
val senderInfo = Pair(author, decryptedEvent.content.ifBlank { null })
|
||||||
|
|
||||||
if (noteZapped.event?.content != null) {
|
if (noteZapped.event?.content != null) {
|
||||||
decryptContent(noteZapped, account.signer)?.let { decrypted ->
|
decryptContent(noteZapped, account.signer)?.let { decrypted ->
|
||||||
Log.d(TAG, "Notify Decrypted if Private Note")
|
Log.d(TAG, "Notify Decrypted if Private Note")
|
||||||
|
|
||||||
val zappedContent = decrypted.split("\n")[0]
|
val zappedContent = decrypted.split("\n")[0]
|
||||||
|
|
||||||
val user = senderInfo.first.toBestDisplayName()
|
|
||||||
var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount)
|
|
||||||
senderInfo.second?.ifBlank { null }?.let { title += " ($it)" }
|
|
||||||
|
|
||||||
var content =
|
|
||||||
stringRes(
|
|
||||||
applicationContext,
|
|
||||||
R.string.app_notification_zaps_channel_message_from,
|
|
||||||
user,
|
|
||||||
)
|
|
||||||
zappedContent.let {
|
|
||||||
content +=
|
|
||||||
" " +
|
|
||||||
stringRes(
|
|
||||||
applicationContext,
|
|
||||||
R.string.app_notification_zaps_channel_message_for,
|
|
||||||
zappedContent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val userPicture = senderInfo.first.profilePicture()
|
|
||||||
val noteUri =
|
|
||||||
"notifications$ACCOUNT_QUERY_PARAM" +
|
|
||||||
account.signer.pubKey
|
|
||||||
.hexToByteArray()
|
|
||||||
.toNpub() +
|
|
||||||
SCROLL_TO_QUERY_PARAM + event.id
|
|
||||||
|
|
||||||
Log.d(TAG) { "Notify ${event.id} $content $title $noteUri" }
|
|
||||||
|
|
||||||
notificationManager()
|
|
||||||
.sendZapNotification(
|
|
||||||
event.id,
|
|
||||||
content,
|
|
||||||
title,
|
|
||||||
event.createdAt,
|
|
||||||
userPicture,
|
|
||||||
noteUri,
|
|
||||||
applicationContext,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// doesn't have a base note to refer to.
|
|
||||||
Log.d(TAG, "Notify Zapped note not available")
|
|
||||||
|
|
||||||
val user = senderInfo.first.toBestDisplayName()
|
val user = senderInfo.first.toBestDisplayName()
|
||||||
var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount)
|
var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount)
|
||||||
senderInfo.second?.ifBlank { null }?.let { title += " ($it)" }
|
senderInfo.second?.ifBlank { null }?.let { title += " ($it)" }
|
||||||
|
|
||||||
val content =
|
var content =
|
||||||
stringRes(
|
stringRes(
|
||||||
applicationContext,
|
applicationContext,
|
||||||
R.string.app_notification_zaps_channel_message_from,
|
R.string.app_notification_zaps_channel_message_from,
|
||||||
user,
|
user,
|
||||||
)
|
)
|
||||||
|
zappedContent.let {
|
||||||
|
content +=
|
||||||
|
" " +
|
||||||
|
stringRes(
|
||||||
|
applicationContext,
|
||||||
|
R.string.app_notification_zaps_channel_message_for,
|
||||||
|
zappedContent,
|
||||||
|
)
|
||||||
|
}
|
||||||
val userPicture = senderInfo.first.profilePicture()
|
val userPicture = senderInfo.first.profilePicture()
|
||||||
val noteUri =
|
val noteUri =
|
||||||
"notifications$ACCOUNT_QUERY_PARAM" +
|
"notifications$ACCOUNT_QUERY_PARAM" +
|
||||||
@@ -585,7 +592,7 @@ class EventNotificationConsumer(
|
|||||||
.toNpub() +
|
.toNpub() +
|
||||||
SCROLL_TO_QUERY_PARAM + event.id
|
SCROLL_TO_QUERY_PARAM + event.id
|
||||||
|
|
||||||
Log.d(TAG) { "Notify ${event.id} $title $noteUri" }
|
Log.d(TAG) { "Notify ${event.id} $content $title $noteUri" }
|
||||||
|
|
||||||
notificationManager()
|
notificationManager()
|
||||||
.sendZapNotification(
|
.sendZapNotification(
|
||||||
@@ -598,6 +605,41 @@ class EventNotificationConsumer(
|
|||||||
applicationContext,
|
applicationContext,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// doesn't have a base note to refer to.
|
||||||
|
Log.d(TAG, "Notify Zapped note not available")
|
||||||
|
|
||||||
|
val user = senderInfo.first.toBestDisplayName()
|
||||||
|
var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount)
|
||||||
|
senderInfo.second?.ifBlank { null }?.let { title += " ($it)" }
|
||||||
|
|
||||||
|
val content =
|
||||||
|
stringRes(
|
||||||
|
applicationContext,
|
||||||
|
R.string.app_notification_zaps_channel_message_from,
|
||||||
|
user,
|
||||||
|
)
|
||||||
|
|
||||||
|
val userPicture = senderInfo.first.profilePicture()
|
||||||
|
val noteUri =
|
||||||
|
"notifications$ACCOUNT_QUERY_PARAM" +
|
||||||
|
account.signer.pubKey
|
||||||
|
.hexToByteArray()
|
||||||
|
.toNpub() +
|
||||||
|
SCROLL_TO_QUERY_PARAM + event.id
|
||||||
|
|
||||||
|
Log.d(TAG) { "Notify ${event.id} $title $noteUri" }
|
||||||
|
|
||||||
|
notificationManager()
|
||||||
|
.sendZapNotification(
|
||||||
|
event.id,
|
||||||
|
content,
|
||||||
|
title,
|
||||||
|
event.createdAt,
|
||||||
|
userPicture,
|
||||||
|
noteUri,
|
||||||
|
applicationContext,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -609,14 +651,9 @@ class EventNotificationConsumer(
|
|||||||
) {
|
) {
|
||||||
Log.d(TAG, "New Reaction to Notify")
|
Log.d(TAG, "New Reaction to Notify")
|
||||||
|
|
||||||
// old event being re-broadcast
|
// Age + self-author gates run centrally in dispatchForAccount.
|
||||||
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
|
// p-tag match already enforced by consumeFromCache; no redundant
|
||||||
|
// isTaggedUser re-check needed.
|
||||||
// don't notify for own reactions
|
|
||||||
if (event.pubKey == account.signer.pubKey) return
|
|
||||||
|
|
||||||
// only notify if the reaction is for the current user
|
|
||||||
if (!event.isTaggedUser(account.signer.pubKey)) return
|
|
||||||
|
|
||||||
val reactedPostId = event.originalPost().firstOrNull() ?: return
|
val reactedPostId = event.originalPost().firstOrNull() ?: return
|
||||||
val reactedNote = LocalCache.checkGetOrCreateNote(reactedPostId)
|
val reactedNote = LocalCache.checkGetOrCreateNote(reactedPostId)
|
||||||
@@ -676,38 +713,180 @@ class EventNotificationConsumer(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun notify(
|
||||||
|
event: TextNoteEvent,
|
||||||
|
account: Account,
|
||||||
|
) {
|
||||||
|
Log.d(TAG, "New TextNote to Notify")
|
||||||
|
// Age + self-author gates run centrally in dispatchForAccount.
|
||||||
|
|
||||||
|
val replyTargetId = event.replyingTo()
|
||||||
|
|
||||||
|
if (replyTargetId != null) {
|
||||||
|
val repliedNote = LocalCache.getNoteIfExists(replyTargetId)
|
||||||
|
if (repliedNote?.author?.pubkeyHex == account.signer.pubKey) {
|
||||||
|
val threadRoot = event.markedRoot()?.eventId ?: event.unmarkedRoot()?.eventId ?: replyTargetId
|
||||||
|
notifyReply(event, account, repliedNote.event?.content, threadRoot)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a reply to us but we're p-tagged — a mention or citation.
|
||||||
|
notifyMention(event, account)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun notify(
|
||||||
|
event: CommentEvent,
|
||||||
|
account: Account,
|
||||||
|
) {
|
||||||
|
Log.d(TAG, "New NIP-22 Comment to Notify")
|
||||||
|
// Age + self-author gates run centrally in dispatchForAccount.
|
||||||
|
|
||||||
|
// NIP-22 marks direct-reply and root authors. Notify when the current
|
||||||
|
// account is either (someone commenting on our post, or replying to our comment).
|
||||||
|
val pubKey = account.signer.pubKey
|
||||||
|
val isTarget = event.replyAuthorKeys().contains(pubKey) || event.rootAuthorKeys().contains(pubKey)
|
||||||
|
if (!isTarget) return
|
||||||
|
|
||||||
|
val parentContent =
|
||||||
|
event
|
||||||
|
.replyingTo()
|
||||||
|
?.let { LocalCache.getNoteIfExists(it)?.event?.content }
|
||||||
|
|
||||||
|
val threadRoot =
|
||||||
|
event.rootEventIds().firstOrNull()
|
||||||
|
?: event.rootAddressIds().firstOrNull()
|
||||||
|
?: event.replyingToAddressOrEvent()
|
||||||
|
?: event.id
|
||||||
|
|
||||||
|
notifyReply(event, account, parentContent, threadRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun notifyReply(
|
||||||
|
event: Event,
|
||||||
|
account: Account,
|
||||||
|
parentContent: String?,
|
||||||
|
threadRootId: String,
|
||||||
|
) {
|
||||||
|
val replyNote = LocalCache.getNoteIfExists(event.id) ?: return
|
||||||
|
|
||||||
|
val author = LocalCache.getOrCreateUser(event.pubKey)
|
||||||
|
val user = author.toBestDisplayName()
|
||||||
|
val userPicture = author.profilePicture()
|
||||||
|
|
||||||
|
val title = stringRes(applicationContext, R.string.app_notification_replies_channel_message, user)
|
||||||
|
|
||||||
|
val replyExcerpt =
|
||||||
|
event.content
|
||||||
|
.split("\n")
|
||||||
|
.firstOrNull { it.isNotBlank() }
|
||||||
|
?.take(280)
|
||||||
|
?: ""
|
||||||
|
|
||||||
|
val parentExcerpt =
|
||||||
|
parentContent
|
||||||
|
?.split("\n")
|
||||||
|
?.firstOrNull { it.isNotBlank() }
|
||||||
|
?.take(140)
|
||||||
|
|
||||||
|
val content =
|
||||||
|
if (!parentExcerpt.isNullOrBlank()) {
|
||||||
|
replyExcerpt + "\n\n" +
|
||||||
|
stringRes(
|
||||||
|
applicationContext,
|
||||||
|
R.string.app_notification_replies_channel_message_for,
|
||||||
|
parentExcerpt,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
replyExcerpt
|
||||||
|
}
|
||||||
|
|
||||||
|
val accountNpub =
|
||||||
|
account.signer.pubKey
|
||||||
|
.hexToByteArray()
|
||||||
|
.toNpub()
|
||||||
|
val noteUri = replyNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
|
||||||
|
|
||||||
|
notificationManager()
|
||||||
|
.sendReplyNotification(
|
||||||
|
id = event.id,
|
||||||
|
messageBody = content,
|
||||||
|
messageTitle = title,
|
||||||
|
time = event.createdAt,
|
||||||
|
pictureUrl = userPicture,
|
||||||
|
uri = noteUri,
|
||||||
|
applicationContext = applicationContext,
|
||||||
|
threadRootId = threadRootId,
|
||||||
|
inlineReply = InlineReplyTarget(accountNpub = accountNpub, targetEventId = event.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun notifyMention(
|
||||||
|
event: Event,
|
||||||
|
account: Account,
|
||||||
|
) {
|
||||||
|
// Age + self-author gates run centrally in dispatchForAccount.
|
||||||
|
val note = LocalCache.getNoteIfExists(event.id) ?: return
|
||||||
|
|
||||||
|
val author = LocalCache.getOrCreateUser(event.pubKey)
|
||||||
|
val user = author.toBestDisplayName()
|
||||||
|
val userPicture = author.profilePicture()
|
||||||
|
|
||||||
|
val title = stringRes(applicationContext, R.string.app_notification_mentions_channel_message, user)
|
||||||
|
|
||||||
|
val content =
|
||||||
|
event.content
|
||||||
|
.split("\n")
|
||||||
|
.firstOrNull { it.isNotBlank() }
|
||||||
|
?.take(280)
|
||||||
|
?: ""
|
||||||
|
|
||||||
|
val accountNpub =
|
||||||
|
account.signer.pubKey
|
||||||
|
.hexToByteArray()
|
||||||
|
.toNpub()
|
||||||
|
val noteUri = note.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
|
||||||
|
|
||||||
|
notificationManager()
|
||||||
|
.sendMentionNotification(
|
||||||
|
id = event.id,
|
||||||
|
messageBody = content,
|
||||||
|
messageTitle = title,
|
||||||
|
time = event.createdAt,
|
||||||
|
pictureUrl = userPicture,
|
||||||
|
uri = noteUri,
|
||||||
|
applicationContext = applicationContext,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun notifyChessEvent(
|
private suspend fun notifyChessEvent(
|
||||||
event: BaseChessEvent,
|
event: BaseChessEvent,
|
||||||
account: Account,
|
account: Account,
|
||||||
contentStringRes: Int,
|
contentStringRes: Int,
|
||||||
) {
|
) {
|
||||||
if (
|
// Age + self-author gates run centrally in dispatchForAccount.
|
||||||
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
|
val author = LocalCache.getOrCreateUser(event.pubKey)
|
||||||
event.pubKey != account.signer.pubKey
|
val user = author.toBestDisplayName()
|
||||||
) {
|
val userPicture = author.profilePicture()
|
||||||
val author = LocalCache.getOrCreateUser(event.pubKey)
|
val title = stringRes(applicationContext, R.string.app_notification_chess_channel_name)
|
||||||
val user = author.toBestDisplayName()
|
val content = stringRes(applicationContext, contentStringRes, user)
|
||||||
val userPicture = author.profilePicture()
|
val noteUri =
|
||||||
val title = stringRes(applicationContext, R.string.app_notification_chess_channel_name)
|
"notifications$ACCOUNT_QUERY_PARAM" +
|
||||||
val content = stringRes(applicationContext, contentStringRes, user)
|
account.signer.pubKey
|
||||||
val noteUri =
|
.hexToByteArray()
|
||||||
"notifications$ACCOUNT_QUERY_PARAM" +
|
.toNpub() +
|
||||||
account.signer.pubKey
|
SCROLL_TO_QUERY_PARAM + event.id
|
||||||
.hexToByteArray()
|
|
||||||
.toNpub() +
|
|
||||||
SCROLL_TO_QUERY_PARAM + event.id
|
|
||||||
|
|
||||||
notificationManager()
|
notificationManager()
|
||||||
.sendChessNotification(
|
.sendChessNotification(
|
||||||
event.id,
|
event.id,
|
||||||
content,
|
content,
|
||||||
title,
|
title,
|
||||||
event.createdAt,
|
event.createdAt,
|
||||||
userPicture,
|
userPicture,
|
||||||
noteUri,
|
noteUri,
|
||||||
applicationContext,
|
applicationContext,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun notifyIncomingCall(
|
private suspend fun notifyIncomingCall(
|
||||||
|
|||||||
+104
-11
@@ -21,24 +21,45 @@
|
|||||||
package com.vitorpamplona.amethyst.service.notifications
|
package com.vitorpamplona.amethyst.service.notifications
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import com.vitorpamplona.amethyst.LocalPreferences
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
|
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
|
||||||
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||||
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||||
|
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
|
||||||
|
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||||
|
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||||
|
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||||
|
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||||
|
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||||
|
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||||
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
|
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
|
||||||
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
|
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
|
||||||
|
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||||
|
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||||
|
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||||
|
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
|
||||||
|
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
|
||||||
|
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||||
|
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
|
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,12 +90,27 @@ class NotificationDispatcher(
|
|||||||
// consumeFromCache can't route it. It's delivered directly via
|
// consumeFromCache can't route it. It's delivered directly via
|
||||||
// [notifyWelcome] from processMarmotWelcomeFlow, which does know the
|
// [notifyWelcome] from processMarmotWelcomeFlow, which does know the
|
||||||
// recipient account.
|
// recipient account.
|
||||||
private val NOTIFICATION_KINDS =
|
private val NOTIFICATION_KINDS: Set<Int> =
|
||||||
listOf(
|
setOf(
|
||||||
// Direct-arrival
|
// Direct-arrival
|
||||||
PrivateDmEvent.KIND,
|
PrivateDmEvent.KIND,
|
||||||
LnZapEvent.KIND,
|
LnZapEvent.KIND,
|
||||||
ReactionEvent.KIND,
|
ReactionEvent.KIND,
|
||||||
|
TextNoteEvent.KIND,
|
||||||
|
CommentEvent.KIND,
|
||||||
|
// Public content kinds — routed to the Mentions channel when p-tagged.
|
||||||
|
PictureEvent.KIND,
|
||||||
|
VideoNormalEvent.KIND,
|
||||||
|
VideoShortEvent.KIND,
|
||||||
|
VideoHorizontalEvent.KIND,
|
||||||
|
VideoVerticalEvent.KIND,
|
||||||
|
ChannelMessageEvent.KIND,
|
||||||
|
PollEvent.KIND,
|
||||||
|
GitPatchEvent.KIND,
|
||||||
|
GitIssueEvent.KIND,
|
||||||
|
HighlightEvent.KIND,
|
||||||
|
LongTextNoteEvent.KIND,
|
||||||
|
WikiNoteEvent.KIND,
|
||||||
LiveChessGameAcceptEvent.KIND,
|
LiveChessGameAcceptEvent.KIND,
|
||||||
LiveChessMoveEvent.KIND,
|
LiveChessMoveEvent.KIND,
|
||||||
WakeUpEvent.KIND,
|
WakeUpEvent.KIND,
|
||||||
@@ -92,21 +128,78 @@ class NotificationDispatcher(
|
|||||||
fun start() {
|
fun start() {
|
||||||
if (job?.isActive == true) return
|
if (job?.isActive == true) return
|
||||||
Log.d(TAG, "Starting notification dispatcher")
|
Log.d(TAG, "Starting notification dispatcher")
|
||||||
|
|
||||||
|
// Only fire on events created after the dispatcher starts — equivalent
|
||||||
|
// to the relay protocol's `limit: 0` subscription semantics, so we
|
||||||
|
// don't retrigger on historical re-broadcasts of events the user has
|
||||||
|
// already seen. Captured once and shared across filter rebuilds
|
||||||
|
// triggered by account changes.
|
||||||
|
val dispatcherSince = TimeUtils.now()
|
||||||
|
|
||||||
job =
|
job =
|
||||||
scope.launch {
|
scope.launch {
|
||||||
LocalCache
|
// Ensure the saved-accounts StateFlow is primed from disk.
|
||||||
.observeNewEvents<Event>(Filter(kinds = NOTIFICATION_KINDS))
|
// accountsFlow() exposes the backing MutableStateFlow which
|
||||||
.collect { event ->
|
// starts as null and only populates on the first suspend read.
|
||||||
try {
|
LocalPreferences.allSavedAccounts()
|
||||||
consumer.consumeFromCache(event)
|
|
||||||
} catch (e: Exception) {
|
LocalPreferences
|
||||||
if (e is CancellationException) throw e
|
.accountsFlow()
|
||||||
Log.e(TAG, "Failed to dispatch notification for ${event.kind} ${event.id}", e)
|
.filterNotNull()
|
||||||
|
.map { accounts ->
|
||||||
|
accounts
|
||||||
|
.filter { it.hasPrivKey || it.loggedInWithExternalSigner }
|
||||||
|
.mapNotNullTo(mutableSetOf()) { npubToHexOrNull(it.npub) }
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
.collectLatest { pubkeys ->
|
||||||
|
if (pubkeys.isEmpty()) {
|
||||||
|
Log.d(TAG) { "No notifiable accounts; observer idle." }
|
||||||
|
return@collectLatest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log.d(TAG) { "Observing notifications for ${pubkeys.size} account(s)." }
|
||||||
|
|
||||||
|
// Single observer predicate. Each check is cheap and
|
||||||
|
// short-circuits so kind mismatch (by far the most
|
||||||
|
// common case) rejects before any allocation.
|
||||||
|
//
|
||||||
|
// - kind ∈ NOTIFICATION_KINDS — channel-relevant types
|
||||||
|
// - createdAt ≥ dispatcherSince — `limit: 0` semantics,
|
||||||
|
// drops re-broadcasts from before this session
|
||||||
|
// - createdAt ≥ fifteenMinutesAgo — rolling freshness,
|
||||||
|
// matches the downstream per-channel policy. Calls
|
||||||
|
// use a stricter 20s check in notifyIncomingCall so
|
||||||
|
// they still pass through.
|
||||||
|
// - event.notifies(pubkey) for any of our accounts —
|
||||||
|
// each kind decides which tag(s) name its recipients
|
||||||
|
// (lowercase `p` by default, plus uppercase `P` for
|
||||||
|
// NIP-22 root authors, etc.).
|
||||||
|
val predicate = { event: Event ->
|
||||||
|
event.kind in NOTIFICATION_KINDS &&
|
||||||
|
event.createdAt >= dispatcherSince &&
|
||||||
|
event.createdAt >= TimeUtils.fifteenMinutesAgo() &&
|
||||||
|
pubkeys.any { event.notifies(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalCache
|
||||||
|
.observeNewEvents<Event>(predicate)
|
||||||
|
.collect { event ->
|
||||||
|
try {
|
||||||
|
consumer.consumeFromCache(event)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
Log.e(TAG, "Failed to dispatch notification for ${event.kind} ${event.id}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun npubToHexOrNull(npub: String): String? =
|
||||||
|
runCatching { npub.bechToBytes("npub").toHexKey() }
|
||||||
|
.onFailure { Log.d(TAG) { "Skipping non-decodable npub $npub: ${it.message}" } }
|
||||||
|
.getOrNull()
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
job?.cancel()
|
job?.cancel()
|
||||||
job = null
|
job = null
|
||||||
|
|||||||
+90
-24
@@ -28,8 +28,12 @@ import androidx.core.app.RemoteInput
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.vitorpamplona.amethyst.Amethyst
|
import com.vitorpamplona.amethyst.Amethyst
|
||||||
import com.vitorpamplona.amethyst.LocalPreferences
|
import com.vitorpamplona.amethyst.LocalPreferences
|
||||||
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||||
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||||
|
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -69,32 +73,56 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
|||||||
|
|
||||||
if (members.isEmpty()) return
|
if (members.isEmpty()) return
|
||||||
|
|
||||||
val pendingResult = goAsync()
|
runOnRelay(notificationManager, notificationId) {
|
||||||
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
sendReply(accountNpub, members, replyText)
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
// activates the relay to send the message.
|
|
||||||
val collectionJob =
|
|
||||||
scope.launch {
|
|
||||||
Amethyst.instance.relayProxyClientConnector.relayServices
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
sendReply(accountNpub, members, replyText)
|
|
||||||
notificationManager.cancel(notificationId)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (e is CancellationException) throw e
|
|
||||||
Log.e("NotificationReply") { "Failed to send reply: ${e.message}" }
|
|
||||||
} finally {
|
|
||||||
pendingResult.finish()
|
|
||||||
|
|
||||||
// closes the relay connection.
|
|
||||||
collectionJob.cancel()
|
|
||||||
scope.cancel()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NotificationUtils.PUBLIC_REPLY_ACTION -> {
|
||||||
|
val replyText =
|
||||||
|
RemoteInput
|
||||||
|
.getResultsFromIntent(intent)
|
||||||
|
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
|
||||||
|
?.toString()
|
||||||
|
|
||||||
|
if (replyText.isNullOrBlank()) return
|
||||||
|
|
||||||
|
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
|
||||||
|
val targetEventId = intent.getStringExtra(NotificationUtils.KEY_TARGET_EVENT_ID) ?: return
|
||||||
|
|
||||||
|
runOnRelay(notificationManager, notificationId) {
|
||||||
|
sendPublicReply(accountNpub, targetEventId, replyText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runOnRelay(
|
||||||
|
notificationManager: NotificationManager,
|
||||||
|
notificationId: Int,
|
||||||
|
block: suspend () -> Unit,
|
||||||
|
) {
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
val collectionJob =
|
||||||
|
scope.launch {
|
||||||
|
Amethyst.instance.relayProxyClientConnector.relayServices
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
notificationManager.cancel(notificationId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
Log.e("NotificationReply") { "Failed to send reply: ${e.message}" }
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
collectionJob.cancel()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,4 +139,42 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
|||||||
|
|
||||||
account.sendNip17PrivateMessage(template)
|
account.sendNip17PrivateMessage(template)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun sendPublicReply(
|
||||||
|
accountNpub: String,
|
||||||
|
targetEventId: String,
|
||||||
|
replyText: String,
|
||||||
|
) {
|
||||||
|
val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(accountNpub) ?: return
|
||||||
|
val account = Amethyst.instance.accountsCache.loadAccount(accountSettings)
|
||||||
|
|
||||||
|
val targetEvent = LocalCache.getNoteIfExists(targetEventId)?.event ?: return
|
||||||
|
|
||||||
|
val template =
|
||||||
|
when (targetEvent) {
|
||||||
|
is TextNoteEvent -> {
|
||||||
|
TextNoteEvent.build(
|
||||||
|
note = replyText,
|
||||||
|
replyingTo = EventHintBundle(targetEvent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is CommentEvent -> {
|
||||||
|
CommentEvent.replyBuilder(
|
||||||
|
msg = replyText,
|
||||||
|
replyingTo = EventHintBundle(targetEvent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
// Non-threaded events (e.g. long-form articles) use NIP-22 comments.
|
||||||
|
CommentEvent.replyBuilder(
|
||||||
|
msg = replyText,
|
||||||
|
replyingTo = EventHintBundle(targetEvent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
account.signAndComputeBroadcast(template)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+164
@@ -48,23 +48,40 @@ object NotificationUtils {
|
|||||||
private var zapChannel: NotificationChannel? = null
|
private var zapChannel: NotificationChannel? = null
|
||||||
private var reactionChannel: NotificationChannel? = null
|
private var reactionChannel: NotificationChannel? = null
|
||||||
private var chessChannel: NotificationChannel? = null
|
private var chessChannel: NotificationChannel? = null
|
||||||
|
private var replyChannel: NotificationChannel? = null
|
||||||
|
private var mentionChannel: NotificationChannel? = null
|
||||||
|
|
||||||
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
|
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
|
||||||
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
|
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
|
||||||
private const val REACTION_GROUP_KEY = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION"
|
private const val REACTION_GROUP_KEY = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION"
|
||||||
private const val CHESS_GROUP_KEY = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION"
|
private const val CHESS_GROUP_KEY = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION"
|
||||||
|
const val REPLY_GROUP_KEY_PREFIX = "com.vitorpamplona.amethyst.REPLY_NOTIFICATION"
|
||||||
|
private const val MENTION_GROUP_KEY = "com.vitorpamplona.amethyst.MENTION_NOTIFICATION"
|
||||||
|
|
||||||
const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION"
|
const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION"
|
||||||
|
const val PUBLIC_REPLY_ACTION = "com.vitorpamplona.amethyst.PUBLIC_REPLY_ACTION"
|
||||||
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
|
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
|
||||||
const val KEY_REPLY_TEXT = "key_reply_text"
|
const val KEY_REPLY_TEXT = "key_reply_text"
|
||||||
const val KEY_NOTIFICATION_ID = "key_notification_id"
|
const val KEY_NOTIFICATION_ID = "key_notification_id"
|
||||||
const val KEY_ACCOUNT_NPUB = "key_account_npub"
|
const val KEY_ACCOUNT_NPUB = "key_account_npub"
|
||||||
const val KEY_CHATROOM_MEMBERS = "key_chatroom_members"
|
const val KEY_CHATROOM_MEMBERS = "key_chatroom_members"
|
||||||
|
const val KEY_TARGET_EVENT_ID = "key_target_event_id"
|
||||||
|
|
||||||
private const val DM_SUMMARY_ID = 0x10000
|
private const val DM_SUMMARY_ID = 0x10000
|
||||||
private const val ZAP_SUMMARY_ID = 0x20000
|
private const val ZAP_SUMMARY_ID = 0x20000
|
||||||
private const val REACTION_SUMMARY_ID = 0x40000
|
private const val REACTION_SUMMARY_ID = 0x40000
|
||||||
private const val CHESS_SUMMARY_ID = 0x30000
|
private const val CHESS_SUMMARY_ID = 0x30000
|
||||||
|
private const val REPLY_SUMMARY_ID_BASE = 0x50000
|
||||||
|
private const val MENTION_SUMMARY_ID = 0x60000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a stable summary notification id for a per-thread reply group.
|
||||||
|
* Uses the thread root id hash mixed with the base id so different threads
|
||||||
|
* don't collide with each other or with the other channel summaries.
|
||||||
|
*/
|
||||||
|
fun replySummaryIdFor(threadRootId: String): Int = REPLY_SUMMARY_ID_BASE xor threadRootId.hashCode()
|
||||||
|
|
||||||
|
fun replyGroupKeyFor(threadRootId: String): String = "$REPLY_GROUP_KEY_PREFIX:$threadRootId"
|
||||||
|
|
||||||
fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
|
fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
|
||||||
if (dmChannel != null) return dmChannel!!
|
if (dmChannel != null) return dmChannel!!
|
||||||
@@ -150,6 +167,48 @@ object NotificationUtils {
|
|||||||
return chessChannel!!
|
return chessChannel!!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getOrCreateReplyChannel(applicationContext: Context): NotificationChannel {
|
||||||
|
if (replyChannel != null) return replyChannel!!
|
||||||
|
|
||||||
|
replyChannel =
|
||||||
|
NotificationChannel(
|
||||||
|
stringRes(applicationContext, R.string.app_notification_replies_channel_id),
|
||||||
|
stringRes(applicationContext, R.string.app_notification_replies_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT,
|
||||||
|
).apply {
|
||||||
|
description =
|
||||||
|
stringRes(applicationContext, R.string.app_notification_replies_channel_description)
|
||||||
|
}
|
||||||
|
|
||||||
|
val notificationManager: NotificationManager =
|
||||||
|
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
|
||||||
|
notificationManager.createNotificationChannel(replyChannel!!)
|
||||||
|
|
||||||
|
return replyChannel!!
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getOrCreateMentionChannel(applicationContext: Context): NotificationChannel {
|
||||||
|
if (mentionChannel != null) return mentionChannel!!
|
||||||
|
|
||||||
|
mentionChannel =
|
||||||
|
NotificationChannel(
|
||||||
|
stringRes(applicationContext, R.string.app_notification_mentions_channel_id),
|
||||||
|
stringRes(applicationContext, R.string.app_notification_mentions_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT,
|
||||||
|
).apply {
|
||||||
|
description =
|
||||||
|
stringRes(applicationContext, R.string.app_notification_mentions_channel_description)
|
||||||
|
}
|
||||||
|
|
||||||
|
val notificationManager: NotificationManager =
|
||||||
|
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
|
||||||
|
notificationManager.createNotificationChannel(mentionChannel!!)
|
||||||
|
|
||||||
|
return mentionChannel!!
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun NotificationManager.sendReactionNotification(
|
suspend fun NotificationManager.sendReactionNotification(
|
||||||
id: String,
|
id: String,
|
||||||
messageBody: String,
|
messageBody: String,
|
||||||
@@ -206,6 +265,76 @@ object NotificationUtils {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun NotificationManager.sendReplyNotification(
|
||||||
|
id: String,
|
||||||
|
messageBody: String,
|
||||||
|
messageTitle: String,
|
||||||
|
time: Long,
|
||||||
|
pictureUrl: String?,
|
||||||
|
uri: String,
|
||||||
|
applicationContext: Context,
|
||||||
|
threadRootId: String,
|
||||||
|
inlineReply: InlineReplyTarget? = null,
|
||||||
|
) {
|
||||||
|
getOrCreateReplyChannel(applicationContext)
|
||||||
|
val channelId = stringRes(applicationContext, R.string.app_notification_replies_channel_id)
|
||||||
|
|
||||||
|
sendNotification(
|
||||||
|
id = id,
|
||||||
|
messageBody = messageBody,
|
||||||
|
messageTitle = messageTitle,
|
||||||
|
time = time,
|
||||||
|
pictureUrl = pictureUrl,
|
||||||
|
uri = uri,
|
||||||
|
channelId = channelId,
|
||||||
|
notificationGroupKey = replyGroupKeyFor(threadRootId),
|
||||||
|
category = NotificationCompat.CATEGORY_SOCIAL,
|
||||||
|
summaryId = replySummaryIdFor(threadRootId),
|
||||||
|
summaryText = stringRes(applicationContext, R.string.app_notification_replies_summary),
|
||||||
|
applicationContext = applicationContext,
|
||||||
|
inlineReply = inlineReply,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun NotificationManager.sendMentionNotification(
|
||||||
|
id: String,
|
||||||
|
messageBody: String,
|
||||||
|
messageTitle: String,
|
||||||
|
time: Long,
|
||||||
|
pictureUrl: String?,
|
||||||
|
uri: String,
|
||||||
|
applicationContext: Context,
|
||||||
|
) {
|
||||||
|
getOrCreateMentionChannel(applicationContext)
|
||||||
|
val channelId = stringRes(applicationContext, R.string.app_notification_mentions_channel_id)
|
||||||
|
|
||||||
|
sendNotification(
|
||||||
|
id = id,
|
||||||
|
messageBody = messageBody,
|
||||||
|
messageTitle = messageTitle,
|
||||||
|
time = time,
|
||||||
|
pictureUrl = pictureUrl,
|
||||||
|
uri = uri,
|
||||||
|
channelId = channelId,
|
||||||
|
notificationGroupKey = MENTION_GROUP_KEY,
|
||||||
|
category = NotificationCompat.CATEGORY_SOCIAL,
|
||||||
|
summaryId = MENTION_SUMMARY_ID,
|
||||||
|
summaryText = stringRes(applicationContext, R.string.app_notification_mentions_summary),
|
||||||
|
applicationContext = applicationContext,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload for wiring a RemoteInput-powered inline reply action onto a public
|
||||||
|
* note notification. The receiver resolves the target event from LocalCache
|
||||||
|
* via [targetEventId] and signs the appropriate kind (1 for NIP-10, 1111
|
||||||
|
* for NIP-22) under the account identified by [accountNpub].
|
||||||
|
*/
|
||||||
|
data class InlineReplyTarget(
|
||||||
|
val accountNpub: String,
|
||||||
|
val targetEventId: String,
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun NotificationManager.sendZapNotification(
|
suspend fun NotificationManager.sendZapNotification(
|
||||||
id: String,
|
id: String,
|
||||||
messageBody: String,
|
messageBody: String,
|
||||||
@@ -438,6 +567,7 @@ object NotificationUtils {
|
|||||||
summaryId: Int,
|
summaryId: Int,
|
||||||
summaryText: String,
|
summaryText: String,
|
||||||
applicationContext: Context,
|
applicationContext: Context,
|
||||||
|
inlineReply: InlineReplyTarget? = null,
|
||||||
) {
|
) {
|
||||||
val notId = id.hashCode()
|
val notId = id.hashCode()
|
||||||
|
|
||||||
@@ -484,6 +614,40 @@ object NotificationUtils {
|
|||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setWhen(time * 1000)
|
.setWhen(time * 1000)
|
||||||
|
|
||||||
|
if (inlineReply != null) {
|
||||||
|
val remoteInput =
|
||||||
|
RemoteInput
|
||||||
|
.Builder(KEY_REPLY_TEXT)
|
||||||
|
.setLabel(stringRes(applicationContext, R.string.app_notification_reply_label))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val replyIntent =
|
||||||
|
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
|
||||||
|
action = PUBLIC_REPLY_ACTION
|
||||||
|
putExtra(KEY_NOTIFICATION_ID, notId)
|
||||||
|
putExtra(KEY_ACCOUNT_NPUB, inlineReply.accountNpub)
|
||||||
|
putExtra(KEY_TARGET_EVENT_ID, inlineReply.targetEventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val replyPendingIntent =
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
applicationContext,
|
||||||
|
notId,
|
||||||
|
replyIntent,
|
||||||
|
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
|
||||||
|
val replyAction =
|
||||||
|
NotificationCompat.Action
|
||||||
|
.Builder(R.drawable.amethyst, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent)
|
||||||
|
.addRemoteInput(remoteInput)
|
||||||
|
.setAllowGeneratedReplies(true)
|
||||||
|
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
builder.addAction(replyAction)
|
||||||
|
}
|
||||||
|
|
||||||
notify(notId, builder.build())
|
notify(notId, builder.build())
|
||||||
|
|
||||||
sendGroupSummary(channelId, notificationGroupKey, summaryId, summaryText, applicationContext)
|
sendGroupSummary(channelId, notificationGroupKey, summaryId, summaryText, applicationContext)
|
||||||
|
|||||||
+1
-2
@@ -51,7 +51,6 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
|||||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||||
import java.util.Locale
|
|
||||||
import kotlin.math.round
|
import kotlin.math.round
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -103,7 +102,7 @@ fun ForwardZapTo(
|
|||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
UsernameDisplay(splitItem.key, accountViewModel = accountViewModel)
|
UsernameDisplay(splitItem.key, accountViewModel = accountViewModel)
|
||||||
Text(
|
Text(
|
||||||
text = String.format(Locale.getDefault(), "%.0f%%", splitItem.percentage * 100),
|
text = "${(splitItem.percentage * 100).toInt()}%",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
|||||||
@@ -49,6 +49,8 @@
|
|||||||
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy meg tudja tolni a bejegyzéseket</string>
|
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy meg tudja tolni a bejegyzéseket</string>
|
||||||
<string name="login_with_a_private_key_to_like_posts">Ön nyilvános kulcsot használ, és a nyilvános kulcsok csak olvashatóak. Jelentkezzen be a privát kulccsal a hozzászólások kedveléséhez</string>
|
<string name="login_with_a_private_key_to_like_posts">Ön nyilvános kulcsot használ, és a nyilvános kulcsok csak olvashatóak. Jelentkezzen be a privát kulccsal a hozzászólások kedveléséhez</string>
|
||||||
<string name="no_zap_amount_setup_long_press_to_change">Nincs beállítva Zap-összeg. Koppintson hosszan a beállításhoz</string>
|
<string name="no_zap_amount_setup_long_press_to_change">Nincs beállítva Zap-összeg. Koppintson hosszan a beállításhoz</string>
|
||||||
|
<string name="chat_zap_anonymous">Névtelen</string>
|
||||||
|
<string name="chat_clip_created_a_clip">létrehozott egy klippet</string>
|
||||||
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatók a bejegyzések. Jelentkezzen be a privát kulcsával, hogy Zap-et tudjon küldeni</string>
|
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatók a bejegyzések. Jelentkezzen be a privát kulcsával, hogy Zap-et tudjon küldeni</string>
|
||||||
<string name="login_with_a_private_key_to_be_able_to_follow">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy követni tudjon embereket</string>
|
<string name="login_with_a_private_key_to_be_able_to_follow">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy követni tudjon embereket</string>
|
||||||
<string name="login_with_a_private_key_to_be_able_to_unfollow">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy ki tudja követni az embereket, akiket követ</string>
|
<string name="login_with_a_private_key_to_be_able_to_unfollow">Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy ki tudja követni az embereket, akiket követ</string>
|
||||||
@@ -271,6 +273,7 @@
|
|||||||
rövid élettartamú, ezért a csevegési üzenetek idővel eltűnnek</string>
|
rövid élettartamú, ezért a csevegési üzenetek idővel eltűnnek</string>
|
||||||
<string name="public_chat">Nyilvános csevegés</string>
|
<string name="public_chat">Nyilvános csevegés</string>
|
||||||
<string name="marmot_group">MLS-csoport</string>
|
<string name="marmot_group">MLS-csoport</string>
|
||||||
|
<string name="marmot_group_no_messages_yet">Még nincs üzenet</string>
|
||||||
<string name="public_chat_title">Nyilvános csevegés metaadatai</string>
|
<string name="public_chat_title">Nyilvános csevegés metaadatai</string>
|
||||||
<string name="public_chat_explainer">A nyilvános csevegések mindenki számára láthatóak a Nostr-on, és bárki
|
<string name="public_chat_explainer">A nyilvános csevegések mindenki számára láthatóak a Nostr-on, és bárki
|
||||||
részt vehet bennük. Ezek kiválóan alkalmasak bizonyos témák köré szerveződő nyílt közösségek számára.
|
részt vehet bennük. Ezek kiválóan alkalmasak bizonyos témák köré szerveződő nyílt közösségek számára.
|
||||||
@@ -335,6 +338,11 @@
|
|||||||
<string name="private_conversation_notification">"<Nem sikerült a privát üzenetet visszafejteni>\n\nÖnt megemlítették egy privát/titkosított beszélgetésben %1$s és %2$s között."</string>
|
<string name="private_conversation_notification">"<Nem sikerült a privát üzenetet visszafejteni>\n\nÖnt megemlítették egy privát/titkosított beszélgetésben %1$s és %2$s között."</string>
|
||||||
<string name="account_switch_add_account_dialog_title">Új fiók hozzáadása</string>
|
<string name="account_switch_add_account_dialog_title">Új fiók hozzáadása</string>
|
||||||
<string name="drawer_accounts">Fiókok</string>
|
<string name="drawer_accounts">Fiókok</string>
|
||||||
|
<string name="drawer_section_navigate">Navigálás</string>
|
||||||
|
<string name="drawer_section_you">Ön</string>
|
||||||
|
<string name="drawer_section_feeds">Hírfolyamok</string>
|
||||||
|
<string name="drawer_section_create">Létrehozás</string>
|
||||||
|
<string name="drawer_section_system">Rendszer</string>
|
||||||
<string name="account_switch_select_account">Fiók kiválasztása</string>
|
<string name="account_switch_select_account">Fiók kiválasztása</string>
|
||||||
<string name="account_switch_add_account_btn">Új fiók hozzáadása</string>
|
<string name="account_switch_add_account_btn">Új fiók hozzáadása</string>
|
||||||
<string name="account_switch_active_account">Aktív fiók</string>
|
<string name="account_switch_active_account">Aktív fiók</string>
|
||||||
@@ -390,6 +398,26 @@
|
|||||||
<string name="open_polls">Megnyitás</string>
|
<string name="open_polls">Megnyitás</string>
|
||||||
<string name="closed_polls">Lezárva</string>
|
<string name="closed_polls">Lezárva</string>
|
||||||
<string name="badges">Kitűzők</string>
|
<string name="badges">Kitűzők</string>
|
||||||
|
<string name="communities">Közösségek</string>
|
||||||
|
<string name="new_community">Új közösség</string>
|
||||||
|
<string name="edit_community">Közösség szerkesztése</string>
|
||||||
|
<string name="new_community_name">Közösség neve</string>
|
||||||
|
<string name="new_community_description">Leírás</string>
|
||||||
|
<string name="new_community_rules">Szabályok (nem kötelező)</string>
|
||||||
|
<string name="new_community_pick_cover">Borítókép hozzáadása</string>
|
||||||
|
<string name="new_community_pick_cover_hint">Koppintson egy képre a kiválasztáshoz – a kép fel lesz töltve a médiskiszolgálóra.</string>
|
||||||
|
<string name="new_community_moderators_section">Moderátorok</string>
|
||||||
|
<string name="new_community_moderators_hint">A moderátorok jóváhagyhatják a bejegyzéseket. Ön mindig moderátor.</string>
|
||||||
|
<string name="new_community_add_moderator">Egy moderátor hozzáadása</string>
|
||||||
|
<string name="new_community_add_moderator_placeholder">Név, npub vagy NIP-05</string>
|
||||||
|
<string name="new_community_owner">Tulajdonos</string>
|
||||||
|
<string name="new_community_relays_section">Átjátszók</string>
|
||||||
|
<string name="new_community_relays_hint">Válasszon átjátzsókat, amelyek a kéréseket, jóváhagyásokat vagy a közösségi szerzők metaadatait fogják tárolni.</string>
|
||||||
|
<string name="new_community_relay_marker_none">Bármelyik</string>
|
||||||
|
<string name="new_community_relay_marker_author">Szerző</string>
|
||||||
|
<string name="new_community_relay_marker_requests">Kérések</string>
|
||||||
|
<string name="new_community_relay_marker_approvals">Jóváhagyások</string>
|
||||||
|
<string name="new_community_empty">Nincs olyan közösség, amely megfelelne ennek a szűrőnek.</string>
|
||||||
<string name="received_badges">Fogadott</string>
|
<string name="received_badges">Fogadott</string>
|
||||||
<string name="my_badges">Saját</string>
|
<string name="my_badges">Saját</string>
|
||||||
<string name="awarded_badges">Kitüntetett</string>
|
<string name="awarded_badges">Kitüntetett</string>
|
||||||
@@ -425,6 +453,11 @@
|
|||||||
<string name="profile_badges_empty">Ön még nem kapott kitűzőt.</string>
|
<string name="profile_badges_empty">Ön még nem kapott kitűzőt.</string>
|
||||||
<string name="pictures">Képek</string>
|
<string name="pictures">Képek</string>
|
||||||
<string name="shorts">Rövidek</string>
|
<string name="shorts">Rövidek</string>
|
||||||
|
<string name="public_chats">Nyilvános csevegések</string>
|
||||||
|
<string name="follow_packs">Követési csomagok</string>
|
||||||
|
<string name="live_streams">Élő közvetítések</string>
|
||||||
|
<string name="audio_rooms">Hangszobák</string>
|
||||||
|
<string name="audio_room_audience">Közönség</string>
|
||||||
<string name="longs">Videók</string>
|
<string name="longs">Videók</string>
|
||||||
<string name="articles">Cikkek</string>
|
<string name="articles">Cikkek</string>
|
||||||
<string name="private_bookmarks">Privát könyvjelzők</string>
|
<string name="private_bookmarks">Privát könyvjelzők</string>
|
||||||
@@ -452,6 +485,20 @@
|
|||||||
<string name="bookmark_list_links_btn_label">Hivakozások megtekintése</string>
|
<string name="bookmark_list_links_btn_label">Hivakozások megtekintése</string>
|
||||||
<string name="bookmark_list_hashtags_btn_label">Kulcsszavak megtekintése</string>
|
<string name="bookmark_list_hashtags_btn_label">Kulcsszavak megtekintése</string>
|
||||||
<string name="bookmark_list_feed_empty_msg">Még nincs egyetlen könyvjelzőlistája sem. Koppintson az „Új” gombra, hogy létrehozzon egyet.</string>
|
<string name="bookmark_list_feed_empty_msg">Még nincs egyetlen könyvjelzőlistája sem. Koppintson az „Új” gombra, hogy létrehozzon egyet.</string>
|
||||||
|
<string name="interest_sets_title">Kulcsszó-csomagok</string>
|
||||||
|
<string name="interest_sets_empty">Még nincs egyetlen érdeklődési csomagja sem. Koppintson az „Új” gombra, hogy létrehozzon egyet.</string>
|
||||||
|
<string name="interest_set_create_btn_label">Új érdeklődési csomag</string>
|
||||||
|
<string name="interest_set_creation_screen_title">Új érdeklődési csomag</string>
|
||||||
|
<string name="interest_set_name_label">Név megadása</string>
|
||||||
|
<string name="interest_set_name_placeholder">Saját érdeklődések</string>
|
||||||
|
<string name="interest_set_hashtag_add_placeholder">Kulcsszó hozzáadása</string>
|
||||||
|
<string name="interest_set_hashtag_private_toggle">Privát</string>
|
||||||
|
<string name="interest_set_hashtag_count">%1$d kucsszó</string>
|
||||||
|
<string name="interest_set_actions_dialog_title">Új érdeklődési csomagműveletek</string>
|
||||||
|
<string name="interest_set_rename">Átnevezés</string>
|
||||||
|
<string name="interest_set_clone">Klónozás</string>
|
||||||
|
<string name="interest_set_add_hashtag">Kulcsszó hozzáadása</string>
|
||||||
|
<string name="interest_set_toggle_visibility">Váltás nyilvános vagy privát között</string>
|
||||||
<string name="private_posts_label">Privát bejegyzések</string>
|
<string name="private_posts_label">Privát bejegyzések</string>
|
||||||
<string name="private_posts_count">Privát bejegyzések (%1$s)</string>
|
<string name="private_posts_count">Privát bejegyzések (%1$s)</string>
|
||||||
<string name="public_posts_label">Nyilvános bejegyzések</string>
|
<string name="public_posts_label">Nyilvános bejegyzések</string>
|
||||||
@@ -771,6 +818,16 @@
|
|||||||
<string name="call_settings_turn_url">Kiszolgáló webcíme</string>
|
<string name="call_settings_turn_url">Kiszolgáló webcíme</string>
|
||||||
<string name="call_settings_turn_username">Felhasználónév</string>
|
<string name="call_settings_turn_username">Felhasználónév</string>
|
||||||
<string name="call_settings_turn_credential">Hitelesítési adatok</string>
|
<string name="call_settings_turn_credential">Hitelesítési adatok</string>
|
||||||
|
<string name="always_on_notif_channel_description">Aktív kapcsolatot tart fenn a beérkező üzenetek átjátszóival a valós idejű értesítések érdekében</string>
|
||||||
|
<string name="always_on_notif_title">Amethyst értesítések aktíválva</string>
|
||||||
|
<string name="always_on_notif_connected">Kapcsolódva %1$d beérkező üzenetátjátszóhoz</string>
|
||||||
|
<string name="always_on_notif_connecting">Kapcsolódás a beérkező üzenetátjátszókhoz\u2026</string>
|
||||||
|
<string name="always_on_notif_stop">Szüneteltetés</string>
|
||||||
|
<string name="always_on_notif_setting_title">Folyamatos értesítési szolgáltatás</string>
|
||||||
|
<string name="always_on_notif_setting_description">Folyamatos kapcsolatot tart fenn a beérkező üzenetek átjátszóival az értesítések azonnali kézbesítése érdekében. Megjeleníti a folyamatban lévő értesítéseket. Több akkumulátort fogyaszt, de így biztosan nem marad le egyetlen üzenetről sem.</string>
|
||||||
|
<string name="battery_optimization_title">Akkumulátor-optimalizálás aktív</string>
|
||||||
|
<string name="battery_optimization_description">Az Android korlátozhatja a háttérben futó átjátszókapcsolatokat. Az Amethyst esetében tiltsa le az akkumulátor-optimalizálást a megbízható értesítések biztosítása érdekében.</string>
|
||||||
|
<string name="battery_optimization_fix_now">Javítás most</string>
|
||||||
<string name="reply_notify">Értesítés: </string>
|
<string name="reply_notify">Értesítés: </string>
|
||||||
<string name="channel_list_join_conversation">Csatlakozás a beszélgetéshez</string>
|
<string name="channel_list_join_conversation">Csatlakozás a beszélgetéshez</string>
|
||||||
<string name="channel_list_user_or_group_id">Felhasználó- vagy csoport-azonosító</string>
|
<string name="channel_list_user_or_group_id">Felhasználó- vagy csoport-azonosító</string>
|
||||||
@@ -936,6 +993,7 @@
|
|||||||
<string name="account_settings">Fiókbeállítások</string>
|
<string name="account_settings">Fiókbeállítások</string>
|
||||||
<string name="app_settings">Alkalmazásbeállítások</string>
|
<string name="app_settings">Alkalmazásbeállítások</string>
|
||||||
<string name="danger_zone">Kritikus beállítások</string>
|
<string name="danger_zone">Kritikus beállítások</string>
|
||||||
|
<string name="reset_marmot_confirm_action">Visszaállítás</string>
|
||||||
<string name="connectivity_type_always">Mindig</string>
|
<string name="connectivity_type_always">Mindig</string>
|
||||||
<string name="connectivity_type_wifi_only">Csak Wi-Fi-n</string>
|
<string name="connectivity_type_wifi_only">Csak Wi-Fi-n</string>
|
||||||
<string name="connectivity_type_unmetered_wifi_only">Korlátlan Wi-Fi</string>
|
<string name="connectivity_type_unmetered_wifi_only">Korlátlan Wi-Fi</string>
|
||||||
@@ -954,6 +1012,7 @@
|
|||||||
<string name="theme">Téma</string>
|
<string name="theme">Téma</string>
|
||||||
<string name="automatically_load_images_gifs">Képelőnézet</string>
|
<string name="automatically_load_images_gifs">Képelőnézet</string>
|
||||||
<string name="automatically_play_videos">Videólejátszás</string>
|
<string name="automatically_play_videos">Videólejátszás</string>
|
||||||
|
<string name="autoplay_videos">Videók automatikus lejátszása</string>
|
||||||
<string name="automatically_show_url_preview">Webcím-előnézet</string>
|
<string name="automatically_show_url_preview">Webcím-előnézet</string>
|
||||||
<string name="automatically_hide_nav_bars">Magával ragadó görgetés</string>
|
<string name="automatically_hide_nav_bars">Magával ragadó görgetés</string>
|
||||||
<string name="automatically_hide_nav_bars_description">Navigációs sáv elrejtése görgetéskor</string>
|
<string name="automatically_hide_nav_bars_description">Navigációs sáv elrejtése görgetéskor</string>
|
||||||
@@ -1023,7 +1082,8 @@
|
|||||||
<string name="language_description">Az alkalmazás felületéhez</string>
|
<string name="language_description">Az alkalmazás felületéhez</string>
|
||||||
<string name="theme_description">Sötét, világos vagy a rendszer által használt téma</string>
|
<string name="theme_description">Sötét, világos vagy a rendszer által használt téma</string>
|
||||||
<string name="automatically_load_images_gifs_description">Képek és GIF-ek automatikus betöltése</string>
|
<string name="automatically_load_images_gifs_description">Képek és GIF-ek automatikus betöltése</string>
|
||||||
<string name="automatically_play_videos_description">Videók és GIF-ek automatikus lejátszása</string>
|
<string name="automatically_play_videos_description">Videók és GIF-ek automatikus betöltése</string>
|
||||||
|
<string name="autoplay_videos_description">Videók automatikus lejátszása, amint megjelennek a kijelzőn</string>
|
||||||
<string name="automatically_show_url_preview_description">Webcím-előnézetek megjelenítése</string>
|
<string name="automatically_show_url_preview_description">Webcím-előnézetek megjelenítése</string>
|
||||||
<string name="load_image_description">Mikor töltse be a képeket</string>
|
<string name="load_image_description">Mikor töltse be a képeket</string>
|
||||||
<string name="copy_stack_to_clipboard">Köteg másolása</string>
|
<string name="copy_stack_to_clipboard">Köteg másolása</string>
|
||||||
@@ -1305,6 +1365,10 @@
|
|||||||
<string name="like_description">Tetszik</string>
|
<string name="like_description">Tetszik</string>
|
||||||
<string name="zap_description">Zap</string>
|
<string name="zap_description">Zap</string>
|
||||||
<string name="change_reaction">Gyors reakciók megváltoztatása</string>
|
<string name="change_reaction">Gyors reakciók megváltoztatása</string>
|
||||||
|
<string name="bottom_bar_settings">Alsó navigációs sáv</string>
|
||||||
|
<string name="bottom_bar_settings_description">Húzással rendezheti át az elemek sorrendjét. A kapcsolóval elemeket adhat hozzá az alsó sávhoz, illetve eltávolíthat onnan. Ha nincs elem, az alsó sáv el van rejtve.</string>
|
||||||
|
<string name="bottom_bar_settings_available">Elérhető</string>
|
||||||
|
<string name="bottom_bar_settings_reorder">Újrarendezés</string>
|
||||||
<string name="reactions_settings">Reakciósor</string>
|
<string name="reactions_settings">Reakciósor</string>
|
||||||
<string name="reactions_settings_description">Állítsa be, hogy mely reakciógombok jelenjenek meg, azok sorrendjét, valamint a számlálók megjelenítését.</string>
|
<string name="reactions_settings_description">Állítsa be, hogy mely reakciógombok jelenjenek meg, azok sorrendjét, valamint a számlálók megjelenítését.</string>
|
||||||
<string name="reactions_settings_enabled">Engedélyezve</string>
|
<string name="reactions_settings_enabled">Engedélyezve</string>
|
||||||
@@ -1322,6 +1386,23 @@
|
|||||||
<string name="reactions_settings_share_description">Ennek a bejegyzésnek a megosztása külső alkalmazásban</string>
|
<string name="reactions_settings_share_description">Ennek a bejegyzésnek a megosztása külső alkalmazásban</string>
|
||||||
<string name="reactions_settings_pay">Fizetés</string>
|
<string name="reactions_settings_pay">Fizetés</string>
|
||||||
<string name="reactions_settings_pay_description">Fizetés küldése a szerzőnek az elérendő fizetési céljainak segítségével</string>
|
<string name="reactions_settings_pay_description">Fizetés küldése a szerzőnek az elérendő fizetési céljainak segítségével</string>
|
||||||
|
<string name="video_player_settings">Videólejátszó gombjai</string>
|
||||||
|
<string name="video_player_settings_description">Válassza ki, mely gombok jelenjenek meg a videolejátszón, és melyek a kiegészítő menüben. Húzással rendezheti át a sorrendet.</string>
|
||||||
|
<string name="video_player_settings_reorder">Újrarendezés</string>
|
||||||
|
<string name="video_player_settings_location_top_bar">Felső sáv</string>
|
||||||
|
<string name="video_player_settings_location_overflow">Túlcsorduló menü</string>
|
||||||
|
<string name="video_player_settings_action_fullscreen">Teljes képernyő</string>
|
||||||
|
<string name="video_player_settings_action_fullscreen_description">Videó megnyitása teljes képernyős nézetben</string>
|
||||||
|
<string name="video_player_settings_action_mute">Némítás</string>
|
||||||
|
<string name="video_player_settings_action_mute_description">Hang ki/be</string>
|
||||||
|
<string name="video_player_settings_action_quality">Videóminőség</string>
|
||||||
|
<string name="video_player_settings_action_quality_description">Válasszon felbontást a HLS-videókhoz (rejtett a nem HLS-videók esetében)</string>
|
||||||
|
<string name="video_player_settings_action_share">Megosztás vagy mentés</string>
|
||||||
|
<string name="video_player_settings_action_share_description">Ossza meg a videó hivatkozását külső oldalakon</string>
|
||||||
|
<string name="video_player_settings_action_download">Mentés a telefonra</string>
|
||||||
|
<string name="video_player_settings_action_download_description">Töltse le a videót a készülékedre (rejtett az élő közvetítéseknél)</string>
|
||||||
|
<string name="video_player_settings_action_pip">Kép a képben</string>
|
||||||
|
<string name="video_player_settings_action_pip_description">Videó lejátszása lebegő ablakban (rejtett, ha a rendszer nem támogatja)</string>
|
||||||
<string name="profile_image_of_user">%1$s profilképe</string>
|
<string name="profile_image_of_user">%1$s profilképe</string>
|
||||||
<string name="relay_info">%1$s átjátszó</string>
|
<string name="relay_info">%1$s átjátszó</string>
|
||||||
<string name="expand_relay_list">Átjátszólista kibontása</string>
|
<string name="expand_relay_list">Átjátszólista kibontása</string>
|
||||||
@@ -1525,6 +1606,7 @@
|
|||||||
<string name="select_list_to_filter">Szempont kiválasztása a hírfolyam szűréséhez</string>
|
<string name="select_list_to_filter">Szempont kiválasztása a hírfolyam szűréséhez</string>
|
||||||
<string name="feed_group_feeds">Hírfolyamok</string>
|
<string name="feed_group_feeds">Hírfolyamok</string>
|
||||||
<string name="feed_group_hashtags">Kulcsszavak</string>
|
<string name="feed_group_hashtags">Kulcsszavak</string>
|
||||||
|
<string name="feed_group_interest_sets">Érdeklődési csomagok</string>
|
||||||
<string name="feed_group_locations">Helyszínek</string>
|
<string name="feed_group_locations">Helyszínek</string>
|
||||||
<string name="feed_group_communities">Közösségek</string>
|
<string name="feed_group_communities">Közösségek</string>
|
||||||
<string name="feed_group_lists">Listák</string>
|
<string name="feed_group_lists">Listák</string>
|
||||||
@@ -1553,6 +1635,21 @@
|
|||||||
<string name="unable_to_share_video">Nem sikerült megosztani a videót, próbálja meg újra később…</string>
|
<string name="unable_to_share_video">Nem sikerült megosztani a videót, próbálja meg újra később…</string>
|
||||||
<string name="downloading_video_for_sharing">Videó letöltése…</string>
|
<string name="downloading_video_for_sharing">Videó letöltése…</string>
|
||||||
<string name="search_by_hashtag">Kulcsszó keresése: #%1$s</string>
|
<string name="search_by_hashtag">Kulcsszó keresése: #%1$s</string>
|
||||||
|
<string name="search_scope_all">Összes</string>
|
||||||
|
<string name="search_scope_people">Emberek</string>
|
||||||
|
<string name="search_scope_notes">Bejegyzések</string>
|
||||||
|
<string name="search_source_local">Helyi</string>
|
||||||
|
<string name="search_source_relays">Átjátszók</string>
|
||||||
|
<string name="search_follows_only">Csak követettek</string>
|
||||||
|
<string name="search_sort_newest">Legújabb</string>
|
||||||
|
<string name="search_sort_oldest">Legrégebbi</string>
|
||||||
|
<string name="search_sort_relevance">Relevancia</string>
|
||||||
|
<string name="search_sort_popular">Népszerű</string>
|
||||||
|
<string name="search_filters_title">Szűrők</string>
|
||||||
|
<string name="search_filters_reset">Visszaállítás</string>
|
||||||
|
<string name="search_filters_section_source">Forrás</string>
|
||||||
|
<string name="search_filters_section_sort">Rendezés</string>
|
||||||
|
<string name="search_filters_open">Szűrők</string>
|
||||||
<string name="dont_translate_from">Innentől NE fordítsa le</string>
|
<string name="dont_translate_from">Innentől NE fordítsa le</string>
|
||||||
<string name="dont_translate_from_description">Az itt látható nyelvek nem lesznek lefordítva. Az eltávolításához és az újbóli fordításhoz válasszon ki egy nyelvet.</string>
|
<string name="dont_translate_from_description">Az itt látható nyelvek nem lesznek lefordítva. Az eltávolításához és az újbóli fordításhoz válasszon ki egy nyelvet.</string>
|
||||||
<string name="translate_to">Fordítás erre:</string>
|
<string name="translate_to">Fordítás erre:</string>
|
||||||
@@ -2095,4 +2192,38 @@
|
|||||||
<string name="ai_tone_punchy">Erőteljes</string>
|
<string name="ai_tone_punchy">Erőteljes</string>
|
||||||
<string name="ai_tone_emojify">+ Emodzsi</string>
|
<string name="ai_tone_emojify">+ Emodzsi</string>
|
||||||
<!-- Emoji packs -->
|
<!-- Emoji packs -->
|
||||||
|
<string name="emoji_packs_title">Emodzsicsomagok</string>
|
||||||
|
<string name="emoji_pack_management_title">Hozzáadás az emodzsilistához</string>
|
||||||
|
<string name="add_to_emoji_list">Hozzáadás a saját emodzsilistához</string>
|
||||||
|
<string name="remove_from_emoji_list">Eltávolítás a saját emodzsilistáról</string>
|
||||||
|
<string name="new_emoji_pack">Új emodzsicsomag</string>
|
||||||
|
<string name="edit_emoji_pack">Emodzsicsomag szerkesztése</string>
|
||||||
|
<string name="emoji_shortcode_label">Rövid kód (például: :smile:)</string>
|
||||||
|
<string name="emoji_shortcode_invalid">Csak betűk, számok, kötőjelek és alsó kötőjelek</string>
|
||||||
|
<string name="emoji_url_label">Kép webcíme</string>
|
||||||
|
<string name="emoji_pack_address_label">Csomag címe (nem kötelező)</string>
|
||||||
|
<string name="emoji_pack_name_label">Csomag neve</string>
|
||||||
|
<string name="emoji_pack_description_label">Leírás (nem kötelező)</string>
|
||||||
|
<string name="emoji_pack_image_label">Borítókép webcíme (nem kötelező)</string>
|
||||||
|
<string name="emoji_pack_upload_image_cta">Egy borítókép feltöltése</string>
|
||||||
|
<string name="emoji_pack_upload_image_hint">Válasszon ki egy négyzet alakú képet, amely ezt az emodzsicsomagot szemlélteti.</string>
|
||||||
|
<string name="no_emoji_packs">Önnek még nincsenek emodzsicsomagjai</string>
|
||||||
|
<string name="emoji_pack_count">%1$d emodzsi</string>
|
||||||
|
<string name="my_emoji_list_title">Saját emodzsilista</string>
|
||||||
|
<string name="my_emoji_list_explainer">Kiválasztott listához hozzáadott emodzsicsomagok (NIP-51 típus 10030)</string>
|
||||||
|
<string name="my_emoji_list_empty">Még nincsenek emodzsicsomagok a listán</string>
|
||||||
|
<string name="add_emoji_fab">Emodzsi hozzáadása</string>
|
||||||
|
<string name="emoji_add_dialog_title">Egyéni emodzsi hozzáadása</string>
|
||||||
|
<string name="emoji_remove_dialog_title">%1$s eltávolítása:?</string>
|
||||||
|
<string name="emoji_long_press_hint">Érintsen meg hosszan egy emodzsit az eltávolításhoz</string>
|
||||||
|
<string name="emoji_pack_is_in_list">A(z) „%1$s” már az emodzsilistában van</string>
|
||||||
|
<string name="emoji_pack_is_not_in_list">A(z) „%1$s” még nincs az emodzsilistában</string>
|
||||||
|
<string name="emoji_pack_actions_dialog_title">Emodzsicsomag-műveletek</string>
|
||||||
|
<string name="manage_emoji_packs">Saját emodzsicsomagok</string>
|
||||||
|
<string name="emoji_sets">Emodzsik</string>
|
||||||
|
<string name="browse_emoji_sets">Emodzsicsomagok böngészése</string>
|
||||||
|
<string name="emoji_private_toggle">Privát</string>
|
||||||
|
<string name="emoji_private_badge">Privát emodzsi</string>
|
||||||
|
<string name="emoji_public_explainer">A nyilvános emodzsik mindenki számára láthatók, és megjelennek a reakciómenüben, valamint a „:” automatikus kiegészítő listájában, ha ez a csomag szerepel az emodzsilistában.</string>
|
||||||
|
<string name="emoji_private_explainer">A privát emodzsik titkosítva kerülnek tárolásra az átjátszókon, és csak Önnek láthatók. Ugyanúgy megjelennek a reakciómenüben és a „:” automatikus kiegészítésben, mint a nyilvánosak.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -876,6 +876,17 @@
|
|||||||
<string name="app_notification_chess_challenge_accepted">%1$s accepted your chess challenge</string>
|
<string name="app_notification_chess_challenge_accepted">%1$s accepted your chess challenge</string>
|
||||||
<string name="app_notification_chess_your_turn">%1$s moved — your turn</string>
|
<string name="app_notification_chess_your_turn">%1$s moved — your turn</string>
|
||||||
<string name="app_notification_chess_summary">Chess updates</string>
|
<string name="app_notification_chess_summary">Chess updates</string>
|
||||||
|
<string name="app_notification_replies_channel_id" translatable="false">RepliesID</string>
|
||||||
|
<string name="app_notification_replies_channel_name">Replies</string>
|
||||||
|
<string name="app_notification_replies_channel_description">Notifies you when somebody replies to your post</string>
|
||||||
|
<string name="app_notification_replies_channel_message">%1$s replied</string>
|
||||||
|
<string name="app_notification_replies_channel_message_for">on: %1$s</string>
|
||||||
|
<string name="app_notification_replies_summary">New replies</string>
|
||||||
|
<string name="app_notification_mentions_channel_id" translatable="false">MentionsID</string>
|
||||||
|
<string name="app_notification_mentions_channel_name">Mentions</string>
|
||||||
|
<string name="app_notification_mentions_channel_description">Notifies you when somebody mentions or cites you in a post</string>
|
||||||
|
<string name="app_notification_mentions_channel_message">%1$s mentioned you</string>
|
||||||
|
<string name="app_notification_mentions_summary">New mentions</string>
|
||||||
|
|
||||||
<!-- Call notifications and UI -->
|
<!-- Call notifications and UI -->
|
||||||
<string name="app_notification_calls_channel_name">Incoming calls</string>
|
<string name="app_notification_calls_channel_name">Incoming calls</string>
|
||||||
|
|||||||
-2
@@ -21,12 +21,10 @@
|
|||||||
package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
|
package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
|
||||||
|
|
||||||
import androidx.media3.common.MimeTypes
|
import androidx.media3.common.MimeTypes
|
||||||
import androidx.media3.common.util.UnstableApi
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
|
||||||
class MediaItemCacheMimeTypeTest {
|
class MediaItemCacheMimeTypeTest {
|
||||||
@Test
|
@Test
|
||||||
fun appleHlsPlaylistMimeIsNormalizedForExoPlayer() {
|
fun appleHlsPlaylistMimeIsNormalizedForExoPlayer() {
|
||||||
|
|||||||
+11
-8
@@ -22,18 +22,21 @@ package com.vitorpamplona.amethyst.commons.model.observables
|
|||||||
|
|
||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emits each new event that matches the given Nostr filter, one at a time.
|
* Emits each new event for which [predicate] returns true, one at a time,
|
||||||
|
* as it is inserted into the cache. Unlike [EventListMatchingFilter] /
|
||||||
|
* [NoteListMatchingFilter], this does not accumulate a list — it simply
|
||||||
|
* calls [onNew] per matching event. Useful for reactive event-triggered
|
||||||
|
* pipelines like notifications.
|
||||||
*
|
*
|
||||||
* Unlike [EventListMatchingFilter] / [NoteListMatchingFilter], this does not
|
* The predicate has to be fast — it runs on every new cache insertion.
|
||||||
* accumulate a list — it simply calls [onNew] per matching event as it is
|
* Callers with a Nostr [com.vitorpamplona.quartz.nip01Core.relay.filters.Filter]
|
||||||
* inserted into the cache. Useful for reactive event-triggered pipelines
|
* can pass `filter::match` as the predicate and compose additional checks
|
||||||
* (e.g. notifications) that need per-event delivery without list overhead.
|
* with `&&`.
|
||||||
*/
|
*/
|
||||||
class NewEventMatchingFilter<T : Event>(
|
class NewEventMatchingFilter<T : Event>(
|
||||||
private val filter: Filter,
|
private val predicate: (Event) -> Boolean,
|
||||||
private val onNew: (T) -> Unit,
|
private val onNew: (T) -> Unit,
|
||||||
) : Observable {
|
) : Observable {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
@@ -41,7 +44,7 @@ class NewEventMatchingFilter<T : Event>(
|
|||||||
event: Event,
|
event: Event,
|
||||||
note: Note,
|
note: Note,
|
||||||
) {
|
) {
|
||||||
if (filter.match(event)) {
|
if (predicate(event)) {
|
||||||
onNew(event as T)
|
onNew(event as T)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
@@ -61,6 +61,23 @@ class WakeUpEvent(
|
|||||||
|
|
||||||
fun kinds() = tags.kinds()
|
fun kinds() = tags.kinds()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A WakeUpEvent's `p` tags point to the authors of the referenced subject
|
||||||
|
* events (see [authorKeys]), **not** to the account the wake-up should be
|
||||||
|
* delivered to. Example: Bob reacts to Alice's note, a WakeUpEvent about
|
||||||
|
* Bob's reaction p-tags Bob — but it's Alice's device that needs to wake
|
||||||
|
* up to process the reaction.
|
||||||
|
*
|
||||||
|
* WakeUpEvents reach this device through transport-level routing (push,
|
||||||
|
* relay subscription). By the time one lands in [LocalCache], it is
|
||||||
|
* already "for us" — so every logged-in signing account is a valid
|
||||||
|
* recipient to kick the relay wakeup on behalf of. Returning true here
|
||||||
|
* means the dispatcher invokes [com.vitorpamplona.quartz.experimental.
|
||||||
|
* notifications.wake.WakeUpEvent]-handling for each logged-in account,
|
||||||
|
* which is fine because keeping relay connections alive is idempotent.
|
||||||
|
*/
|
||||||
|
override fun notifies(userHex: HexKey): Boolean = true
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val KIND = 23903
|
const val KIND = 23903
|
||||||
const val ALT_DESCRIPTION = "WakeUp"
|
const val ALT_DESCRIPTION = "WakeUp"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.core
|
|||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.EventKSerializer
|
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.EventKSerializer
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
@@ -45,6 +46,24 @@ open class Event(
|
|||||||
*/
|
*/
|
||||||
open fun isContentEncoded() = false
|
open fun isContentEncoded() = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when this event is intended to notify [userHex].
|
||||||
|
*
|
||||||
|
* The default delegates to
|
||||||
|
* [com.vitorpamplona.quartz.nip01Core.tags.people.PTag.isNotifying],
|
||||||
|
* i.e. "any lowercase `p` tag addresses the user" — the convention for
|
||||||
|
* most kinds that address a single recipient or a set of mentions
|
||||||
|
* (NIP-01 mentions, NIP-04/17 DMs, NIP-25 reactions, NIP-28 chat
|
||||||
|
* messages, NIP-34 git issues/patches, NIP-57 zap receipts, NIP-68
|
||||||
|
* pictures, NIP-71 videos, NIP-84 highlights, NIP-AC calls, chess,
|
||||||
|
* wiki/long-form/poll mentions).
|
||||||
|
*
|
||||||
|
* Subclasses override when the NIP defines additional notification tags
|
||||||
|
* — e.g. NIP-22 comments use uppercase `P` for the root author in
|
||||||
|
* addition to lowercase `p` for the direct-reply author.
|
||||||
|
*/
|
||||||
|
open fun notifies(userHex: HexKey): Boolean = PTag.isNotifying(tags, userHex)
|
||||||
|
|
||||||
fun toJson(): String = OptimizedJsonMapper.toJson(this)
|
fun toJson(): String = OptimizedJsonMapper.toJson(this)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||||
@@ -54,6 +55,24 @@ data class PTag(
|
|||||||
key: HexKey,
|
key: HexKey,
|
||||||
): Boolean = tag.has(1) && tag[0] == TAG_NAME && tag[1] == key
|
): Boolean = tag.has(1) && tag[0] == TAG_NAME && tag[1] == key
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if any `p` tag inside [tags] addresses [userHex].
|
||||||
|
* This is the canonical check for "does this event notify this user
|
||||||
|
* under the lowercase-`p` convention" and is the default used by
|
||||||
|
* [Event.notifies]. Kinds that address recipients through other tag
|
||||||
|
* names (e.g. NIP-22 uppercase `P` for root authors) override
|
||||||
|
* [Event.notifies] and may combine this with their own checks.
|
||||||
|
*/
|
||||||
|
fun isNotifying(
|
||||||
|
tags: TagArray,
|
||||||
|
userHex: HexKey,
|
||||||
|
): Boolean {
|
||||||
|
for (tag in tags) {
|
||||||
|
if (isTagged(tag, userHex)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
fun parse(tag: Tag): PTag? {
|
fun parse(tag: Tag): PTag? {
|
||||||
ensure(tag.has(1)) { return null }
|
ensure(tag.has(1)) { return null }
|
||||||
ensure(tag[0] == TAG_NAME) { return null }
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
|||||||
@@ -197,6 +197,15 @@ class CommentEvent(
|
|||||||
|
|
||||||
override fun unmarkedReplyTos() = emptyList<String>()
|
override fun unmarkedReplyTos() = emptyList<String>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-22 addresses two distinct recipients: the direct-reply author
|
||||||
|
* (lowercase `p` via [ReplyAuthorTag]) and the root-scope author
|
||||||
|
* (uppercase `P` via [RootAuthorTag]). A comment several levels deep
|
||||||
|
* only tags the root author with uppercase `P`, so the base-class
|
||||||
|
* lowercase-only default would miss them.
|
||||||
|
*/
|
||||||
|
override fun notifies(userHex: HexKey): Boolean = super.notifies(userHex) || rootAuthorKeys().contains(userHex)
|
||||||
|
|
||||||
override fun replyingTo(): HexKey? =
|
override fun replyingTo(): HexKey? =
|
||||||
tags.lastNotNullOfOrNull(ReplyEventTag::parseKey)
|
tags.lastNotNullOfOrNull(ReplyEventTag::parseKey)
|
||||||
?: tags.lastNotNullOfOrNull(RootEventTag::parseKey)
|
?: tags.lastNotNullOfOrNull(RootEventTag::parseKey)
|
||||||
|
|||||||
Reference in New Issue
Block a user