diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/ApiPlugin.kt b/app/src/spinner/java/org/thoughtcrime/securesms/ApiPlugin.kt index 47ffd6f540..90679c7ae3 100644 --- a/app/src/spinner/java/org/thoughtcrime/securesms/ApiPlugin.kt +++ b/app/src/spinner/java/org/thoughtcrime/securesms/ApiPlugin.kt @@ -8,8 +8,13 @@ package org.thoughtcrime.securesms import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonProperty import org.signal.core.util.logging.Log +import org.signal.core.util.orNull import org.signal.spinner.Plugin import org.signal.spinner.PluginResult +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId import org.whispersystems.signalservice.internal.util.JsonUtil class ApiPlugin : Plugin { @@ -22,7 +27,10 @@ class ApiPlugin : Plugin { override val path: String = PATH private val apis = mapOf( - "localBackups" to ::localBackups + "localBackups" to ::localBackups, + "recipient" to ::recipient, + "thread" to ::thread, + "message" to ::message ) override fun get(parameters: Map>): PluginResult { @@ -32,7 +40,7 @@ class ApiPlugin : Plugin { val apiButtons = apis.keys.joinToString("\n") { apiName -> """
- +
""".trimIndent() @@ -45,7 +53,7 @@ class ApiPlugin : Plugin { resultSpan.textContent = 'Loading...'; try { - const response = await fetch('$PATH?api=$apiName'); + const response = await fetch('$PATH?api=$apiName&id=1'); if (!response.ok) { const errorText = await response.text(); resultSpan.textContent = 'Error: ' + errorText; @@ -98,7 +106,137 @@ class ApiPlugin : Plugin { return PluginResult.JsonResult(JsonUtil.toJson(PluginCache.localBackups)) } + private fun recipient(parameters: Map>): PluginResult { + val recipientId = parameters["id"]?.firstOrNull()?.toLongOrNull() ?: return PluginResult.ErrorResult.notFound("recipient not found") + + val recipient = Recipient.resolved(RecipientId.from(recipientId)) + + val groupType = when { + recipient.isMmsGroup -> "MMS" + recipient.isPushV2Group -> "Signal V2" + recipient.isPushV1Group -> "Signal V1" + recipient.isPushGroup -> "Signal" + else -> null + } + + return RecipientInfo( + name = recipient.getDisplayName(AppDependencies.application), + aci = recipient.aci.map { it.toString() }.orNull(), + pni = recipient.pni.map { it.toString() }.orNull(), + e164 = recipient.e164.orNull(), + username = recipient.username.orNull(), + nickname = recipient.nickname.toString().takeIf { it.isNotBlank() }, + note = recipient.note, + about = recipient.about, + aboutEmoji = recipient.aboutEmoji, + isBlocked = recipient.isBlocked, + isSystemContact = recipient.isSystemContact, + isGroup = recipient.isGroup, + groupId = recipient.groupId.map { it.toString() }.orNull(), + groupType = groupType, + isActiveGroup = recipient.isActiveGroup, + participantCount = if (recipient.isGroup) recipient.participantIds.size else null + ).toJsonResult() + } + + private fun thread(parameters: Map>): PluginResult { + val threadId = parameters["id"]?.firstOrNull()?.toLongOrNull() ?: return PluginResult.ErrorResult.notFound("thread not found") + + val threadRecord = SignalDatabase.threads.getThreadRecord(threadId) ?: return PluginResult.ErrorResult(message = "Thread not found") + + return ThreadInfo( + threadId = threadRecord.threadId, + recipientName = threadRecord.recipient.getDisplayName(AppDependencies.application), + recipientId = threadRecord.recipient.id.toLong(), + unreadCount = threadRecord.unreadCount, + snippet = threadRecord.body, + date = threadRecord.date, + archived = threadRecord.isArchived, + pinned = threadRecord.isPinned, + unreadSelfMentionsCount = threadRecord.unreadSelfMentionsCount + ).toJsonResult() + } + + private fun message(parameters: Map>): PluginResult { + val messageId = parameters["id"]?.firstOrNull()?.toLongOrNull() ?: return PluginResult.ErrorResult.notFound("message not found") + + val messageRecord = try { + SignalDatabase.messages.getMessageRecord(messageId) + } catch (e: Exception) { + return PluginResult.ErrorResult(message = "Message not found: ${e.message}") + } + + val messageType = when { + messageRecord.isUpdate -> "Update" + messageRecord.isOutgoing -> "Outgoing" + else -> "Incoming" + } + + return MessageInfo( + messageId = messageRecord.id, + threadId = messageRecord.threadId, + body = messageRecord.body, + fromRecipientName = messageRecord.fromRecipient.getDisplayName(AppDependencies.application), + fromRecipientId = messageRecord.fromRecipient.id.toLong(), + toRecipientName = messageRecord.toRecipient.getDisplayName(AppDependencies.application), + toRecipientId = messageRecord.toRecipient.id.toLong(), + dateSent = messageRecord.dateSent, + dateReceived = messageRecord.dateReceived, + isOutgoing = messageRecord.isOutgoing, + type = messageType + ).toJsonResult() + } + data class LocalBackups @JsonCreator constructor(@field:JsonProperty val backups: List) data class LocalBackup @JsonCreator constructor(@field:JsonProperty val name: String, @field:JsonProperty val timestamp: Long) + + data class RecipientInfo @JsonCreator constructor( + @field:JsonProperty val name: String, + @field:JsonProperty val aci: String? = null, + @field:JsonProperty val pni: String? = null, + @field:JsonProperty val e164: String? = null, + @field:JsonProperty val username: String? = null, + @field:JsonProperty val nickname: String? = null, + @field:JsonProperty val note: String? = null, + @field:JsonProperty val about: String? = null, + @field:JsonProperty val aboutEmoji: String? = null, + @field:JsonProperty val isBlocked: Boolean = false, + @field:JsonProperty val isSystemContact: Boolean = false, + @field:JsonProperty val isGroup: Boolean = false, + @field:JsonProperty val groupId: String? = null, + @field:JsonProperty val groupType: String? = null, + @field:JsonProperty val isActiveGroup: Boolean = false, + @field:JsonProperty val participantCount: Int? = null + ) + + data class ThreadInfo @JsonCreator constructor( + @field:JsonProperty val threadId: Long, + @field:JsonProperty val recipientName: String, + @field:JsonProperty val recipientId: Long, + @field:JsonProperty val unreadCount: Int, + @field:JsonProperty val snippet: String? = null, + @field:JsonProperty val date: Long, + @field:JsonProperty val archived: Boolean = false, + @field:JsonProperty val pinned: Boolean = false, + @field:JsonProperty val unreadSelfMentionsCount: Int = 0 + ) + + data class MessageInfo @JsonCreator constructor( + @field:JsonProperty val messageId: Long, + @field:JsonProperty val threadId: Long, + @field:JsonProperty val body: String? = null, + @field:JsonProperty val fromRecipientName: String, + @field:JsonProperty val fromRecipientId: Long, + @field:JsonProperty val toRecipientName: String? = null, + @field:JsonProperty val toRecipientId: Long? = null, + @field:JsonProperty val dateSent: Long, + @field:JsonProperty val dateReceived: Long, + @field:JsonProperty val isOutgoing: Boolean, + @field:JsonProperty val type: String + ) + + fun Any.toJsonResult(): PluginResult.JsonResult { + return PluginResult.JsonResult(JsonUtil.toJson(this)) + } } diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt b/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt index 520e66304b..6b5733f37e 100644 --- a/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt +++ b/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt @@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.database.AttachmentTransformer import org.thoughtcrime.securesms.database.DatabaseMonitor import org.thoughtcrime.securesms.database.GV2Transformer import org.thoughtcrime.securesms.database.GV2UpdateTransformer +import org.thoughtcrime.securesms.database.IdPopupTransformer import org.thoughtcrime.securesms.database.IsStoryTransformer import org.thoughtcrime.securesms.database.JobDatabase import org.thoughtcrime.securesms.database.KeyValueDatabase @@ -72,7 +73,8 @@ class SpinnerApplicationContext : ApplicationContext() { KyberKeyTransformer, RecipientTransformer, AttachmentTransformer, - PollTransformer + PollTransformer, + IdPopupTransformer ) ), "jobmanager" to DatabaseConfig(db = { JobDatabase.getInstance(this).sqlCipherDatabase }, columnTransformers = listOf(TimestampTransformer)), diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/database/IdPopupTransformer.kt b/app/src/spinner/java/org/thoughtcrime/securesms/database/IdPopupTransformer.kt new file mode 100644 index 0000000000..8636692836 --- /dev/null +++ b/app/src/spinner/java/org/thoughtcrime/securesms/database/IdPopupTransformer.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.database + +import android.database.Cursor +import org.signal.spinner.ColumnTransformer +import org.signal.spinner.DefaultColumnTransformer + +object IdPopupTransformer : ColumnTransformer { + + private val recipientColumns = setOf( + MessageTable.FROM_RECIPIENT_ID, + MessageTable.TO_RECIPIENT_ID, + ThreadTable.RECIPIENT_ID + ) + + private val threadIdColumns = setOf( + MessageTable.THREAD_ID + ) + + private val messageIdColumns = setOf( + AttachmentTable.MESSAGE_ID, + ThreadTable.SNIPPET_MESSAGE_ID + ) + + override fun matches(tableName: String?, columnName: String): Boolean { + return recipientColumns.contains(columnName) || threadIdColumns.contains(columnName) || messageIdColumns.contains(columnName) + } + + override fun transform(tableName: String?, columnName: String, cursor: Cursor): String? { + val default = DefaultColumnTransformer.transform(tableName, columnName, cursor) + + return if (recipientColumns.contains(columnName)) { + default?.let { + """""" + } + } else if (threadIdColumns.contains(columnName)) { + default?.let { + """""" + } + } else if (messageIdColumns.contains(columnName)) { + default?.let { + """""" + } + } else { + default + } + } +} diff --git a/spinner/lib/src/main/assets/browse.hbs b/spinner/lib/src/main/assets/browse.hbs index 532911df8f..a88b9e5954 100644 --- a/spinner/lib/src/main/assets/browse.hbs +++ b/spinner/lib/src/main/assets/browse.hbs @@ -71,5 +71,11 @@ {{> partials/suffix}} + + {{> partials/tooltips}} + + diff --git a/spinner/lib/src/main/assets/css/tooltips.css b/spinner/lib/src/main/assets/css/tooltips.css new file mode 100644 index 0000000000..071c7b03e1 --- /dev/null +++ b/spinner/lib/src/main/assets/css/tooltips.css @@ -0,0 +1,73 @@ +td { + overflow: visible !important; +} + +td pre { + overflow: visible !important; +} + +.popup { + position: relative; + cursor: help; + text-decoration: underline dotted; + display: inline-block; +} + +.popup .tooltip { + visibility: hidden; + background-color: #555; + color: #fff; + text-align: left; + border-radius: 4px; + padding: 8px 12px; + position: absolute; + z-index: 1000; + bottom: 200%; + left: 50%; + transform: translateX(-50%); + white-space: pre-wrap; + max-width: 600px; + min-width: 200px; + opacity: 0; + transition: opacity 0.3s; + font-size: 12px; + line-height: 1.5; +} + +.popup .tooltip.align-left { + left: 0; + transform: translateX(0); +} + +.popup .tooltip.align-right { + left: auto; + right: 0; + transform: translateX(0); +} + +.popup .tooltip::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: #555 transparent transparent transparent; +} + +.popup .tooltip.align-left::after { + left: 20px; + margin-left: 0; +} + +.popup .tooltip.align-right::after { + left: auto; + right: 20px; + margin-left: 0; +} + +.popup:hover .tooltip { + visibility: visible; + opacity: 1; +} diff --git a/spinner/lib/src/main/assets/js/tooltips.js b/spinner/lib/src/main/assets/js/tooltips.js new file mode 100644 index 0000000000..3abe50f05d --- /dev/null +++ b/spinner/lib/src/main/assets/js/tooltips.js @@ -0,0 +1,324 @@ +function adjustTooltipPosition(tooltipSpan) { + // Reset any previous alignment classes + tooltipSpan.classList.remove('align-left', 'align-right'); + + // Get tooltip and viewport dimensions + const tooltipRect = tooltipSpan.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + + // Check if tooltip overflows on the left + if (tooltipRect.left < 0) { + tooltipSpan.classList.add('align-left'); + } + // Check if tooltip overflows on the right + else if (tooltipRect.right > viewportWidth) { + tooltipSpan.classList.add('align-right'); + } +} + +function initializeRecipientTooltips() { + const popupElements = document.querySelectorAll('.popup[data-recipient-id]'); + const cache = {}; + + popupElements.forEach(element => { + const recipientId = element.getAttribute('data-recipient-id'); + const tooltipSpan = element.querySelector('.tooltip'); + + if (!recipientId || !tooltipSpan) { + return; + } + + let hasLoaded = false; + + element.addEventListener('mouseenter', async function() { + if (hasLoaded) { + return; + } + + if (cache[recipientId]) { + tooltipSpan.textContent = cache[recipientId]; + adjustTooltipPosition(tooltipSpan); + hasLoaded = true; + return; + } + + try { + const response = await fetch(`/api?api=recipient&id=${recipientId}`); + if (!response.ok) { + tooltipSpan.textContent = `Error loading recipient`; + hasLoaded = true; + return; + } + + const data = await response.json(); + + const lines = []; + + // Name (primary) + let nameLine = data.isGroup ? `Group: ${data.name || recipientId}` : `Name: ${data.name || recipientId}`; + if (data.nickname) { + nameLine += ` (${data.nickname})`; + } + lines.push(nameLine); + + // Group-specific information + if (data.isGroup) { + if (data.groupType) { + lines.push(`Type: ${data.groupType}`); + } + if (data.groupId) { + lines.push(`Group ID: ${data.groupId}`); + } + if (data.participantCount !== null && data.participantCount !== undefined) { + const memberText = data.participantCount === 1 ? 'member' : 'members'; + lines.push(`Members: ${data.participantCount} ${memberText}`); + } + const groupStatus = data.isActiveGroup ? 'Active' : 'Inactive'; + lines.push(`Status: ${groupStatus}`); + } else { + // Individual-specific information + // ACI + if (data.aci) { + lines.push(`ACI: ${data.aci}`); + } + + // PNI + if (data.pni) { + lines.push(`PNI: ${data.pni}`); + } + + // Username + if (data.username) { + lines.push(`Username: @${data.username}`); + } + + // Phone number + if (data.e164) { + lines.push(`Phone: ${data.e164}`); + } + + // About/Bio + if (data.about) { + const aboutText = data.aboutEmoji ? `${data.aboutEmoji} ${data.about}` : data.about; + lines.push(`About: ${aboutText}`); + } + + // Note + if (data.note) { + lines.push(`Note: 📝 ${data.note}`); + } + + // Status indicators + const statusParts = []; + if (data.isBlocked) { + statusParts.push('🚫 Blocked'); + } + if (data.isSystemContact) { + statusParts.push('📱 Contact'); + } + if (statusParts.length > 0) { + lines.push(`Status: ${statusParts.join(' | ')}`); + } + } + + const displayText = lines.join('\n'); + tooltipSpan.textContent = displayText; + adjustTooltipPosition(tooltipSpan); + cache[recipientId] = displayText; + hasLoaded = true; + } catch (error) { + console.error('Error fetching recipient data:', error); + tooltipSpan.textContent = `Error: ${error.message}`; + hasLoaded = true; + } + }); + }); +} + +function initializeThreadTooltips() { + const popupElements = document.querySelectorAll('.popup[data-thread-id]'); + const cache = {}; + + popupElements.forEach(element => { + const threadId = element.getAttribute('data-thread-id'); + const tooltipSpan = element.querySelector('.tooltip'); + + if (!threadId || !tooltipSpan) { + return; + } + + let hasLoaded = false; + + element.addEventListener('mouseenter', async function() { + if (hasLoaded) { + return; + } + + if (cache[threadId]) { + tooltipSpan.textContent = cache[threadId]; + adjustTooltipPosition(tooltipSpan); + hasLoaded = true; + return; + } + + try { + const response = await fetch(`/api?api=thread&id=${threadId}`); + if (!response.ok) { + tooltipSpan.textContent = `Error loading thread`; + hasLoaded = true; + return; + } + + const data = await response.json(); + + const lines = []; + + // Thread ID + lines.push(`Thread ID: ${data.threadId}`); + + // Recipient + lines.push(`Recipient: ${data.recipientName} (ID: ${data.recipientId})`); + + // Unread count + if (data.unreadCount > 0) { + lines.push(`Unread: ${data.unreadCount}`); + } + + // Unread self mentions + if (data.unreadSelfMentionsCount > 0) { + lines.push(`Unread Mentions: ${data.unreadSelfMentionsCount}`); + } + + // Snippet + if (data.snippet) { + const snippetPreview = data.snippet.length > 50 + ? data.snippet.substring(0, 50) + '...' + : data.snippet; + lines.push(`Snippet: ${snippetPreview}`); + } + + // Date + if (data.date) { + const date = new Date(data.date); + lines.push(`Last Activity: ${date.toLocaleString()}`); + } + + // Status flags + const statusParts = []; + if (data.pinned) { + statusParts.push('📌 Pinned'); + } + if (data.archived) { + statusParts.push('📦 Archived'); + } + if (statusParts.length > 0) { + lines.push(`Status: ${statusParts.join(' | ')}`); + } + + const displayText = lines.join('\n'); + tooltipSpan.textContent = displayText; + adjustTooltipPosition(tooltipSpan); + cache[threadId] = displayText; + hasLoaded = true; + } catch (error) { + console.error('Error fetching thread data:', error); + tooltipSpan.textContent = `Error: ${error.message}`; + hasLoaded = true; + } + }); + }); +} + +function initializeMessageTooltips() { + const popupElements = document.querySelectorAll('.popup[data-message-id]'); + const cache = {}; + + popupElements.forEach(element => { + const messageId = element.getAttribute('data-message-id'); + const tooltipSpan = element.querySelector('.tooltip'); + + if (!messageId || !tooltipSpan) { + return; + } + + let hasLoaded = false; + + element.addEventListener('mouseenter', async function() { + if (hasLoaded) { + return; + } + + if (cache[messageId]) { + tooltipSpan.textContent = cache[messageId]; + adjustTooltipPosition(tooltipSpan); + hasLoaded = true; + return; + } + + try { + const response = await fetch(`/api?api=message&id=${messageId}`); + if (!response.ok) { + tooltipSpan.textContent = `Error loading message`; + hasLoaded = true; + return; + } + + const data = await response.json(); + + const lines = []; + + // Message ID + lines.push(`Message ID: ${data.messageId}`); + + // Type + lines.push(`Type: ${data.type}`); + + // From + lines.push(`From: ${data.fromRecipientName} (ID: ${data.fromRecipientId})`); + + // To + if (data.toRecipientName) { + lines.push(`To: ${data.toRecipientName} (ID: ${data.toRecipientId})`); + } + + // Thread ID + lines.push(`Thread ID: ${data.threadId}`); + + // Body + if (data.body) { + const bodyPreview = data.body.length > 50 + ? data.body.substring(0, 50) + '...' + : data.body; + lines.push(`Body: ${bodyPreview}`); + } + + // Dates + if (data.dateSent) { + const dateSent = new Date(data.dateSent); + lines.push(`Sent: ${dateSent.toLocaleString()}`); + } + + if (data.dateReceived) { + const dateReceived = new Date(data.dateReceived); + lines.push(`Received: ${dateReceived.toLocaleString()}`); + } + + const displayText = lines.join('\n'); + tooltipSpan.textContent = displayText; + adjustTooltipPosition(tooltipSpan); + cache[messageId] = displayText; + hasLoaded = true; + } catch (error) { + console.error('Error fetching message data:', error); + tooltipSpan.textContent = `Error: ${error.message}`; + hasLoaded = true; + } + }); + }); +} + +function initializeTooltips() { + initializeRecipientTooltips(); + initializeThreadTooltips(); + initializeMessageTooltips(); +} diff --git a/spinner/lib/src/main/assets/partials/tooltips.hbs b/spinner/lib/src/main/assets/partials/tooltips.hbs new file mode 100644 index 0000000000..2307a8aea7 --- /dev/null +++ b/spinner/lib/src/main/assets/partials/tooltips.hbs @@ -0,0 +1,2 @@ + + diff --git a/spinner/lib/src/main/assets/query.hbs b/spinner/lib/src/main/assets/query.hbs index 506f89271b..e24d3c9fef 100644 --- a/spinner/lib/src/main/assets/query.hbs +++ b/spinner/lib/src/main/assets/query.hbs @@ -49,6 +49,8 @@ {{> partials/suffix}} + {{> partials/tooltips}} + @@ -61,6 +63,7 @@ //document.querySelector('.query-input').addEventListener('keypress', submitOnEnter); document.getElementById('query-form').addEventListener('submit', onQuerySubmitted, false); renderQueryHistory(); + initializeTooltips(); editor = ace.edit(document.querySelector('.query-input'), { mode: 'ace/mode/sql',