Add extra info tooltips to Spinner.

This commit is contained in:
Cody Henthorne
2025-12-22 09:28:23 -05:00
committed by jeffrey-signal
parent f2582cae54
commit 952990c8af
8 changed files with 604 additions and 4 deletions

View File

@@ -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<String, List<String>>): PluginResult {
@@ -32,7 +40,7 @@ class ApiPlugin : Plugin {
val apiButtons = apis.keys.joinToString("\n") { apiName ->
"""
<div style="margin-bottom: 20px;">
<button id="btn_$apiName" style="padding: 10px; margin-right: 10px;">Call $PATH?api=$apiName</button>
<button id="btn_$apiName" style="padding: 10px; margin-right: 10px;">Call $PATH?api=$apiName&id=1</button>
<span id="result_$apiName" style="font-weight: bold;"></span>
</div>
""".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<String, List<String>>): 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<String, List<String>>): 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<String, List<String>>): 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<LocalBackup>)
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))
}
}

View File

@@ -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)),

View File

@@ -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 {
"""<div class="popup" data-recipient-id="$it">$it<span class="tooltip">Loading...</span></div>"""
}
} else if (threadIdColumns.contains(columnName)) {
default?.let {
"""<div class="popup" data-thread-id="$it">$it<span class="tooltip">Loading...</span></div>"""
}
} else if (messageIdColumns.contains(columnName)) {
default?.let {
"""<div class="popup" data-message-id="$it">$it<span class="tooltip">Loading...</span></div>"""
}
} else {
default
}
}
}

View File

@@ -71,5 +71,11 @@
</form>
{{> partials/suffix}}
{{> partials/tooltips}}
<script type="text/javascript">
initializeTooltips();
</script>
</body>
</html>

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -0,0 +1,2 @@
<link rel="stylesheet" href="/css/tooltips.css">
<script src="/js/tooltips.js"></script>

View File

@@ -49,6 +49,8 @@
{{> partials/suffix}}
{{> partials/tooltips}}
<script src="https://cdnjs.cloudflare.com/ajax/libs/sql-formatter/4.0.2/sql-formatter.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.32.5/ace.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.32.5/mode-sql.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
@@ -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',