fix: mark snaps as seen button for newer snap versions
This commit is contained in:
@@ -15,6 +15,7 @@ import androidx.compose.material.icons.outlined.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.eternal.purrfectsnap.bridge.DownloadCallback
|
||||
import me.eternal.purrfectsnap.common.data.ContentType
|
||||
import me.eternal.purrfectsnap.common.data.FileType
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.common.data.download.*
|
||||
@@ -66,6 +67,11 @@ class SnapChapterInfo(
|
||||
val duration: Long?
|
||||
)
|
||||
|
||||
data class OperaViewerMessageContext(
|
||||
val conversationId: String,
|
||||
val clientMessageId: Long
|
||||
)
|
||||
|
||||
class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleType.AUTO_DOWNLOAD) {
|
||||
private var lastSeenMediaInfoMap: MutableMap<SplitMediaAssetType, MediaInfo>? = null
|
||||
var lastSeenMapParams: ParamMap? = null
|
||||
@@ -195,6 +201,71 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSnapContentType(contentTypeId: Int): Boolean {
|
||||
return when (ContentType.fromId(contentTypeId)) {
|
||||
ContentType.SNAP,
|
||||
ContentType.TINY_SNAP,
|
||||
ContentType.EXTERNAL_MEDIA -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateViewerMessageContext(messageContext: OperaViewerMessageContext): OperaViewerMessageContext? {
|
||||
val message = context.database.getConversationMessageFromId(messageContext.clientMessageId) ?: return null
|
||||
if (message.clientConversationId != messageContext.conversationId) return null
|
||||
if (!isSnapContentType(message.contentType)) return null
|
||||
return messageContext
|
||||
}
|
||||
|
||||
private fun parseViewerMessageContext(rawValue: String): OperaViewerMessageContext? {
|
||||
val parts = rawValue.split(':')
|
||||
if (parts.size < 3) return null
|
||||
|
||||
val conversationId = parts.firstOrNull()?.takeIf {
|
||||
runCatching { UUID.fromString(it) }.isSuccess
|
||||
} ?: return null
|
||||
val clientMessageId = parts.lastOrNull()?.toLongOrNull() ?: return null
|
||||
|
||||
return OperaViewerMessageContext(
|
||||
conversationId = conversationId,
|
||||
clientMessageId = clientMessageId
|
||||
)
|
||||
}
|
||||
|
||||
fun resolveViewerMessageContextFromParamMap(paramMap: ParamMap? = lastSeenMapParams): OperaViewerMessageContext? {
|
||||
if (paramMap == null) return null
|
||||
|
||||
paramMap["MESSAGE_ID"]?.toString()
|
||||
?.let(::parseViewerMessageContext)
|
||||
?.let(::validateViewerMessageContext)
|
||||
?.let { return it }
|
||||
|
||||
return paramMap.concurrentHashMap.values
|
||||
.asSequence()
|
||||
.mapNotNull { value ->
|
||||
value?.toString()?.let(::parseViewerMessageContext)
|
||||
}
|
||||
.mapNotNull(::validateViewerMessageContext)
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
fun resolveCurrentSnapMessageContext(): OperaViewerMessageContext? {
|
||||
val messaging = context.feature(Messaging::class)
|
||||
val currentConversationId = messaging.openedConversationUUID?.toString()
|
||||
val currentMessageId = messaging.lastFocusedMessageId.takeIf { it > 0L }
|
||||
|
||||
if (currentConversationId != null && currentMessageId != null) {
|
||||
validateViewerMessageContext(
|
||||
OperaViewerMessageContext(
|
||||
conversationId = currentConversationId,
|
||||
clientMessageId = currentMessageId
|
||||
)
|
||||
)?.let { return it }
|
||||
}
|
||||
|
||||
return resolveViewerMessageContextFromParamMap()
|
||||
}
|
||||
|
||||
private fun handleLocalReferences(path: String) = runBlocking {
|
||||
Uri.parse(path).let { uri ->
|
||||
if (uri.scheme == "file" || uri.scheme == null) {
|
||||
@@ -285,9 +356,10 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
) {
|
||||
|
||||
// ─── Messages ─────────────────────────
|
||||
paramMap["MESSAGE_ID"]?.toString()?.takeIf { forceDownload || shouldAutoDownload("friend_snaps") }?.let { id ->
|
||||
val messageId = id.substringAfterLast(":").toLong()
|
||||
val conversationMessage = context.database.getConversationMessageFromId(messageId) ?: return@let
|
||||
resolveViewerMessageContextFromParamMap(paramMap)?.takeIf {
|
||||
forceDownload || shouldAutoDownload("friend_snaps")
|
||||
}?.let { messageContext ->
|
||||
val conversationMessage = context.database.getConversationMessageFromId(messageContext.clientMessageId) ?: return@let
|
||||
val conversationId = conversationMessage.clientConversationId!!
|
||||
|
||||
if (!forceDownload && !canUseRule(conversationId)) return@let
|
||||
@@ -530,14 +602,26 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
}
|
||||
|
||||
val operaLayerList = (param.thisObject() as Any).getObjectField(layerListField.get()!!) as ArrayList<*>
|
||||
val mediaParamMap: ParamMap = operaLayerList
|
||||
val layerParamMaps = operaLayerList
|
||||
.asSequence()
|
||||
.mapNotNull { layerObj ->
|
||||
layerObj?.let { runCatching { Layer(it).paramMap }.getOrNull() }
|
||||
}
|
||||
.firstOrNull {
|
||||
it.containsKey("image_media_info") || it.containsKey("video_media_info_list")
|
||||
} ?: return@onOperaViewStateCallback
|
||||
.toList()
|
||||
val firstLayerParamMap = layerParamMaps.firstOrNull()
|
||||
val mediaParamMap: ParamMap = (
|
||||
// Chat snaps need the primary MESSAGE_ID-bearing param map for mark-as-seen to work.
|
||||
layerParamMaps.firstOrNull {
|
||||
it.containsKey("MESSAGE_ID") &&
|
||||
(it.containsKey("image_media_info") || it.containsKey("video_media_info_list"))
|
||||
}
|
||||
?: firstLayerParamMap?.takeIf {
|
||||
it.containsKey("image_media_info") || it.containsKey("video_media_info_list")
|
||||
}
|
||||
?: layerParamMaps.firstOrNull {
|
||||
it.containsKey("image_media_info") || it.containsKey("video_media_info_list")
|
||||
}
|
||||
) ?: return@onOperaViewStateCallback
|
||||
|
||||
val mediaInfoMap = mutableMapOf<SplitMediaAssetType, MediaInfo>()
|
||||
val isVideo = mediaParamMap.containsKey("video_media_info_list")
|
||||
|
||||
@@ -104,10 +104,9 @@ class OperaContextActionMenu : AbstractMenu() {
|
||||
val playableStorySnapRecord = paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()
|
||||
val sentTimestamp = playableStorySnapRecord?.substringAfter("timestamp=")
|
||||
?.substringBefore(",")?.toLongOrNull()
|
||||
?: paramMap["MESSAGE_ID"]?.toString()?.let { messageId ->
|
||||
?: mediaDownloader.resolveCurrentSnapMessageContext()?.clientMessageId?.let { messageId ->
|
||||
context.database.getConversationMessageFromId(
|
||||
messageId.substring(messageId.lastIndexOf(":") + 1)
|
||||
.toLong()
|
||||
messageId
|
||||
)?.creationTimestamp
|
||||
}
|
||||
?: paramMap["SNAP_TIMESTAMP"]?.toString()?.toLongOrNull()
|
||||
|
||||
@@ -6,41 +6,220 @@ import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.RemoveRedEye
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.eternal.purrfectsnap.common.ui.createComposeView
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.OnSnapInteractionEvent
|
||||
import me.eternal.purrfectsnap.core.features.impl.downloader.MediaDownloader
|
||||
import me.eternal.purrfectsnap.core.features.impl.downloader.OperaViewerMessageContext
|
||||
import me.eternal.purrfectsnap.core.features.impl.messaging.AutoMarkAsRead
|
||||
import me.eternal.purrfectsnap.core.ui.children
|
||||
import me.eternal.purrfectsnap.core.ui.iterateParent
|
||||
import me.eternal.purrfectsnap.core.ui.menu.AbstractMenu
|
||||
import me.eternal.purrfectsnap.core.ui.randomTag
|
||||
import me.eternal.purrfectsnap.core.ui.triggerCloseTouchEvent
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
|
||||
import me.eternal.purrfectsnap.core.util.ktx.vibrateLongPress
|
||||
import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper
|
||||
|
||||
class OperaViewerIcons : AbstractMenu() {
|
||||
private val actionMenuIconSize by lazy { context.userInterface.dpToPx(32) }
|
||||
private val actionMenuIconMargin by lazy { context.userInterface.dpToPx(5) }
|
||||
private val actionMenuIconMarginTop by lazy { context.userInterface.dpToPx(10) }
|
||||
private val injectedParentTag = randomTag()
|
||||
private val viewerVisibleState = mutableStateOf(false)
|
||||
private val viewerMessageContextState = mutableStateOf<OperaViewerMessageContext?>(null)
|
||||
private val inlineMarkButtonVisibleState = mutableStateOf(false)
|
||||
private var overlayRegistered = false
|
||||
private var hooksInitialized = false
|
||||
|
||||
override fun init() {
|
||||
if (hooksInitialized) return
|
||||
hooksInitialized = true
|
||||
|
||||
registerOverlayFallback()
|
||||
|
||||
context.event.subscribe(OnSnapInteractionEvent::class) {
|
||||
viewerMessageContextState.value = context.feature(MediaDownloader::class).resolveCurrentSnapMessageContext()
|
||||
}
|
||||
|
||||
context.mappings.useMapper(OperaPageViewControllerMapper::class) {
|
||||
arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName ->
|
||||
classReference.get()?.hook(
|
||||
methodName.get() ?: return@forEach,
|
||||
HookStage.AFTER
|
||||
) { param ->
|
||||
val viewState = param.thisObject<Any>().getObjectField(viewStateField.get()!!).toString()
|
||||
val isVisible = viewState == "FULLY_DISPLAYED"
|
||||
viewerVisibleState.value = isVisible
|
||||
|
||||
if (!isVisible) {
|
||||
viewerMessageContextState.value = null
|
||||
inlineMarkButtonVisibleState.value = false
|
||||
return@hook
|
||||
}
|
||||
|
||||
viewerMessageContextState.value = context.feature(MediaDownloader::class).resolveCurrentSnapMessageContext()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerOverlayFallback() {
|
||||
if (overlayRegistered) return
|
||||
overlayRegistered = true
|
||||
|
||||
context.inAppOverlay.addCustomComposable {
|
||||
val messageContext = viewerMessageContextState.value
|
||||
if (
|
||||
!context.config.messaging.markSnapAsSeenButton.get() ||
|
||||
!viewerVisibleState.value ||
|
||||
inlineMarkButtonVisibleState.value ||
|
||||
messageContext == null
|
||||
) return@addCustomComposable
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(end = 18.dp, bottom = 118.dp),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(52.dp)
|
||||
.clickable {
|
||||
context.coroutineScope.launch {
|
||||
markCurrentSnapAsSeen(parent = null)
|
||||
}
|
||||
},
|
||||
shape = CircleShape,
|
||||
color = Color.Black.copy(alpha = 0.55f)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.RemoveRedEye,
|
||||
tint = Color.White,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Class<*>?.hasNameSuffixInHierarchy(suffix: String): Boolean {
|
||||
var current = this
|
||||
while (current != null) {
|
||||
if (current.name.endsWith(suffix)) return true
|
||||
current = current.superclass
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun shouldInjectIntoViewer(event: AddViewEvent): Boolean {
|
||||
if (event.view !is FrameLayout) return false
|
||||
if (!event.parent.javaClass.hasNameSuffixInHierarchy("OpenLayout")) return false
|
||||
|
||||
val viewGroup = event.view as? ViewGroup ?: return false
|
||||
if (viewGroup.getTag(injectedParentTag) != null) return false
|
||||
|
||||
val hasOnlyImageChildren = viewGroup.childCount > 0 && viewGroup.children().all { it is ImageView }
|
||||
val hasMaskFrameSibling = event.parent.children().any {
|
||||
it.javaClass.hasNameSuffixInHierarchy("ScalableCircleMaskFrameLayout")
|
||||
}
|
||||
|
||||
return hasOnlyImageChildren || hasMaskFrameSibling
|
||||
}
|
||||
|
||||
private fun resolveCurrentMessageContext(mediaDownloader: MediaDownloader): OperaViewerMessageContext? {
|
||||
return mediaDownloader.resolveCurrentSnapMessageContext()?.also {
|
||||
viewerMessageContextState.value = it
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncInlineMarkButtonVisibility(view: View, mediaDownloader: MediaDownloader) {
|
||||
val isVisible = resolveCurrentMessageContext(mediaDownloader) != null
|
||||
view.visibility = if (isVisible) View.VISIBLE else View.GONE
|
||||
inlineMarkButtonVisibleState.value = isVisible
|
||||
}
|
||||
|
||||
private suspend fun markCurrentSnapAsSeen(parent: ViewGroup?) {
|
||||
val messageContext = resolveCurrentMessageContext(context.feature(MediaDownloader::class)) ?: return
|
||||
val result = context.feature(AutoMarkAsRead::class).markSnapAsSeen(
|
||||
messageContext.conversationId,
|
||||
messageContext.clientMessageId
|
||||
)
|
||||
|
||||
if (result == "DUPLICATEREQUEST" || result == null) {
|
||||
if (context.config.messaging.skipWhenMarkingAsSeen.get()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (parent != null) {
|
||||
parent.iterateParent {
|
||||
it.triggerCloseTouchEvent()
|
||||
false
|
||||
}
|
||||
} else {
|
||||
context.mainActivity
|
||||
?.findViewById<View>(android.R.id.content)
|
||||
?.triggerCloseTouchEvent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result == "DUPLICATEREQUEST") return
|
||||
if (result == null) {
|
||||
context.inAppOverlay.showStatusToast(
|
||||
Icons.Default.Info,
|
||||
context.translation["mark_as_seen.seen_toast"],
|
||||
durationMs = 800
|
||||
)
|
||||
} else {
|
||||
context.inAppOverlay.showStatusToast(
|
||||
Icons.Default.Info,
|
||||
"Failed to mark as seen: $result",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewAdded(event: AddViewEvent) {
|
||||
if (event.view is FrameLayout && event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) {
|
||||
val viewGroup = event.view as? ViewGroup ?: return
|
||||
if (
|
||||
viewGroup.childCount == 0 ||
|
||||
viewGroup.children().any { it !is ImageView } ||
|
||||
event.parent.children().none { it.javaClass.name.endsWith("ScalableCircleMaskFrameLayout") }
|
||||
) return
|
||||
inject(viewGroup)
|
||||
}
|
||||
if (!shouldInjectIntoViewer(event)) return
|
||||
val viewGroup = event.view as? ViewGroup ?: return
|
||||
viewGroup.setTag(injectedParentTag, true)
|
||||
viewerVisibleState.value = true
|
||||
viewGroup.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
|
||||
override fun onViewAttachedToWindow(v: View) {
|
||||
viewerVisibleState.value = true
|
||||
}
|
||||
|
||||
override fun onViewDetachedFromWindow(v: View) {
|
||||
viewerVisibleState.value = false
|
||||
inlineMarkButtonVisibleState.value = false
|
||||
}
|
||||
})
|
||||
inject(viewGroup)
|
||||
}
|
||||
|
||||
private fun inject(parent: ViewGroup) {
|
||||
@@ -97,14 +276,6 @@ class OperaViewerIcons : AbstractMenu() {
|
||||
}
|
||||
|
||||
if (context.config.messaging.markSnapAsSeenButton.get()) {
|
||||
fun getMessageId(): Pair<String, String>? {
|
||||
return mediaDownloader.lastSeenMapParams?.get("MESSAGE_ID")
|
||||
?.toString()
|
||||
?.split(":")
|
||||
?.takeIf { it.size == 3 }
|
||||
?.let { return it[0] to it[2] }
|
||||
}
|
||||
|
||||
parent.addView(createComposeView(parent.context) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.RemoveRedEye,
|
||||
@@ -113,48 +284,23 @@ class OperaViewerIcons : AbstractMenu() {
|
||||
)
|
||||
}.apply {
|
||||
setOnClickListener {
|
||||
this@OperaViewerIcons.context.apply {
|
||||
coroutineScope.launch {
|
||||
val (conversationId, clientMessageId) = getMessageId() ?: return@launch
|
||||
val result = feature(AutoMarkAsRead::class).markSnapAsSeen(conversationId, clientMessageId.toLong())
|
||||
|
||||
if (result == "DUPLICATEREQUEST" || result == null) {
|
||||
if (config.messaging.skipWhenMarkingAsSeen.get()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
parent.iterateParent {
|
||||
it.triggerCloseTouchEvent()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result == "DUPLICATEREQUEST") return@launch
|
||||
if (result == null) {
|
||||
inAppOverlay.showStatusToast(
|
||||
Icons.Default.Info,
|
||||
translation["mark_as_seen.seen_toast"],
|
||||
durationMs = 800
|
||||
)
|
||||
} else {
|
||||
inAppOverlay.showStatusToast(
|
||||
Icons.Default.Info,
|
||||
"Failed to mark as seen: $result",
|
||||
)
|
||||
}
|
||||
}
|
||||
this@OperaViewerIcons.context.coroutineScope.launch {
|
||||
markCurrentSnapAsSeen(parent)
|
||||
}
|
||||
}
|
||||
|
||||
addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener {
|
||||
override fun onViewAttachedToWindow(v: View) {
|
||||
v.visibility = View.GONE
|
||||
inlineMarkButtonVisibleState.value = false
|
||||
this@OperaViewerIcons.context.coroutineScope.launch(Dispatchers.Main) {
|
||||
delay(250)
|
||||
v.visibility = if (getMessageId() != null) View.VISIBLE else View.GONE
|
||||
syncInlineMarkButtonVisibility(v, mediaDownloader)
|
||||
}
|
||||
}
|
||||
override fun onViewDetachedFromWindow(v: View) {}
|
||||
override fun onViewDetachedFromWindow(v: View) {
|
||||
inlineMarkButtonVisibleState.value = false
|
||||
}
|
||||
})
|
||||
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
@@ -169,4 +315,4 @@ class OperaViewerIcons : AbstractMenu() {
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user