feat(lxmf): update conversation previews to include image, audio, and file attachment notifications and add related tests

This commit is contained in:
Ivan
2026-05-06 16:32:21 -05:00
parent 83db6cedfd
commit 06710bcd69
15 changed files with 355 additions and 9 deletions
+24 -1
View File
@@ -152,7 +152,7 @@ def lxmf_sidebar_preview_for_conversation_latest_row(
local_hash: str,
peer_display_name: str,
) -> str:
"""Single-line preview for conversation list APIs (reactions have empty body)."""
"""Single-line preview for conversation list APIs (reactions and some media have empty body)."""
content = row.get("content")
if content is not None and str(content).strip():
return str(content)
@@ -202,6 +202,29 @@ def lxmf_sidebar_preview_for_conversation_latest_row(
return f"{actor} requested your location"
return f"{actor} sent a location request"
image = fields.get("image")
if isinstance(image, dict) and image:
if actor == "You":
return "You sent an image"
return f"{actor} sent an image"
audio = fields.get("audio")
if isinstance(audio, dict) and audio:
if actor == "You":
return "You sent a voice note"
return f"{actor} sent a voice note"
file_attachments = fields.get("file_attachments")
if isinstance(file_attachments, list) and len(file_attachments) > 0:
n = len(file_attachments)
if n == 1:
if actor == "You":
return "You sent a file"
return f"{actor} sent a file"
if actor == "You":
return f"You sent {n} files"
return f"{actor} sent {n} files"
return str(content or "")
@@ -48,7 +48,8 @@
v-if="
selectedPeerPath ||
selectedPeerSignalMetrics?.snr != null ||
selectedPeerLxmfStampInfo?.stamp_cost
selectedPeerLxmfStampInfo?.stamp_cost ||
lxmfHasOutboundTicket
"
class="flex items-center gap-2 min-w-0"
>
@@ -77,9 +78,30 @@
>
</span>
<span v-if="selectedPeerLxmfStampInfo?.stamp_cost" class="flex items-center gap-2 shrink-0">
<span
v-if="selectedPeerLxmfStampInfo?.stamp_cost || lxmfHasOutboundTicket"
class="flex items-center gap-1 shrink-0"
>
<span class="text-gray-300 dark:text-zinc-700 opacity-50">•</span>
<MaterialDesignIcon
v-if="lxmfHasOutboundTicket"
icon-name="ticket-confirmation"
class="size-3.5 shrink-0"
:class="
lxmfStampTicketValid
? 'text-emerald-600 dark:text-emerald-400'
: 'text-amber-600 dark:text-amber-500'
"
:title="
lxmfStampTicketValid
? $t('messages.stamp_ticket_valid', {
expires: lxmfStampTicketExpiresRelative,
})
: $t('messages.stamp_ticket_expired')
"
/>
<span
v-if="selectedPeerLxmfStampInfo?.stamp_cost"
class="cursor-pointer hover:text-gray-700 dark:hover:text-zinc-200"
title="LXMF stamp requirement"
@click="$emit('stamp-info-click', selectedPeerLxmfStampInfo)"
@@ -115,11 +137,15 @@
<script>
import Utils from "../../js/Utils";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import IconButton from "../IconButton.vue";
import LxmfUserIcon from "../LxmfUserIcon.vue";
import ConversationDropDownMenu from "./ConversationDropDownMenu.vue";
dayjs.extend(relativeTime);
export default {
name: "ConversationPeerHeader",
components: {
@@ -176,6 +202,34 @@ export default {
destinationDisplay() {
return Utils.formatDestinationHash(this.selectedPeer?.destination_hash);
},
lxmfHasOutboundTicket() {
return this.selectedPeerLxmfStampInfo?.outbound_ticket_expiry != null;
},
lxmfStampTicketExpiryMs() {
const e = this.selectedPeerLxmfStampInfo?.outbound_ticket_expiry;
if (e == null) {
return null;
}
const n = Number(e);
if (!Number.isFinite(n)) {
return null;
}
return n * 1000;
},
lxmfStampTicketValid() {
const ms = this.lxmfStampTicketExpiryMs;
if (ms == null) {
return false;
}
return ms > Date.now();
},
lxmfStampTicketExpiresRelative() {
const ms = this.lxmfStampTicketExpiryMs;
if (ms == null) {
return "";
}
return dayjs(ms).fromNow();
},
},
};
</script>
@@ -3130,9 +3130,7 @@ export default {
}
);
// do nothing if response is for a previous request
if (seq !== this.lxmfMessagesRequestSequence) {
console.log("ignoring response for previous lxmf messages request");
return;
}
@@ -4153,12 +4151,35 @@ export default {
lxmfImageUrl(hash) {
return `/api/v1/lxmf-messages/attachment/${hash}/image`;
},
lxmfDataUrlFromOutboundJobImage(img) {
if (!img?.image_bytes || typeof img.image_bytes !== "string") {
return null;
}
const raw = (img.image_type || "png").toLowerCase().replace(/^image\//, "");
let mime = "image/png";
if (raw === "jpg" || raw === "jpeg") {
mime = "image/jpeg";
} else if (raw === "png" || raw === "gif" || raw === "webp" || raw === "bmp") {
mime = `image/${raw}`;
} else if (raw === "webm") {
mime = "video/webm";
} else if (raw === "svg" || raw === "svg+xml") {
mime = "image/svg+xml";
} else {
mime = `image/${raw}`;
}
return `data:${mime};base64,${img.image_bytes}`;
},
pendingOutboundImageSrc(chatItem) {
const prev = chatItem.lxmf_message?.fields?.image?._preview_url;
if (prev) {
return prev;
}
return this.lxmfImageUrl(chatItem.lxmf_message.hash);
const h = chatItem.lxmf_message?.hash;
if (typeof h === "string" && h.startsWith("pending-")) {
return "";
}
return this.lxmfImageUrl(h);
},
removePendingOutboundPlaceholder(hash) {
if (!hash) {
@@ -4836,10 +4857,12 @@ export default {
job.pendingHash = pendingHash;
const pendingFields = {};
if (job.images.length > 0) {
const previewUrl =
job.imagePreviewUrls[0] || this.lxmfDataUrlFromOutboundJobImage(job.images[0]);
pendingFields.image = {
image_type: job.images[0].image_type,
image_size: job.images[0].image_size,
_preview_url: job.imagePreviewUrls[0],
_preview_url: previewUrl,
};
}
this.chatItems.push({
@@ -1,7 +1,8 @@
/**
* One-line preview text for the conversation list / sidebar (LXMF latest row).
* Handles plain content, Columba reactions, location/telemetry fields, and
* Sideband location-request commands. Pairs with the Python helper
* Handles plain content, Columba reactions, location/telemetry fields,
* Sideband location-request commands, and media-only payloads (image, audio,
* file attachments). Pairs with the Python helper
* lxmf_sidebar_preview_for_conversation_latest_row.
*/
@@ -102,5 +103,41 @@ export function lxmfConversationListPreview(msg, { myLxmfAddressHash, peerDispla
return msg?.is_incoming ? `${name} requested your location` : `${name} sent a location request`;
}
const imageField = fields?.image;
if (imageField && typeof imageField === "object" && Object.keys(imageField).length > 0) {
const fromSelf = isOutboundFromSelf(msg, myLxmfAddressHash);
if (typeof t === "function") {
return fromSelf ? t("messages.conversation_image_you") : t("messages.conversation_image_other", { name });
}
return fromSelf ? "You sent an image" : `${name} sent an image`;
}
const audioField = fields?.audio;
if (audioField && typeof audioField === "object" && Object.keys(audioField).length > 0) {
const fromSelf = isOutboundFromSelf(msg, myLxmfAddressHash);
if (typeof t === "function") {
return fromSelf ? t("messages.conversation_voice_you") : t("messages.conversation_voice_other", { name });
}
return fromSelf ? "You sent a voice note" : `${name} sent a voice note`;
}
const files = fields?.file_attachments;
if (Array.isArray(files) && files.length > 0) {
const fromSelf = isOutboundFromSelf(msg, myLxmfAddressHash);
const n = files.length;
if (typeof t === "function") {
if (n === 1) {
return fromSelf ? t("messages.conversation_file_you") : t("messages.conversation_file_other", { name });
}
return fromSelf
? t("messages.conversation_files_you", { count: n })
: t("messages.conversation_files_other", { name, count: n });
}
if (n === 1) {
return fromSelf ? "You sent a file" : `${name} sent a file`;
}
return fromSelf ? `You sent ${n} files` : `${name} sent ${n} files`;
}
return raw ?? "";
}
+10
View File
@@ -1175,6 +1175,8 @@
"hops_away": "{count} Hops entfernt",
"snr": "SNR {snr}",
"stamp_cost": "Stempelkosten {cost}",
"stamp_ticket_valid": "Ausgehendes Stempel-Ticket aktiv (sofortiger Versand). Läuft ab {expires}.",
"stamp_ticket_expired": "Stempel-Ticket abgelaufen; die nächste Nachricht kann einen Proof-of-Work erfordern.",
"pop_out_chat": "Chat auslagern",
"custom_display_name": "Benutzerdefinierter Anzeigename",
"stranger_banner_text": "Dieser Peer ist nicht in Ihren Kontakten. Anhänge von Fremden werden blockiert.",
@@ -1379,6 +1381,14 @@
"conversation_telemetry_stream_preview": "{name} hat einen Telemetrie-Datenstrom gesendet",
"conversation_location_request_in_preview": "{name} hat den Standort angefordert",
"conversation_location_request_out_preview": "{name} hat eine Standortanfrage gesendet",
"conversation_image_you": "Du hast ein Bild gesendet",
"conversation_image_other": "{name} hat ein Bild gesendet",
"conversation_voice_you": "Du hast eine Sprachnachricht gesendet",
"conversation_voice_other": "{name} hat eine Sprachnachricht gesendet",
"conversation_file_you": "Du hast eine Datei gesendet",
"conversation_file_other": "{name} hat eine Datei gesendet",
"conversation_files_you": "Du hast {count} Dateien gesendet",
"conversation_files_other": "{name} hat {count} Dateien gesendet",
"message_not_found_in_cache": "Nachricht nicht im Cache gefunden"
},
"nomadnet": {
+10
View File
@@ -1123,6 +1123,8 @@
"hops_away": "{count} hops away",
"snr": "SNR {snr}",
"stamp_cost": "Stamp Cost {cost}",
"stamp_ticket_valid": "Outbound stamp ticket active (instant send). Expires {expires}.",
"stamp_ticket_expired": "Stamp ticket expired; the next message may require proof-of-work.",
"pop_out_chat": "Pop out chat",
"more_actions": "More actions",
"retry_failed": "Retry failed messages",
@@ -1327,6 +1329,14 @@
"conversation_telemetry_stream_preview": "{name} sent a telemetry stream",
"conversation_location_request_in_preview": "{name} requested your location",
"conversation_location_request_out_preview": "{name} sent a location request",
"conversation_image_you": "You sent an image",
"conversation_image_other": "{name} sent an image",
"conversation_voice_you": "You sent a voice note",
"conversation_voice_other": "{name} sent a voice note",
"conversation_file_you": "You sent a file",
"conversation_file_other": "{name} sent a file",
"conversation_files_you": "You sent {count} files",
"conversation_files_other": "{name} sent {count} files",
"message_not_found_in_cache": "Message not found in cache"
},
"settings": {
+10
View File
@@ -1123,6 +1123,8 @@
"hops_away": "{count}salta lejos",
"snr": "SNR{snr}",
"stamp_cost": "Costo de sello{cost}",
"stamp_ticket_valid": "Boleto de sello saliente activo (envío instantáneo). Caduca {expires}.",
"stamp_ticket_expired": "Boleto de sello caducado; el próximo mensaje puede requerir prueba de trabajo.",
"pop_out_chat": "Apágalo.",
"more_actions": "Más acciones",
"retry_failed": "Retry falló mensajes",
@@ -1327,6 +1329,14 @@
"conversation_telemetry_stream_preview": "{name} envió un flujo de telemetría",
"conversation_location_request_in_preview": "{name} solicitó tu ubicación",
"conversation_location_request_out_preview": "{name} envió una solicitud de ubicación",
"conversation_image_you": "Enviaste una imagen",
"conversation_image_other": "{name} envió una imagen",
"conversation_voice_you": "Enviaste una nota de voz",
"conversation_voice_other": "{name} envió una nota de voz",
"conversation_file_you": "Enviaste un archivo",
"conversation_file_other": "{name} envió un archivo",
"conversation_files_you": "Enviaste {count} archivos",
"conversation_files_other": "{name} envió {count} archivos",
"message_not_found_in_cache": "Mensaje no encontrado en caché"
},
"settings": {
+10
View File
@@ -1123,6 +1123,8 @@
"hops_away": "{count}houblon loin",
"snr": "SNR{snr}",
"stamp_cost": "Coût du timbre{cost}",
"stamp_ticket_valid": "Ticket de timbre sortant actif (envoi instantané). Expire {expires}.",
"stamp_ticket_expired": "Ticket de timbre expiré ; le prochain message peut nécessiter une preuve de travail.",
"pop_out_chat": "Clavardage",
"more_actions": "Autres actions",
"retry_failed": "Réessayer les messages échoués",
@@ -1327,6 +1329,14 @@
"conversation_telemetry_stream_preview": "{name} a envoyé un flux de télémétrie",
"conversation_location_request_in_preview": "{name} a demandé votre position",
"conversation_location_request_out_preview": "{name} a envoyé une demande de position",
"conversation_image_you": "Vous avez envoyé une image",
"conversation_image_other": "{name} a envoyé une image",
"conversation_voice_you": "Vous avez envoyé une note vocale",
"conversation_voice_other": "{name} a envoyé une note vocale",
"conversation_file_you": "Vous avez envoyé un fichier",
"conversation_file_other": "{name} a envoyé un fichier",
"conversation_files_you": "Vous avez envoyé {count} fichiers",
"conversation_files_other": "{name} a envoyé {count} fichiers",
"message_not_found_in_cache": "Message non trouvé dans cache"
},
"settings": {
+10
View File
@@ -1175,6 +1175,8 @@
"hops_away": "{count} salti di distanza",
"snr": "SNR {snr}",
"stamp_cost": "Costo Francobollo {cost}",
"stamp_ticket_valid": "Biglietto di francobollo in uscita attivo (invio istantaneo). Scade {expires}.",
"stamp_ticket_expired": "Biglietto scaduto; il prossimo messaggio potrebbe richiedere proof-of-work.",
"pop_out_chat": "Estrai chat",
"custom_display_name": "Nome Visualizzato Personalizzato",
"stranger_banner_text": "Questo peer non è nei tuoi contatti. Gli allegati da sconosciuti sono bloccati.",
@@ -1379,6 +1381,14 @@
"conversation_telemetry_stream_preview": "{name} ha inviato un flusso di telemetria",
"conversation_location_request_in_preview": "{name} ha richiesto la tua posizione",
"conversation_location_request_out_preview": "{name} ha inviato una richiesta di posizione",
"conversation_image_you": "Hai inviato un'immagine",
"conversation_image_other": "{name} ha inviato un'immagine",
"conversation_voice_you": "Hai inviato una nota vocale",
"conversation_voice_other": "{name} ha inviato una nota vocale",
"conversation_file_you": "Hai inviato un file",
"conversation_file_other": "{name} ha inviato un file",
"conversation_files_you": "Hai inviato {count} file",
"conversation_files_other": "{name} ha inviato {count} file",
"message_not_found_in_cache": "Messaggio non trovato nella cache"
},
"settings": {
+10
View File
@@ -1123,6 +1123,8 @@
"hops_away": "{count}hop weg",
"snr": "SNR{snr}",
"stamp_cost": "Postzegelkosten{cost}",
"stamp_ticket_valid": "Uitgaand postzegelticket actief (direct verzenden). Verloopt {expires}.",
"stamp_ticket_expired": "Postzegelticket verlopen; het volgende bericht kan proof-of-work vereisen.",
"pop_out_chat": "Pop out chat",
"more_actions": "Meer acties",
"retry_failed": "Opnieuw proberen van mislukte berichten",
@@ -1327,6 +1329,14 @@
"conversation_telemetry_stream_preview": "{name} stuurde een telemetriestroom",
"conversation_location_request_in_preview": "{name} vroeg om jouw locatie",
"conversation_location_request_out_preview": "{name} stuurde een locatieverzoek",
"conversation_image_you": "Je hebt een afbeelding gestuurd",
"conversation_image_other": "{name} heeft een afbeelding gestuurd",
"conversation_voice_you": "Je hebt een spraakbericht gestuurd",
"conversation_voice_other": "{name} heeft een spraakbericht gestuurd",
"conversation_file_you": "Je hebt een bestand gestuurd",
"conversation_file_other": "{name} heeft een bestand gestuurd",
"conversation_files_you": "Je hebt {count} bestanden gestuurd",
"conversation_files_other": "{name} heeft {count} bestanden gestuurd",
"message_not_found_in_cache": "Bericht niet gevonden in cache"
},
"settings": {
+10
View File
@@ -1175,6 +1175,8 @@
"hops_away": "в {count} прыжках",
"snr": "SNR {snr}",
"stamp_cost": "Стоимость штампа {cost}",
"stamp_ticket_valid": "Активный исходящий билет штампа (мгновенная отправка). Истекает {expires}.",
"stamp_ticket_expired": "Билет штампа истёк; следующее сообщение может потребовать proof-of-work.",
"pop_out_chat": "Открыть в отдельном окне",
"custom_display_name": "Свое имя для контакта",
"stranger_banner_text": "Этот узел не в ваших контактах. Вложения от незнакомцев заблокированы.",
@@ -1379,6 +1381,14 @@
"conversation_telemetry_stream_preview": "{name} отправил(а) поток телеметрии",
"conversation_location_request_in_preview": "{name} запросил(а) ваше местоположение",
"conversation_location_request_out_preview": "{name} отправил(а) запрос местоположения",
"conversation_image_you": "Вы отправили изображение",
"conversation_image_other": "{name} отправил(а) изображение",
"conversation_voice_you": "Вы отправили голосовое сообщение",
"conversation_voice_other": "{name} отправил(а) голосовое сообщение",
"conversation_file_you": "Вы отправили файл",
"conversation_file_other": "{name} отправил(а) файл",
"conversation_files_you": "Вы отправили файлов: {count}",
"conversation_files_other": "{name} отправил(а) файлов: {count}",
"message_not_found_in_cache": "Сообщение не найдено в кэше"
},
"nomadnet": {
+10
View File
@@ -1123,6 +1123,8 @@
"hops_away": "{count} 跳远",
"snr": "SNR {snr} dB",
"stamp_cost": "邮戳成本 {cost}",
"stamp_ticket_valid": "出站邮戳票据有效(即时发送)。将于 {expires} 过期。",
"stamp_ticket_expired": "邮戳票据已过期;下一条消息可能需要工作量证明。",
"pop_out_chat": "弹出聊天",
"more_actions": "更多操作",
"retry_failed": "重试失败的消息",
@@ -1327,6 +1329,14 @@
"conversation_telemetry_stream_preview": "{name} 发送了遥测流",
"conversation_location_request_in_preview": "{name} 请求你的位置",
"conversation_location_request_out_preview": "{name} 发送了位置请求",
"conversation_image_you": "你发送了一张图片",
"conversation_image_other": "{name} 发送了一张图片",
"conversation_voice_you": "你发送了一条语音",
"conversation_voice_other": "{name} 发送了一条语音",
"conversation_file_you": "你发送了一个文件",
"conversation_file_other": "{name} 发送了一个文件",
"conversation_files_you": "你发送了 {count} 个文件",
"conversation_files_other": "{name} 发送了 {count} 个文件",
"message_not_found_in_cache": "缓存中未找到消息"
},
"settings": {
+68
View File
@@ -416,3 +416,71 @@ def test_sidebar_preview_telemetry_stream():
peer_display_name="Jordan",
)
assert out == "Jordan sent a telemetry stream"
def test_sidebar_preview_image_incoming():
row = {
"content": "",
"fields": json.dumps({"image": {"image_type": "png", "image_size": 12}}),
"is_incoming": 1,
"source_hash": "b" * 32,
}
out = lxmf_sidebar_preview_for_conversation_latest_row(
row,
local_hash="a" * 32,
peer_display_name="Quinn",
)
assert out == "Quinn sent an image"
def test_sidebar_preview_image_outbound_you():
me = "c" * 32
row = {
"content": "",
"fields": json.dumps({"image": {"image_type": "jpeg", "image_size": 99}}),
"is_incoming": 0,
"source_hash": me,
}
out = lxmf_sidebar_preview_for_conversation_latest_row(
row,
local_hash=me,
peer_display_name="Pat",
)
assert out == "You sent an image"
def test_sidebar_preview_audio_voice_note():
row = {
"content": "",
"fields": json.dumps({"audio": {"audio_mode": "opus", "audio_size": 500}}),
"is_incoming": 1,
"source_hash": "b" * 32,
}
out = lxmf_sidebar_preview_for_conversation_latest_row(
row,
local_hash="a" * 32,
peer_display_name="Morgan",
)
assert out == "Morgan sent a voice note"
def test_sidebar_preview_file_attachments_plural():
row = {
"content": "",
"fields": json.dumps(
{
"file_attachments": [
{"file_name": "a.txt", "file_size": 1},
{"file_name": "b.txt", "file_size": 2},
],
},
),
"is_incoming": 0,
"source_hash": "d" * 32,
}
out = lxmf_sidebar_preview_for_conversation_latest_row(
row,
local_hash="d" * 32,
peer_display_name="Casey",
)
assert out == "You sent 2 files"
@@ -653,6 +653,10 @@ class TestNotificationsGetUserFacingFilter:
body = await self._get(bell_app, unread="true", limit=10)
assert body["unread_count"] == 1
assert len(body["notifications"]) == 1
peer_label = f"peer-{PEER_HASH[:6]}"
assert body["notifications"][0]["latest_message_preview"] == (
f"{peer_label} sent an image"
)
async def test_badge_count_matches_dropdown_items(self, bell_app):
# Mix of user-facing and silent messages across multiple peers must
@@ -96,4 +96,61 @@ describe("lxmfConversationListPreview", () => {
);
expect(s).toBe("Riley requested your location");
});
it("shows image preview when body is empty", () => {
const s = lxmfConversationListPreview(
{
content: "",
is_incoming: true,
source_hash: peer,
fields: { image: { image_type: "png", image_size: 10 } },
},
{ myLxmfAddressHash: me, peerDisplayName: "Jo" }
);
expect(s).toBe("Jo sent an image");
});
it("shows outbound image preview as You", () => {
const s = lxmfConversationListPreview(
{
content: "",
is_incoming: false,
source_hash: me,
fields: { image: { image_type: "webp", image_size: 20 } },
},
{ myLxmfAddressHash: me, peerDisplayName: "Jo" }
);
expect(s).toBe("You sent an image");
});
it("shows voice note preview for audio field", () => {
const s = lxmfConversationListPreview(
{
content: "",
is_incoming: true,
source_hash: peer,
fields: { audio: { audio_mode: "opus", audio_size: 100 } },
},
{ myLxmfAddressHash: me, peerDisplayName: "Max" }
);
expect(s).toBe("Max sent a voice note");
});
it("shows multiple file attachment preview", () => {
const s = lxmfConversationListPreview(
{
content: "",
is_incoming: false,
source_hash: me,
fields: {
file_attachments: [
{ file_name: "a.bin", file_size: 1 },
{ file_name: "b.bin", file_size: 2 },
],
},
},
{ myLxmfAddressHash: me, peerDisplayName: "Jo" }
);
expect(s).toBe("You sent 2 files");
});
});