diff --git a/common/src/main/assets/lang/ar_AE.json b/common/src/main/assets/lang/ar_AE.json index 11e8e1c8..b2562a36 100644 --- a/common/src/main/assets/lang/ar_AE.json +++ b/common/src/main/assets/lang/ar_AE.json @@ -957,6 +957,10 @@ "name": "أيقونة اسم مستخدم تعليقات منصة الأضواء", "description": "اختر الأيقونة التي يتم عرضها بجانب أسماء المستخدمين في تعليقات منصة الأضواء" }, + "spotlight_creator_info": { + "name": "معلومات منشئ منصة الأضواء", + "description": "عرض زر معلومات على سنابات منصة الأضواء/اكتشف لرؤية اسم العرض واسم المستخدم ومعرف المستخدم للمنشئ" + }, "bypass_video_length_restriction": { "name": "تجاوز قيود طول الفيديو", "description": "مفرد: يرسل فيديو واحد\nمقسم: يقسم الفيديو بعد التحرير" @@ -3128,6 +3132,25 @@ "select_date_first": "يرجى تحديد تاريخ أولاً", "invalid_time": "يرجى تحديد وقت مستقبلي" }, + "spotlight_creator_info": { + "title": "معلومات المنشئ", + "close": "إغلاق", + "creator_info": "معلومات المنشئ", + "display_name": "اسم العرض", + "username": "اسم المستخدم", + "user_id": "معرف المستخدم", + "posted_on": "نُشر في", + "loading_username": "جاري التحميل…", + "username_copied": "تم نسخ اسم المستخدم", + "user_id_copied": "تم نسخ معرف المستخدم", + "friend_status": "حالة الصداقة", + "mutual_friend": "صديق مشترك", + "following": "متابَع", + "friend_request_sent": "تم إرسال الطلب", + "friend_request_received": "تم استلام الطلب", + "blocked": "محظور", + "friend_removed": "تمت إزالته" + }, "auto_reply_messages": { "dialog": { "add_message": "إضافة رسالة", diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 79c5e5e1..15c073ef 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -1796,6 +1796,10 @@ "name": "Spotlight Comments Username Icon", "description": "Choose which icon is displayed next to usernames in Spotlight comments" }, + "spotlight_creator_info": { + "name": "Spotlight Creator Info", + "description": "Show an info button on Spotlight/Discover snaps to view creator display name, username, and user ID" + }, "bypass_video_length_restriction": { "name": "Bypass Video Length Restrictions", "description": "Single: sends a single video\nSplit: split videos after editing" @@ -3223,6 +3227,25 @@ "select_date_first": "Please select a date", "invalid_time": "Please select a future time" }, + "spotlight_creator_info": { + "title": "Creator Info", + "close": "Close", + "creator_info": "Creator Info", + "display_name": "Display name", + "username": "Username", + "user_id": "User ID", + "posted_on": "Posted", + "loading_username": "Loading…", + "username_copied": "Username copied", + "user_id_copied": "User ID copied", + "friend_status": "Friend status", + "mutual_friend": "Mutual friend", + "following": "Following", + "friend_request_sent": "Request sent", + "friend_request_received": "Request received", + "blocked": "Blocked", + "friend_removed": "Removed" + }, "auto_reply_messages": { "dialog": { "add_message": "Add Message", diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt index 610605ea..f7a7e680 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt @@ -50,6 +50,7 @@ class Global : ConfigContainer() { val disableMemoriesSnapFeed = boolean("disable_memories_snap_feed") val spotlightCommentsUsername = boolean("spotlight_comments_username") { requireRestart() } val spotlightCommentsUsernameIcon = string("spotlight_comments_username_icon", "[👤]") { requireRestart() } + val spotlightCreatorInfo = boolean("spotlight_creator_info") { requireRestart() } val bypassVideoLengthRestriction = unique("bypass_video_length_restriction", "split", "single") { addNotices( FeatureNotice.BAN_RISK); requireRestart() } val defaultVideoPlaybackRate = float("default_video_playback_rate", 1.0F) { requireRestart(); inputCheck = { (it.toFloatOrNull() ?: 1.0F) in 0.1F..4.0F} } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt index 3037648d..7b7d8156 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt @@ -123,6 +123,7 @@ class FeatureManager( PreventForcedLogout(), ConversationToolbox(), SpotlightCommentsUsername(), + SpotlightCreatorInfo(), OperaViewerParamsOverride(), StealthModeIndicator(), DisablePermissionRequests(), diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/SpotlightCreatorInfo.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/SpotlightCreatorInfo.kt new file mode 100644 index 00000000..7026020e --- /dev/null +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/SpotlightCreatorInfo.kt @@ -0,0 +1,503 @@ +package me.eternal.purrfectsnap.core.features.impl.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import me.eternal.purrfectsnap.common.data.FriendLinkType +import me.eternal.purrfectsnap.common.database.impl.FriendInfo +import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard +import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent +import me.eternal.purrfectsnap.core.features.Feature +import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging +import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette +import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme +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.wrapper.impl.Snapchatter +import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.Layer +import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.ParamMap +import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper +import java.text.SimpleDateFormat +import java.util.* + +class SpotlightCreatorInfo : Feature("SpotlightCreatorInfo") { + private data class CreatorInfo( + val snapId: String, + val creatorDisplayName: String, + val creatorUserId: String?, + val timestamp: Long?, + val friendLinkType: FriendLinkType? = null, + ) + + @Volatile + private var currentCreatorInfo: CreatorInfo? = null + + @Volatile + private var isInSpotlight: Boolean = false + + @Volatile + private var currentSnapId: String? = null + + @Volatile + private var lastSpotlightUpdateTime: Long = 0 + + private val userIdCache = mutableMapOf() + + override fun init() { + if (!context.config.global.spotlightCreatorInfo.get()) return + + onNextActivityCreate { + context.event.subscribe(BindViewEvent::class) { event -> + val modelStr = event.prevModel.toString() + if (!modelStr.contains("Spotlight") && !modelStr.contains("SINGLE_SNAP_STORY")) return@subscribe + + val userId = modelStr.takeIf { it.contains("userId=") } + ?.substringAfter("userId=") + ?.substringBefore(",") + ?.substringBefore(")") + ?.takeIf { it.isNotBlank() && it != "null" } + + val snapId = modelStr.takeIf { it.contains("snapId=") || modelStr.contains("SNAP_ID") } + ?.substringAfter("snapId=") + ?.substringBefore(",") + ?.substringBefore(")") + ?: modelStr.takeIf { it.contains("SNAP_ID") } + ?.substringAfter("SNAP_ID=") + ?.substringBefore(",") + ?.substringBefore(")") + + if (userId != null && snapId != null) { + userIdCache[snapId] = userId + } + } + + context.mappings.useMapper(OperaPageViewControllerMapper::class) { + arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName -> + classReference.get()?.hook( + methodName.get() ?: return@forEach, + HookStage.AFTER + ) onCreatorInfoHook@{ param -> + val viewState = (param.thisObject()).getObjectField(viewStateField.get()!!).toString() + val operaLayerList = (param.thisObject()).getObjectField(layerListField.get()!!) as ArrayList<*> + + if (operaLayerList.isEmpty()) { + if (isInSpotlight) { + isInSpotlight = false + currentCreatorInfo = null + currentSnapId = null + lastSpotlightUpdateTime = 0 + } + return@onCreatorInfoHook + } + + val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap + val snapSource = mediaParamMap["SNAP_SOURCE"]?.toString() + + if (snapSource != "SINGLE_SNAP_STORY") { + if (isInSpotlight) { + isInSpotlight = false + currentCreatorInfo = null + currentSnapId = null + lastSpotlightUpdateTime = 0 + } + return@onCreatorInfoHook + } + + if (viewState != "FULLY_DISPLAYED") return@onCreatorInfoHook + + val snapId = mediaParamMap["SNAP_ID"]?.toString() + if (snapId == null || snapId == currentSnapId) return@onCreatorInfoHook + + val creatorDisplayName = mediaParamMap["CREATOR_DISPLAY_NAME"]?.toString() + var creatorUserId = mediaParamMap["USER_ID"]?.toString() + ?: mediaParamMap["CREATOR_USER_ID"]?.toString() + ?: mediaParamMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() + + val playableStorySnapRecord = mediaParamMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString() + if (creatorUserId == null && playableStorySnapRecord != null) { + val recordStr = playableStorySnapRecord.toString() + creatorUserId = recordStr.substringAfter("userId=", "") + .substringBefore(",") + .substringBefore(")") + .takeIf { it.isNotBlank() && it != "null" } + } + if (creatorUserId == null) creatorUserId = userIdCache[snapId] + + val creationTimestamp = playableStorySnapRecord?.substringAfter("creationTimestamp=") + ?.substringBefore(",")?.toLongOrNull() + val timestamp = creationTimestamp ?: mediaParamMap["SNAP_TIMESTAMP"]?.toString()?.toLongOrNull() + + if (creatorDisplayName != null || creatorUserId != null) { + val friendInfo: FriendInfo? = creatorUserId?.let { context.database.getFriendInfo(it) } + val linkType = friendInfo?.let { FriendLinkType.fromValue(it.friendLinkType) } + + currentSnapId = snapId + isInSpotlight = true + lastSpotlightUpdateTime = System.currentTimeMillis() + currentCreatorInfo = CreatorInfo( + snapId = snapId, + creatorDisplayName = creatorDisplayName ?: context.translation.getOrNull("common.unknown") ?: "Unknown", + creatorUserId = creatorUserId, + timestamp = timestamp, + friendLinkType = linkType + ) + } else { + if (isInSpotlight) { + isInSpotlight = false + currentCreatorInfo = null + currentSnapId = null + lastSpotlightUpdateTime = 0 + } + } + } + } + } + + context.inAppOverlay.addCustomComposable { + var info by remember { mutableStateOf(null) } + var inSpotlight by remember { mutableStateOf(false) } + var showDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + while (true) { + val wasInSpotlight = inSpotlight + info = currentCreatorInfo + inSpotlight = isInSpotlight + if (wasInSpotlight && !inSpotlight) showDialog = false + if (inSpotlight && info != null && lastSpotlightUpdateTime > 0 && !showDialog) { + if (System.currentTimeMillis() - lastSpotlightUpdateTime > 4000) { + inSpotlight = false + info = null + showDialog = false + } + } + kotlinx.coroutines.delay(100) + } + } + + if (inSpotlight && info != null) { + InfoIconOverlay( + onClick = { + showDialog = true + lastSpotlightUpdateTime = System.currentTimeMillis() + } + ) + } + + if (showDialog && info != null) { + CreatorInfoDialog( + creatorInfo = info!!, + onDismiss = { showDialog = false } + ) + } + } + } + } + + @Composable + private fun InfoIconOverlay(onClick: () -> Unit) { + val translation = remember { context.translation.getCategory("spotlight_creator_info") } + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 16.dp), + contentAlignment = Alignment.TopCenter + ) { + Surface( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .clickable(onClick = onClick), + shape = CircleShape, + color = Color.Transparent, + border = BorderStroke(1.dp, PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.6f)), + shadowElevation = 8.dp + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .background( + Brush.radialGradient( + listOf( + PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.4f), + PurrfectOverlayPalette.cardOverlayColor + ) + ), + CircleShape + ) + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = translation.getOrNull("creator_info") ?: "Creator Info", + tint = PurrfectOverlayPalette.textPrimary, + modifier = Modifier.size(22.dp) + ) + } + } + } + } + + @Composable + private fun CreatorInfoDialog(creatorInfo: CreatorInfo, onDismiss: () -> Unit) { + var snapchatterInfo by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + val translation = remember { context.translation.getCategory("spotlight_creator_info") } + + LaunchedEffect(creatorInfo.snapId) { + snapchatterInfo = null + isLoading = false + if (creatorInfo.creatorUserId != null) { + isLoading = true + withContext(Dispatchers.IO) { + val info = runCatching { + context.feature(Messaging::class).fetchSnapchatterInfos(listOf(creatorInfo.creatorUserId!!)).firstOrNull() + }.onFailure { + context.log.error("Failed to fetch creator info for ${creatorInfo.creatorUserId}", it) + }.getOrNull() + withContext(Dispatchers.Main) { + snapchatterInfo = info + isLoading = false + } + } + } + } + + Dialog(onDismissRequest = onDismiss) { + PurrfectOverlayTheme { + val shape = RoundedCornerShape(20.dp) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = shape, + color = Color.Transparent, + border = BorderStroke( + 1.dp, + Brush.linearGradient( + listOf( + PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.55f), + PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.35f) + ) + ) + ), + shadowElevation = 0.dp, + tonalElevation = 0.dp + ) { + Box( + modifier = Modifier + .background(PurrfectOverlayPalette.cardOverlay, shape) + .padding(20.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = translation.getOrNull("title") ?: "Creator Info", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = PurrfectOverlayPalette.textPrimary + ) + IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Default.Close, + contentDescription = translation.getOrNull("close") ?: "Close", + modifier = Modifier.size(20.dp), + tint = PurrfectOverlayPalette.textSecondary + ) + } + } + + HorizontalDivider(color = PurrfectOverlayPalette.textSecondary.copy(alpha = 0.2f)) + + creatorInfo.timestamp?.let { ts -> + InfoRow( + icon = Icons.Default.Schedule, + label = translation.getOrNull("posted_on") ?: "Posted", + value = formatDate(ts) + ) + } + + val displayName = snapchatterInfo?.displayName ?: creatorInfo.creatorDisplayName + InfoRow( + icon = Icons.Default.Person, + label = translation.getOrNull("display_name") ?: "Display name", + value = displayName + ) + + if (isLoading) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = PurrfectOverlayPalette.glowPrimary + ) + Text( + translation.getOrNull("loading_username") ?: "Loading…", + fontSize = 13.sp, + color = PurrfectOverlayPalette.textSecondary + ) + } + } else { + val username = snapchatterInfo?.username + InfoRowWithCopy( + icon = Icons.Default.AccountCircle, + label = translation.getOrNull("username") ?: "Username", + value = username ?: (context.translation.getOrNull("common.unknown") ?: "—"), + canCopy = username != null, + onCopy = { + username?.let { + context.androidContext.copyToClipboard(it, translation.getOrNull("username") ?: "Username") + context.inAppOverlay.showStatusToast(Icons.Default.Check, translation.getOrNull("username_copied") ?: "Copied") + } + } + ) + } + + InfoRowWithCopy( + icon = Icons.Default.Fingerprint, + label = translation.getOrNull("user_id") ?: "User ID", + value = creatorInfo.creatorUserId ?: (context.translation.getOrNull("common.unknown") ?: "—"), + canCopy = creatorInfo.creatorUserId != null, + onCopy = { + creatorInfo.creatorUserId?.let { + context.androidContext.copyToClipboard(it, translation.getOrNull("user_id") ?: "User ID") + context.inAppOverlay.showStatusToast(Icons.Default.Check, translation.getOrNull("user_id_copied") ?: "Copied") + } + } + ) + + creatorInfo.friendLinkType?.let { linkType -> + val statusText = when (linkType) { + FriendLinkType.MUTUAL -> translation.getOrNull("mutual_friend") ?: "Mutual friend" + FriendLinkType.FOLLOWING -> translation.getOrNull("following") ?: "Following" + FriendLinkType.OUTGOING -> translation.getOrNull("friend_request_sent") ?: "Request sent" + FriendLinkType.INCOMING -> translation.getOrNull("friend_request_received") ?: "Request received" + FriendLinkType.BLOCKED -> translation.getOrNull("blocked") ?: "Blocked" + FriendLinkType.DELETED -> translation.getOrNull("friend_removed") ?: "Removed" + else -> null + } + if (statusText != null) { + InfoRow( + icon = Icons.Default.Person, + label = translation.getOrNull("friend_status") ?: "Friend status", + value = statusText + ) + } + } + } + } + } + } + } + } + + @Composable + private fun InfoRow( + icon: ImageVector, + label: String, + value: String + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(20.dp), + tint = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.9f) + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = label, + fontSize = 11.sp, + color = PurrfectOverlayPalette.textSecondary + ) + Text( + text = value, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = PurrfectOverlayPalette.textPrimary + ) + } + } + } + + @Composable + private fun InfoRowWithCopy( + icon: ImageVector, + label: String, + value: String, + canCopy: Boolean, + onCopy: () -> Unit + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(20.dp), + tint = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.9f) + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = label, + fontSize = 11.sp, + color = PurrfectOverlayPalette.textSecondary + ) + Text( + text = value, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = PurrfectOverlayPalette.textPrimary + ) + } + if (canCopy) { + IconButton(onClick = onCopy, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Default.ContentCopy, + contentDescription = "Copy", + modifier = Modifier.size(18.dp), + tint = PurrfectOverlayPalette.glowSecondary + ) + } + } + } + } + + private fun formatDate(timestamp: Long): String { + val ts = if (timestamp < 10000000000L) timestamp * 1000 else timestamp + return SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(ts)) + } +}