Spotlight Creator Info

This commit is contained in:
William Boser
2026-02-09 12:10:18 +04:00
parent c50327bfe9
commit 36aa0c52a2
5 changed files with 551 additions and 0 deletions

View File

@@ -957,6 +957,10 @@
"name": "أيقونة اسم مستخدم تعليقات منصة الأضواء", "name": "أيقونة اسم مستخدم تعليقات منصة الأضواء",
"description": "اختر الأيقونة التي يتم عرضها بجانب أسماء المستخدمين في تعليقات منصة الأضواء" "description": "اختر الأيقونة التي يتم عرضها بجانب أسماء المستخدمين في تعليقات منصة الأضواء"
}, },
"spotlight_creator_info": {
"name": "معلومات منشئ منصة الأضواء",
"description": "عرض زر معلومات على سنابات منصة الأضواء/اكتشف لرؤية اسم العرض واسم المستخدم ومعرف المستخدم للمنشئ"
},
"bypass_video_length_restriction": { "bypass_video_length_restriction": {
"name": "تجاوز قيود طول الفيديو", "name": "تجاوز قيود طول الفيديو",
"description": "مفرد: يرسل فيديو واحد\nمقسم: يقسم الفيديو بعد التحرير" "description": "مفرد: يرسل فيديو واحد\nمقسم: يقسم الفيديو بعد التحرير"
@@ -3128,6 +3132,25 @@
"select_date_first": "يرجى تحديد تاريخ أولاً", "select_date_first": "يرجى تحديد تاريخ أولاً",
"invalid_time": "يرجى تحديد وقت مستقبلي" "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": { "auto_reply_messages": {
"dialog": { "dialog": {
"add_message": "إضافة رسالة", "add_message": "إضافة رسالة",

View File

@@ -1796,6 +1796,10 @@
"name": "Spotlight Comments Username Icon", "name": "Spotlight Comments Username Icon",
"description": "Choose which icon is displayed next to usernames in Spotlight comments" "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": { "bypass_video_length_restriction": {
"name": "Bypass Video Length Restrictions", "name": "Bypass Video Length Restrictions",
"description": "Single: sends a single video\nSplit: split videos after editing" "description": "Single: sends a single video\nSplit: split videos after editing"
@@ -3223,6 +3227,25 @@
"select_date_first": "Please select a date", "select_date_first": "Please select a date",
"invalid_time": "Please select a future time" "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": { "auto_reply_messages": {
"dialog": { "dialog": {
"add_message": "Add Message", "add_message": "Add Message",

View File

@@ -50,6 +50,7 @@ class Global : ConfigContainer() {
val disableMemoriesSnapFeed = boolean("disable_memories_snap_feed") val disableMemoriesSnapFeed = boolean("disable_memories_snap_feed")
val spotlightCommentsUsername = boolean("spotlight_comments_username") { requireRestart() } val spotlightCommentsUsername = boolean("spotlight_comments_username") { requireRestart() }
val spotlightCommentsUsernameIcon = string("spotlight_comments_username_icon", "[👤]") { 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( val bypassVideoLengthRestriction = unique("bypass_video_length_restriction", "split", "single") { addNotices(
FeatureNotice.BAN_RISK); requireRestart() } FeatureNotice.BAN_RISK); requireRestart() }
val defaultVideoPlaybackRate = float("default_video_playback_rate", 1.0F) { requireRestart(); inputCheck = { (it.toFloatOrNull() ?: 1.0F) in 0.1F..4.0F} } val defaultVideoPlaybackRate = float("default_video_playback_rate", 1.0F) { requireRestart(); inputCheck = { (it.toFloatOrNull() ?: 1.0F) in 0.1F..4.0F} }

View File

@@ -123,6 +123,7 @@ class FeatureManager(
PreventForcedLogout(), PreventForcedLogout(),
ConversationToolbox(), ConversationToolbox(),
SpotlightCommentsUsername(), SpotlightCommentsUsername(),
SpotlightCreatorInfo(),
OperaViewerParamsOverride(), OperaViewerParamsOverride(),
StealthModeIndicator(), StealthModeIndicator(),
DisablePermissionRequests(), DisablePermissionRequests(),

View File

@@ -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<String, String>()
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<Any>()).getObjectField(viewStateField.get()!!).toString()
val operaLayerList = (param.thisObject<Any>()).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<CreatorInfo?>(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<Snapchatter?>(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))
}
}